pyMBE.pyMBE

   1#
   2# Copyright (C) 2023-2026 pyMBE-dev team
   3#
   4# This file is part of pyMBE.
   5#
   6# pyMBE is free software: you can redistribute it and/or modify
   7# it under the terms of the GNU General Public License as published by
   8# the Free Software Foundation, either version 3 of the License, or
   9# (at your option) any later version.
  10#
  11# pyMBE is distributed in the hope that it will be useful,
  12# but WITHOUT ANY WARRANTY; without even the implied warranty of
  13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  14# GNU General Public License for more details.
  15#
  16# You should have received a copy of the GNU General Public License
  17# along with this program.  If not, see <http://www.gnu.org/licenses/>.
  18
  19import espressomd
  20import re
  21import json
  22import pint
  23import numpy as np
  24import pandas as pd
  25import scipy.constants
  26import scipy.optimize
  27import logging
  28import importlib.resources
  29
  30# Database
  31from pyMBE.storage.manager import Manager
  32from pyMBE.storage.pint_quantity import PintQuantity
  33## Templates
  34from pyMBE.storage.templates.particle import ParticleTemplate, ParticleStateTemplate
  35from pyMBE.storage.templates.residue import ResidueTemplate
  36from pyMBE.storage.templates.molecule import MoleculeTemplate
  37from pyMBE.storage.templates.peptide import PeptideTemplate
  38from pyMBE.storage.templates.protein import ProteinTemplate
  39from pyMBE.storage.templates.hydrogel import HydrogelTemplate, HydrogelNode, HydrogelChain
  40from pyMBE.storage.templates.bond import BondTemplate
  41from pyMBE.storage.templates.angle import AngleTemplate
  42## Instances
  43from pyMBE.storage.instances.particle import ParticleInstance
  44from pyMBE.storage.instances.residue import ResidueInstance
  45from pyMBE.storage.instances.molecule import MoleculeInstance
  46from pyMBE.storage.instances.peptide import PeptideInstance
  47from pyMBE.storage.instances.protein import ProteinInstance
  48from pyMBE.storage.instances.bond import BondInstance
  49from pyMBE.storage.instances.angle import AngleInstance
  50from pyMBE.storage.instances.hydrogel import HydrogelInstance
  51
  52from pyMBE.simulation_builder.espresso_engine import EspressoSimulation
  53from pyMBE.simulation_builder.lammps_engine import LammpsSimulation 
  54from pyMBE.simulation_builder.base_engine import DummyEngine
  55from pyMBE.simulation_builder.engine_protocol import LammpsProtocol
  56## Reactions
  57from pyMBE.storage.reactions.reaction import Reaction, ReactionParticipant
  58# Utilities
  59import pyMBE.lib.handy_functions as hf
  60import pyMBE.storage.io as io
  61
  62class pymbe_library():
  63    """
  64    Core library of the Molecular Builder for ESPResSo (pyMBE).
  65
  66    Attributes:
  67        N_A ('pint.Quantity'):
  68            Avogadro number.
  69
  70        kB ('pint.Quantity'):
  71            Boltzmann constant.
  72
  73        e ('pint.Quantity'):
  74            Elementary charge.
  75
  76        kT ('pint.Quantity'):
  77            Thermal energy corresponding to the set temperature.
  78
  79        Kw ('pint.Quantity'):
  80            Ionic product of water, used in G-RxMC and Donnan-related calculations.
  81
  82        db ('Manager'):
  83            Database manager holding all pyMBE templates, instances and reactions.
  84
  85        rng ('numpy.random.Generator'):
  86            Random number generator initialized with the provided seed.
  87
  88        units ('pint.UnitRegistry'):
  89            Pint unit registry used for unit-aware calculations.
  90
  91        lattice_builder ('pyMBE.lib.lattice.LatticeBuilder'):
  92            Optional lattice builder object (initialized as ''None'').
  93            
  94        root ('importlib.resources.abc.Traversable'):
  95            Root path to the pyMBE package resources.
  96    """
  97
  98    def __init__(self, seed, temperature=None, unit_length=None, unit_charge=None, Kw=None):
  99        """
 100        Initializes the pyMBE library.
 101
 102        Args:
 103            seed ('int'):
 104                Seed for the random number generator.
 105
 106            temperature ('pint.Quantity', optional):
 107                Simulation temperature. If ''None'', defaults to 298.15 K.
 108
 109            unit_length ('pint.Quantity', optional):
 110                Reference length for reduced units. If ''None'', defaults to
 111                0.355 nm.
 112
 113            unit_charge ('pint.Quantity', optional):
 114                Reference charge for reduced units. If ''None'', defaults to
 115                one elementary charge.
 116
 117            Kw ('pint.Quantity', optional):
 118                Ionic product of water (typically in mol²/L²). If ''None'',
 119                defaults to 1e-14 mol²/L².
 120        """
 121        # Seed and RNG
 122        self.seed=seed
 123        self.rng = np.random.default_rng(seed)
 124        self.units=pint.UnitRegistry()
 125        self.N_A=scipy.constants.N_A / self.units.mol
 126        self.kB=scipy.constants.k * self.units.J / self.units.K
 127        self.e=scipy.constants.e * self.units.C
 128        self.set_reduced_units(unit_length=unit_length, 
 129                               unit_charge=unit_charge,
 130                               temperature=temperature, 
 131                               Kw=Kw)
 132        
 133        self.db = Manager(units=self.units)
 134        self.simulation_engine = DummyEngine()
 135        self.lattice_builder = None
 136        self.root = importlib.resources.files(__package__)
 137
 138    def _check_bond_inputs(self, bond_type, bond_parameters):
 139        """
 140        Checks that the input bond parameters are valid within the current pyMBE implementation.
 141
 142        Args:
 143            bond_type ('str'): 
 144                label to identify the potential to model the bond.
 145            
 146            bond_parameters ('dict'): 
 147                parameters of the potential of the bond.
 148        """
 149        valid_bond_types   = ["harmonic", "FENE"] 
 150        if bond_type not in valid_bond_types:
 151            raise NotImplementedError(f"Bond type '{bond_type}' currently not implemented in pyMBE, accepted types are {valid_bond_types}")
 152        required_parameters = {"harmonic": ["r_0","k"],
 153                                "FENE": ["r_0","k","d_r_max"]}
 154        for required_parameter in required_parameters[bond_type]:
 155            if required_parameter not in bond_parameters.keys():
 156                raise ValueError(f"Missing required parameter {required_parameter} for {bond_type} bond")
 157            
 158    def _check_dimensionality(self, variable, expected_dimensionality):
 159        """
 160        Checks if the dimensionality of 'variable' matches 'expected_dimensionality'.
 161
 162        Args:
 163            variable ('pint.Quantity'): 
 164                Quantity to be checked.
 165
 166            expected_dimensionality ('str'): 
 167                Expected dimension of the variable.
 168
 169        Returns:
 170            ('bool'): 
 171                'True' if the variable if of the expected dimensionality, 'False' otherwise.
 172
 173        Notes:
 174            - 'expected_dimensionality' takes dimensionality following the Pint standards [docs](https://pint.readthedocs.io/en/0.10.1/wrapping.html?highlight=dimensionality#checking-dimensionality).
 175            - For example, to check for a variable corresponding to a velocity 'expected_dimensionality = "[length]/[time]"'    
 176        """
 177        correct_dimensionality=variable.check(f"{expected_dimensionality}")      
 178        if not correct_dimensionality:
 179            raise ValueError(f"The variable {variable} should have a dimensionality of {expected_dimensionality}, instead the variable has a dimensionality of {variable.dimensionality}")
 180        return correct_dimensionality   
 181            
 182    def _check_pka_set(self, pka_set):
 183        """
 184        Checks that 'pka_set' has the formatting expected by pyMBE.
 185       
 186        Args:
 187            pka_set ('dict'): 
 188                {"name" : {"pka_value": pka, "acidity": acidity}}
 189        """
 190        required_keys=['pka_value','acidity']
 191        for required_key in required_keys:
 192            for pka_name, pka_entry in pka_set.items():
 193                if required_key not in pka_entry:
 194                    raise ValueError(f'missing a required key "{required_key}" in entry "{pka_name}" of pka_set ("{pka_entry}")')
 195
 196    def _create_hydrogel_chain(self, hydrogel_chain, nodes,box_l, use_default_bond=False, gen_angle=False):
 197        """
 198        Creates a chain between two nodes of a hydrogel.
 199
 200        Args:
 201            hydrogel_chain ('HydrogelChain'): 
 202                template of a hydrogel chain
 203            nodes ('dict'): 
 204                {node_index: {"name": node_particle_name, "pos": node_position, "id": node_particle_instance_id}}
 205            box_l('list[float,float,float]'): side length of the simulation box for x,y and z coordinates.
 206            use_default_bond ('bool', optional): 
 207                If True, use a default bond template if no specific template exists. Defaults to False.
 208
 209            gen_angle ('bool', optional):
 210                If True, generate the angle potentials internal to the created
 211                chain molecule. Junction angles near the hydrogel crosslinkers
 212                are handled separately at the hydrogel level.
 213
 214        Return:
 215            ('int'): 
 216                molecule_id of the created hydrogel chian.
 217
 218        Notes:
 219            - If the chain is defined between node_start = ''[0 0 0]'' and node_end = ''[1 1 1]'', the chain will be placed between these two nodes.
 220            - The chain will be placed in the direction of the vector between 'node_start' and 'node_end'. 
 221        """
 222        if self.lattice_builder is None:
 223            raise ValueError("LatticeBuilder is not initialized. Use 'initialize_lattice_builder' first.")
 224        molecule_tpl = self.db.get_template(pmb_type="molecule",
 225                                            name=hydrogel_chain.molecule_name)
 226        residue_list = molecule_tpl.residue_list
 227        molecule_name = molecule_tpl.name
 228        node_start = hydrogel_chain.node_start
 229        node_end = hydrogel_chain.node_end
 230        node_start_label = self.lattice_builder._create_node_label(node_start)
 231        node_end_label = self.lattice_builder._create_node_label(node_end)
 232        _, reverse = self.lattice_builder._get_node_vector_pair(node_start, node_end)
 233        if node_start == node_end and residue_list != residue_list[::-1]:
 234            raise ValueError(f"Aborted creation of hydrogel chain between '{node_start}' and '{node_end}' because pyMBE could not resolve a unique topology for that chain")
 235        if reverse:
 236            reverse_residue_order=True
 237        else:
 238            reverse_residue_order=False
 239        start_node_id = nodes[node_start_label]["id"]
 240        end_node_id = nodes[node_end_label]["id"]
 241        # Finding a backbone vector between node_start and node_end
 242        vec_between_nodes = np.array(nodes[node_end_label]["pos"]) - np.array(nodes[node_start_label]["pos"])
 243        vec_between_nodes = vec_between_nodes - self.lattice_builder.box_l * np.round(vec_between_nodes/self.lattice_builder.box_l)
 244        backbone_vector = vec_between_nodes / (self.lattice_builder.mpc+1)
 245        if reverse_residue_order:
 246            vec_between_nodes *= -1.0
 247        # Calculate the start position of the chain
 248        chain_residues = self.db.get_template(pmb_type="molecule",
 249                                              name=molecule_name).residue_list
 250        part_start_chain_name = self.db.get_template(pmb_type="residue",
 251                                                     name=chain_residues[0]).central_bead
 252        lj_parameters = self.get_lj_parameters(particle_name1=nodes[node_start_label]["name"],
 253                                               particle_name2=part_start_chain_name)
 254        bond_tpl = self.get_bond_template(particle_name1=nodes[node_start_label]["name"],
 255                                          particle_name2=part_start_chain_name,
 256                                          use_default_bond=use_default_bond)
 257        l0 = hf.calculate_initial_bond_length(lj_parameters=lj_parameters,
 258                                              bond_type=bond_tpl.bond_type,
 259                                              bond_parameters=bond_tpl.get_parameters(ureg=self.units))
 260        first_bead_pos = np.array((nodes[node_start_label]["pos"])) + np.array(backbone_vector)*l0
 261        mol_id = self.create_molecule(name=molecule_name,  # Use the name defined earlier
 262                                      number_of_molecules=1,  # Creating one chain
 263                                      box_l=box_l, ### Add lattice_builder box length size, this should be box_l=[self.lattice_builder.box_l]*3
 264                                      list_of_first_residue_positions=[first_bead_pos.tolist()], #Start at the first node
 265                                      backbone_vector=np.array(backbone_vector)/l0,
 266                                      use_default_bond=use_default_bond,
 267                                      reverse_residue_order=reverse_residue_order,
 268                                      gen_angle=gen_angle)[0]
 269        chain_pids = self.db._find_instance_ids_by_attribute(pmb_type="particle",
 270                                                             attribute="molecule_id",
 271                                                             value=mol_id)
 272        self.create_bond(particle_id1=start_node_id,particle_id2=chain_pids[0],use_default_bond=use_default_bond)
 273        self.create_bond(particle_id1=chain_pids[-1],particle_id2=end_node_id,use_default_bond=use_default_bond)
 274        return mol_id
 275
 276    def _generate_hydrogel_crosslinker_angles(self,  central_particle_ids):
 277        """
 278        Generate hydrogel angles centered on crosslinkers and adjacent terminal beads.
 279
 280        If the user defines any explicit angle template for such junction
 281        triplets, then all required junction triplets must be defined. If none
 282        are defined, hydrogel construction proceeds without crosslinker-adjacent
 283        angles.
 284        """
 285        particle_instances = self.db.get_instances(pmb_type="particle")
 286        bonded_neighbors = {}
 287        for bond in self.db.get_instances(pmb_type="bond").values():
 288            bonded_neighbors.setdefault(bond.particle_id1, set()).add(bond.particle_id2)
 289            bonded_neighbors.setdefault(bond.particle_id2, set()).add(bond.particle_id1)
 290
 291        triplets = []
 292        for central_particle_id in sorted(set(central_particle_ids)):
 293            neighbors = sorted(bonded_neighbors.get(central_particle_id, set()))
 294            central_name = particle_instances[central_particle_id].name
 295            for idx_i in range(len(neighbors)):
 296                for idx_k in range(idx_i + 1, len(neighbors)):
 297                    side_particle_id1 = neighbors[idx_i]
 298                    side_particle_id3 = neighbors[idx_k]
 299                    side_name1 = particle_instances[side_particle_id1].name
 300                    side_name3 = particle_instances[side_particle_id3].name
 301                    angle_key = AngleTemplate.make_angle_key(side1=side_name1,
 302                                                             central=central_name,
 303                                                             side2=side_name3)
 304                    triplets.append((side_particle_id1,
 305                                     central_particle_id,
 306                                     side_particle_id3,
 307                                     angle_key))
 308
 309        defined_angle_templates = self.db.get_templates(pmb_type="angle")
 310        defined_angle_keys = {angle_key for _, _, _, angle_key in triplets if angle_key in defined_angle_templates}
 311        if not defined_angle_keys:
 312            logging.warning("No angle templates defined for hydrogel crosslinkers")
 313            return
 314        missing_angle_keys = sorted({angle_key for _, _, _, angle_key in triplets if angle_key not in defined_angle_keys})
 315        if missing_angle_keys:
 316            raise ValueError("Hydrogel crosslinker-adjacent angle templates must be defined for all required triplets. "
 317                             f"Missing definitions for: {missing_angle_keys}")
 318        for side_particle_id1, central_particle_id, side_particle_id3, _ in triplets:
 319            self.create_angular_potential(particle_id1=side_particle_id1,
 320                                          particle_id2=central_particle_id,
 321                                          particle_id3=side_particle_id3,
 322                                          use_default_angle=False)
 323
 324    def _create_hydrogel_node(self, node_index, node_name,box_l):
 325        """
 326        Set a node residue type.
 327        
 328        Args:
 329            node_index ('str'): 
 330                Lattice node index in the form of a string, e.g. "[0 0 0]".
 331
 332            node_name ('str'): 
 333                name of the node particle defined in pyMBE.
 334            
 335            box_l('list[float,float,float]'): list of floats with the dimensions of the box
 336
 337        Returns:
 338            ('tuple(list,int)'):
 339                ('list'): Position of the node in the lattice.
 340                ('int'): Particle ID of the node.
 341        """
 342        if self.lattice_builder is None:
 343            raise ValueError("LatticeBuilder is not initialized. Use 'initialize_lattice_builder' first.")
 344        node_position = np.array(node_index)*0.25*self.lattice_builder.box_l
 345        p_id = self.create_particle(name = node_name,
 346                                    box_l=box_l,
 347                                    number_of_particles=1,
 348                                    position = [node_position])
 349        key = self.lattice_builder._get_node_by_label(f"[{node_index[0]} {node_index[1]} {node_index[2]}]")
 350        self.lattice_builder.nodes[key] = node_name
 351        return node_position.tolist(), p_id[0]
 352
 353    def _get_residue_list_from_sequence(self, sequence):
 354        """
 355        Convenience function to get a 'residue_list' from a protein or peptide 'sequence'.
 356
 357        Args:
 358            sequence ('lst'): 
 359                 Sequence of the peptide or protein.
 360
 361        Returns:
 362            residue_list ('list' of 'str'): 
 363                List of the 'name's of the 'residue's  in the sequence of the 'molecule'.             
 364        """
 365        residue_list = []
 366        for item in sequence:
 367            residue_name='AA-'+item
 368            residue_list.append(residue_name)
 369        return residue_list
 370    
 371    def _get_template_type(self, name, allowed_types):
 372        """
 373        Validate that a template name resolves unambiguously to exactly one
 374        allowed pmb_type in the pyMBE database and return it.
 375
 376        Args:
 377            name ('str'): 
 378                Name of the template to validate.
 379
 380            allowed_types ('set[str]'):  
 381                Set of allowed pmb_type values (e.g. {"molecule", "peptide"}).
 382
 383        Returns:
 384            ('str'): 
 385                Resolved pmb_type.
 386
 387        Notes:
 388            - This method does *not* return the template itself, only the validated pmb_type. 
 389        """
 390        registered_pmb_types_with_name = self.db._find_template_types(name=name)
 391        filtered_types = allowed_types.intersection(registered_pmb_types_with_name)
 392        if len(filtered_types) > 1:
 393            raise ValueError(f"Ambiguous template name '{name}': found {len(filtered_types)} templates in the pyMBE database. Molecule creation aborted.")  
 394        if len(filtered_types) == 0:
 395            raise ValueError(f"No {allowed_types} template found with name '{name}'. Found templates of types: {filtered_types}.")
 396        return next(iter(filtered_types))
 397
 398    def _delete_particles_from_engine(self, particle_ids):
 399        """
 400        Remove a list of particles from an ESPResSo simulation system.
 401
 402        Args:
 403            particle_ids  ('Iterable[int]'):
 404                A list (or other iterable) of ESPResSo particle IDs to remove.
 405
 406        Notes:
 407            - This method removes particles only from the ESPResSo simulation,
 408            **not** from the pyMBE database. Database cleanup must be handled
 409            separately by the caller.
 410            - Attempting to remove a non-existent particle ID will raise
 411            an ESPResSo error.
 412        """
 413        self.simulation_engine._delete_particles(particle_ids)
 414
 415    def add_instances_to_engine(self):
 416        self.simulation_engine.add_instances_to_engine()
 417
 418    def calculate_center_of_mass(self, instance_id, pmb_type):
 419        """
 420        Calculates the center of mass of a pyMBE object instance in an ESPResSo system.
 421
 422        Args:
 423            instance_id ('int'):
 424                pyMBE instance ID of the object whose center of mass is calculated.
 425
 426            pmb_type ('str'):
 427                Type of the pyMBE object. Must correspond to a particle-aggregating
 428                template type (e.g. '"molecule"', '"residue"', '"peptide"', '"protein"').
 429
 430        Returns:
 431            ('numpy.ndarray'):
 432                Array of shape '(3,)' containing the Cartesian coordinates of the
 433                center of mass.
 434
 435        Notes:
 436            - This method assumes equal mass for all particles.
 437            - Periodic boundary conditions are *not* unfolded; positions are taken
 438            directly from ESPResSo particle coordinates.
 439        """
 440        return self.simulation_engine.calculate_center_of_mass(instance_id=instance_id,
 441                                                               pmb_type=pmb_type)
 442    
 443    def calculate_HH(self, template_name, pH_list=None, pka_set=None):
 444        """
 445        Calculates the charge in the template object according to the ideal  Henderson–Hasselbalch titration curve.
 446
 447        Args:
 448            template_name ('str'):
 449                Name of the template.
 450
 451            pH_list ('list[float]', optional):
 452                pH values at which the charge is evaluated.
 453                Defaults to 50 values between 2 and 12.
 454
 455            pka_set ('dict', optional):
 456                Mapping: {particle_name: {"pka_value": 'float', "acidity": "acidic"|"basic"}}
 457
 458        Returns:
 459            'list[float]':
 460                Net molecular charge at each pH value.
 461        """
 462        if pH_list is None:
 463            pH_list = np.linspace(2, 12, 50)
 464        if pka_set is None:
 465            pka_set = self.get_pka_set()
 466        self._check_pka_set(pka_set=pka_set)
 467        particle_counts = self.db.get_particle_templates_under(template_name=template_name,
 468                                                               return_counts=True)
 469        if not particle_counts:
 470            return [None] * len(pH_list)
 471        charge_number_map = self.get_charge_number_map()
 472        def formal_charge(particle_name):
 473            tpl = self.db.get_template(name=particle_name, 
 474                                       pmb_type="particle")
 475            state = self.db.get_template(name=tpl.initial_state,
 476                                         pmb_type="particle_state")
 477            return charge_number_map[state.es_type]
 478        Z_HH = []
 479        for pH in pH_list:
 480            Z = 0.0
 481            for particle, multiplicity in particle_counts.items():
 482                if particle in pka_set:
 483                    pka = pka_set[particle]["pka_value"]
 484                    acidity = pka_set[particle]["acidity"]
 485                    if acidity == "acidic":
 486                        psi = -1
 487                    elif acidity == "basic":
 488                        psi = +1
 489                    else:
 490                        raise ValueError(f"Unknown acidity '{acidity}' for particle '{particle}'")
 491                    charge = psi / (1.0 + 10.0 ** (psi * (pH - pka)))
 492                    Z += multiplicity * charge
 493                else:
 494                    Z += multiplicity * formal_charge(particle)
 495            Z_HH.append(Z)
 496        return Z_HH   
 497
 498    def calculate_HH_Donnan(self, c_macro, c_salt, pH_list=None, pka_set=None):
 499        """
 500        Computes macromolecular charges using the Henderson–Hasselbalch equation
 501        coupled to ideal Donnan partitioning.
 502
 503        Args:
 504            c_macro ('dict'):
 505                Mapping of macromolecular species names to their concentrations
 506                in the system:
 507                '{molecule_name: concentration}'.
 508                
 509            c_salt ('float' or 'pint.Quantity'):
 510                Salt concentration in the reservoir.
 511
 512            pH_list ('list[float]', optional):
 513                List of pH values in the reservoir at which the calculation is
 514                performed. If 'None', 50 equally spaced values between 2 and 12
 515                are used.
 516
 517            pka_set ('dict', optional):
 518                Dictionary defining the acid–base properties of titratable particle
 519                types:
 520                '{particle_name: {"pka_value": float, "acidity": "acidic" | "basic"}}'.
 521                If 'None', the pKa set is taken from the pyMBE database.
 522
 523        Returns:
 524            'dict':
 525                Dictionary containing:
 526                - '"charges_dict"' ('dict'):
 527                    Mapping '{molecule_name: list}' of Henderson–Hasselbalch–Donnan
 528                    charges evaluated at each pH value.
 529                - '"pH_system_list"' ('list[float]'):
 530                    Effective pH values inside the system phase after Donnan
 531                    partitioning.
 532                - '"partition_coefficients"' ('list[float]'):
 533                    Partition coefficients of monovalent cations at each pH value.
 534
 535        Notes:
 536            - This method assumes **ideal Donnan equilibrium** and **monovalent salt**.
 537            - The ionic strength of the reservoir includes both salt and
 538            pH-dependent H⁺/OH⁻ contributions.
 539            - All charged macromolecular species present in the system must be
 540            included in 'c_macro'; missing species will lead to incorrect results.
 541            - The nonlinear Donnan equilibrium equation is solved using a scalar
 542            root finder ('brentq') in logarithmic form for numerical stability.
 543            - This method is intended for **two-phase systems**; for single-phase
 544            systems use 'calculate_HH' instead.
 545        """
 546        if pH_list is None:
 547            pH_list=np.linspace(2,12,50)
 548        if pka_set is None:
 549            pka_set=self.get_pka_set() 
 550        self._check_pka_set(pka_set=pka_set)
 551        partition_coefficients_list = []
 552        pH_system_list = []
 553        Z_HH_Donnan={}
 554        for key in c_macro:
 555            Z_HH_Donnan[key] = []
 556        def calc_charges(c_macro, pH):
 557            """
 558            Calculates the charges of the different kinds of molecules according to the Henderson-Hasselbalch equation.
 559
 560            Args:
 561                c_macro ('dict'): 
 562                    {"name": concentration} - A dict containing the concentrations of all charged macromolecular species in the system. 
 563
 564                pH ('float'): 
 565                    pH-value that is used in the HH equation.
 566
 567            Returns:
 568                ('dict'): 
 569                    {"molecule_name": charge}
 570            """
 571            charge = {}
 572            for name in c_macro:
 573                charge[name] = self.calculate_HH(name, [pH], pka_set)[0]
 574            return charge
 575
 576        def calc_partition_coefficient(charge, c_macro):
 577            """
 578            Calculates the partition coefficients of positive ions according to the ideal Donnan theory.
 579
 580            Args:
 581                charge ('dict'): 
 582                    {"molecule_name": charge}
 583
 584                c_macro ('dict'): 
 585                    {"name": concentration} - A dict containing the concentrations of all charged macromolecular species in the system. 
 586            """
 587            nonlocal ionic_strength_res
 588            charge_density = 0.0
 589            for key in charge:
 590                charge_density += charge[key] * c_macro[key]
 591            return (-charge_density / (2 * ionic_strength_res) + np.sqrt((charge_density / (2 * ionic_strength_res))**2 + 1)).magnitude
 592        for pH_value in pH_list:    
 593            # calculate the ionic strength of the reservoir
 594            if pH_value <= 7.0:
 595                ionic_strength_res = 10 ** (-pH_value) * self.units.mol/self.units.l + c_salt 
 596            elif pH_value > 7.0:
 597                ionic_strength_res = 10 ** (-(14-pH_value)) * self.units.mol/self.units.l + c_salt
 598            #Determine the partition coefficient of positive ions by solving the system of nonlinear, coupled equations
 599            #consisting of the partition coefficient given by the ideal Donnan theory and the Henderson-Hasselbalch equation.
 600            #The nonlinear equation is formulated for log(xi) since log-operations are not supported for RootResult objects.
 601            equation = lambda logxi: logxi - np.log10(calc_partition_coefficient(calc_charges(c_macro, pH_value - logxi), c_macro))
 602            logxi = scipy.optimize.root_scalar(equation, bracket=[-1e2, 1e2], method="brentq")
 603            partition_coefficient = 10**logxi.root
 604            charges_temp = calc_charges(c_macro, pH_value-np.log10(partition_coefficient))
 605            for key in c_macro:
 606                Z_HH_Donnan[key].append(charges_temp[key])
 607            pH_system_list.append(pH_value - np.log10(partition_coefficient))
 608            partition_coefficients_list.append(partition_coefficient)
 609        return {"charges_dict": Z_HH_Donnan, "pH_system_list": pH_system_list, "partition_coefficients": partition_coefficients_list}
 610
 611    def calculate_net_charge(self,object_name,pmb_type,dimensionless=False):
 612        """
 613        Calculates the net charge per instance of a given pmb object type.
 614
 615        Args:
 616            object_name (str):
 617                Name of the object (e.g. molecule, residue, peptide, protein).
 618            pmb_type (str):
 619                Type of object to analyze. Must be molecule-like.
 620            dimensionless (bool, optional):
 621                If True, return charge as a pure number.
 622                If False, return a quantity with reduced_charge units.
 623
 624        Returns:
 625            dict:
 626                {"mean": mean_net_charge, "instances": {instance_id: net_charge}}
 627        """
 628        return self.simulation_engine.calculate_net_charge(object_name,
 629                                                           pmb_type,
 630                                                           dimensionless)
 631    
 632    def center_object_in_simulation_box(self, instance_id, box_l,pmb_type):
 633        """
 634        Centers a pyMBE object instance in the simulation box of an ESPResSo system.
 635        The object is translated such that its center of mass coincides with the
 636        geometric center of the ESPResSo simulation box.
 637
 638        Args:
 639            instance_id ('int'):
 640                ID of the pyMBE object instance to be centered.
 641            
 642            box_l('list[float,float,float]'): list of floats with the dimensions of the box
 643
 644            pmb_type ('str'):
 645                Type of the pyMBE object.
 646
 647        Notes:
 648            - Works for both cubic and non-cubic simulation boxes.
 649        """
 650        inst = self.db.get_instance(instance_id=instance_id,
 651                                    pmb_type=pmb_type)
 652        center_of_mass = self.calculate_center_of_mass(instance_id=instance_id,
 653                                                       pmb_type=pmb_type)
 654        box_center = [box_l[0]/2.0,
 655                      box_l[1]/2.0,
 656                      box_l[2]/2.0]
 657        particle_id_list = self.get_particle_id_map(object_name=inst.name)["all"]
 658        for pid in particle_id_list:
 659            es_pos=self.db.get_instance(instance_id=pid,
 660                                    pmb_type='particle').position
 661            centered_position=es_pos - center_of_mass + box_center
 662
 663            self.db._update_instance(instance_id=pid,
 664                                     pmb_type='particle',
 665                                     attribute='position',
 666                                     value=centered_position)
 667            if isinstance(self.simulation_engine, EspressoSimulation):
 668                self.simulation_engine._update_particle_position(
 669                    particle_id=pid, position=centered_position)
 670
 671    def create_added_salt(self, box_l, cation_name, anion_name, c_salt):    
 672        """
 673        Creates a 'c_salt' concentration of 'cation_name' and 'anion_name' ions into the 'espresso_system'.
 674
 675        Args:
 676            cation_name('str'): 'name' of a particle with a positive charge.
 677            anion_name('str'): 'name' of a particle with a negative charge.
 678            c_salt('float'): Salt concentration.
 679            
 680        Returns:
 681            c_salt_calculated('float'): Calculated salt concentration added to 'espresso_system'.
 682        """ 
 683        cation_tpl = self.db.get_template(pmb_type="particle",
 684                                          name=cation_name)
 685        cation_state = self.db.get_template(pmb_type="particle_state",
 686                                            name=cation_tpl.initial_state)
 687        cation_charge = cation_state.z
 688        anion_tpl = self.db.get_template(pmb_type="particle",
 689                                          name=anion_name)
 690        anion_state = self.db.get_template(pmb_type="particle_state",
 691                                            name=anion_tpl.initial_state)
 692        anion_charge = anion_state.z
 693        if cation_charge <= 0:
 694            raise ValueError(f'ERROR cation charge must be positive, charge {cation_charge}')
 695        if anion_charge >= 0:
 696            raise ValueError(f'ERROR anion charge must be negative, charge {anion_charge}')
 697        # Calculate the number of ions in the simulation box
 698        volume=self.units.Quantity(np.prod(box_l), 'reduced_length**3')
 699        if c_salt.check('[substance] [length]**-3'):
 700            N_ions= int((volume*c_salt.to('mol/reduced_length**3')*self.N_A).magnitude)
 701            c_salt_calculated=N_ions/(volume*self.N_A)
 702        elif c_salt.check('[length]**-3'):
 703            N_ions= int((volume*c_salt.to('reduced_length**-3')).magnitude)
 704            c_salt_calculated=N_ions/volume
 705        else:
 706            raise ValueError('Unknown units for c_salt, please provided it in [mol / volume] or [particle / volume]', c_salt)
 707        N_cation = N_ions*abs(anion_charge)
 708        N_anion = N_ions*abs(cation_charge)
 709        self.create_particle(box_l=box_l, 
 710                             name=cation_name, 
 711                             number_of_particles=N_cation)
 712        self.create_particle(box_l=box_l, 
 713                             name=anion_name, 
 714                             number_of_particles=N_anion)
 715        if c_salt_calculated.check('[substance] [length]**-3'):
 716            logging.info(f"added salt concentration of {c_salt_calculated.to('mol/L')} given by {N_cation} cations and {N_anion} anions")
 717        elif c_salt_calculated.check('[length]**-3'):
 718            logging.info(f"added salt concentration of {c_salt_calculated.to('reduced_length**-3')} given by {N_cation} cations and {N_anion} anions")
 719        return c_salt_calculated
 720
 721    def create_bond(self, particle_id1, particle_id2, use_default_bond=False):
 722        """
 723        Creates a bond between two particle instances in an ESPResSo system and registers it in the pyMBE database.
 724
 725        This method performs the following steps:
 726            1. Retrieves the particle instances corresponding to 'particle_id1' and 'particle_id2' from the database.
 727            2. Retrieves or creates the corresponding ESPResSo bond instance using the bond template.
 728            3. Adds the ESPResSo bond instance to the ESPResSo system if it was newly created.
 729            4. Adds the bond to the first particle's bond list in ESPResSo.
 730            5. Creates a 'BondInstance' in the database and registers it.
 731
 732        Args:
 733            particle_id1 ('int'): 
 734                pyMBE and ESPResSo ID of the first particle.
 735
 736            particle_id2 ('int'): 
 737                pyMBE and ESPResSo ID of the second particle.
 738
 739            use_default_bond ('bool', optional): 
 740                If True, use a default bond template if no specific template exists. Defaults to False.
 741
 742        Returns:
 743            ('int'): 
 744                bond_id of the bond instance created in the pyMBE database.
 745        """
 746        particle_inst_1 = self.db.get_instance(pmb_type="particle",
 747                                               instance_id=particle_id1)
 748        particle_inst_2 = self.db.get_instance(pmb_type="particle",
 749                                               instance_id=particle_id2)
 750        bond_tpl = self.get_bond_template(particle_name1=particle_inst_1.name,
 751                                          particle_name2=particle_inst_2.name,
 752                                          use_default_bond=use_default_bond)
 753        bond_id = self.db._propose_instance_id(pmb_type="bond")
 754        pmb_bond_instance = BondInstance(bond_id=bond_id,
 755                                         name=bond_tpl.name,
 756                                         particle_id1=particle_id1,
 757                                         particle_id2=particle_id2)
 758        self.db._register_instance(instance=pmb_bond_instance)
 759
 760    def create_counterions(self, object_name, cation_name, anion_name, box_l):
 761        """
 762        Creates particles of 'cation_name' and 'anion_name' in 'espresso_system' to counter the net charge of 'object_name'.
 763        
 764        Args:
 765            object_name ('str'): 
 766                'name' of a pyMBE object.
 767
 768            cation_name ('str'): 
 769                'name' of a particle with a positive charge.
 770
 771            anion_name ('str'): 
 772                'name' of a particle with a negative charge.
 773            
 774            box_l('list[float,float,float]'): list of floats with the dimensions of the box
 775
 776        Returns: 
 777            ('dict'): 
 778                {"name": number}
 779
 780        Notes:
 781            This function currently does not support the creation of counterions for hydrogels.
 782        """ 
 783        cation_tpl = self.db.get_template(pmb_type="particle",
 784                                          name=cation_name)
 785        cation_state = self.db.get_template(pmb_type="particle_state",
 786                                            name=cation_tpl.initial_state)
 787        cation_charge = cation_state.z
 788        anion_tpl = self.db.get_template(pmb_type="particle",
 789                                          name=anion_name)
 790        anion_state = self.db.get_template(pmb_type="particle_state",
 791                                            name=anion_tpl.initial_state)
 792        anion_charge = anion_state.z
 793        object_ids = self.get_particle_id_map(object_name=object_name)["all"]
 794        counterion_number={}
 795        object_charge={}
 796        for name in ['positive', 'negative']:
 797            object_charge[name]=0
 798        for id in object_ids:
 799            object_name = self.db.get_instance(pmb_type="particle", 
 800                                               instance_id=id).name
 801            object_tpl = self.db.get_template(pmb_type="particle",
 802                                              name=object_name)
 803            object_state = self.db.get_template(pmb_type="particle_state",
 804                                                name=object_tpl.initial_state)
 805            object_z = object_state.z
 806            if object_z > 0:
 807                object_charge['positive']+=1*(np.abs(object_z ))
 808            elif object_z < 0:
 809                object_charge['negative']+=1*(np.abs(object_z ))
 810        if object_charge['positive'] % abs(anion_charge) == 0:
 811            counterion_number[anion_name]=int(object_charge['positive']/abs(anion_charge))
 812        else:
 813            raise ValueError('The number of positive charges in the pmb_object must be divisible by the  charge of the anion')
 814        if object_charge['negative'] % abs(cation_charge) == 0:
 815            counterion_number[cation_name]=int(object_charge['negative']/cation_charge)
 816        else:
 817            raise ValueError('The number of negative charges in the pmb_object must be divisible by the  charge of the cation')
 818        if counterion_number[cation_name] > 0: 
 819            self.create_particle(box_l=box_l, 
 820                                 name=cation_name, 
 821                                 number_of_particles=counterion_number[cation_name])
 822        else:
 823            counterion_number[cation_name]=0
 824        if counterion_number[anion_name] > 0:
 825            self.create_particle(box_l=box_l, 
 826                                 name=anion_name, 
 827                                 number_of_particles=counterion_number[anion_name])
 828        else:
 829            counterion_number[anion_name] = 0
 830        logging.info('the following counter-ions have been created: ')
 831        for name in counterion_number.keys():
 832            logging.info(f'Ion type: {name} created number: {counterion_number[name]}')
 833        return counterion_number
 834
 835
 836    def create_hydrogel(self, name, box_l, use_default_bond=False, gen_angle=False):
 837        """ 
 838        Creates a hydrogel in espresso_system using a pyMBE hydrogel template given by 'name'
 839
 840        Args:
 841            box_l('list[float,float,float]'): list of floats with the dimensions of the box
 842
 843            name ('str'): 
 844                name of the hydrogel template in the pyMBE database.
 845
 846            use_default_bond ('bool', optional): 
 847                If True, use a default bond template if no specific template exists. Defaults to False.
 848
 849            gen_angle ('bool', optional):
 850                If True, generate angle potentials for the internal hydrogel
 851                chains and, when explicitly defined, for all crosslinker-adjacent
 852                triplets. Defaults to False.
 853
 854        Returns:
 855            ('int'): id of the hydrogel instance created.
 856        """
 857        if not self.db._has_template(name=name, pmb_type="hydrogel"):
 858            raise ValueError(f"Hydrogel template with name '{name}' is not defined in the pyMBE database.")
 859        hydrogel_tpl = self.db.get_template(pmb_type="hydrogel",
 860                                            name=name)
 861        assembly_id = self.db._propose_instance_id(pmb_type="hydrogel")
 862        # Create the nodes
 863        nodes = {}
 864        hydrogel_angle_centers = set()
 865        node_topology = hydrogel_tpl.node_map
 866        for node in node_topology:
 867            node_index = node.lattice_index
 868            node_name = node.particle_name
 869            node_pos, node_id = self._create_hydrogel_node(node_index=node_index,
 870                                                          node_name=node_name,
 871                                                          box_l=box_l)
 872            node_label = self.lattice_builder._create_node_label(node_index=node_index)
 873            nodes[node_label] = {"name": node_name, "id": node_id, "pos": node_pos} 
 874            self.db._update_instance(instance_id=node_id,
 875                                     pmb_type="particle",
 876                                     attribute="assembly_id",
 877                                     value=assembly_id)
 878        for hydrogel_chain in hydrogel_tpl.chain_map:
 879            molecule_id = self._create_hydrogel_chain(hydrogel_chain=hydrogel_chain,
 880                                                      nodes=nodes,
 881                                                      box_l=box_l,
 882                                                      use_default_bond=use_default_bond,
 883                                                      gen_angle=gen_angle,
 884                                                      )
 885            self.db._update_instance(instance_id=molecule_id,
 886                                     pmb_type="molecule",
 887                                     attribute="assembly_id",
 888                                     value=assembly_id)
 889            if gen_angle:
 890                residue_ids = self.db._find_instance_ids_by_attribute(pmb_type="residue",
 891                                                                      attribute="molecule_id",
 892                                                                      value=molecule_id)
 893                first_residue_id = min(residue_ids)
 894                last_residue_id = max(residue_ids)
 895                first_residue = self.db.get_instance(pmb_type="residue",
 896                                                     instance_id=first_residue_id)
 897                last_residue = self.db.get_instance(pmb_type="residue",
 898                                                    instance_id=last_residue_id)
 899                first_central_bead_name = self.db.get_template(pmb_type="residue",
 900                                                               name=first_residue.name).central_bead
 901                last_central_bead_name = self.db.get_template(pmb_type="residue",
 902                                                              name=last_residue.name).central_bead
 903                particle_instances = self.db.get_instances(pmb_type="particle")
 904                first_residue_particle_ids = self.db._find_instance_ids_by_attribute(pmb_type="particle",
 905                                                                                      attribute="residue_id",
 906                                                                                      value=first_residue_id)
 907                last_residue_particle_ids = self.db._find_instance_ids_by_attribute(pmb_type="particle",
 908                                                                                     attribute="residue_id",
 909                                                                                     value=last_residue_id)
 910                first_bead_id = None
 911                for particle_id in first_residue_particle_ids:
 912                    if particle_instances[particle_id].name == first_central_bead_name:
 913                        first_bead_id = particle_id
 914                        break
 915
 916                last_bead_id = None
 917                for particle_id in last_residue_particle_ids:
 918                    if particle_instances[particle_id].name == last_central_bead_name:
 919                        last_bead_id = particle_id
 920                        break
 921                node_start_label = self.lattice_builder._create_node_label(hydrogel_chain.node_start)
 922                node_end_label = self.lattice_builder._create_node_label(hydrogel_chain.node_end)
 923                hydrogel_angle_centers.update({
 924                    nodes[node_start_label]["id"],
 925                    nodes[node_end_label]["id"],
 926                    first_bead_id,
 927                    last_bead_id,
 928                })
 929        self.db._propagate_id(root_type="hydrogel", 
 930                                root_id=assembly_id, 
 931                                attribute="assembly_id", 
 932                                value=assembly_id)
 933        if gen_angle:
 934            self._generate_hydrogel_crosslinker_angles(
 935                                                       central_particle_ids=hydrogel_angle_centers)
 936        # Register an hydrogel instance in the pyMBE databasegit 
 937        self.db._register_instance(HydrogelInstance(name=name,
 938                                                    assembly_id=assembly_id))
 939        return assembly_id
 940
 941
 942    def create_molecule(self, name, number_of_molecules, box_l, list_of_first_residue_positions=None, backbone_vector=None, use_default_bond=False, reverse_residue_order = False, gen_angle=False):
 943        """
 944        Creates instances of a given molecule template name into ESPResSo.
 945
 946        Args:
 947            name ('str'): 
 948                Label of the molecule type to be created. 'name'.
 949
 950            box_l('list[float,float,float]'): list of floats with the dimensions of the box
 951
 952            number_of_molecules ('int'): 
 953                Number of molecules or peptides of type 'name' to be created.
 954
 955            list_of_first_residue_positions ('list', optional): 
 956                List of coordinates where the central bead of the first_residue_position will be created, random by default.
 957
 958            backbone_vector ('list' of 'float'): 
 959                Backbone vector of the molecule, random by default. Central beads of the residues in the 'residue_list' are placed along this vector. 
 960
 961            use_default_bond('bool', optional): 
 962                Controls if a bond of type 'default' is used to bond particles with undefined bonds in the pyMBE database.
 963
 964            reverse_residue_order('bool', optional): 
 965                Creates residues in reverse sequential order than the one defined in the molecule template. Defaults to False.
 966
 967        Returns:
 968            ('list' of 'int'): 
 969                List with the 'molecule_id' of the pyMBE molecule instances created into 'espresso_system'.
 970
 971        Notes:
 972            - This function can be used to create both molecules and peptides.    
 973        """
 974        pmb_type = self._get_template_type(name=name,
 975                                           allowed_types={"molecule", "peptide"})
 976        if number_of_molecules <= 0:
 977            return {}
 978        if list_of_first_residue_positions is not None:
 979            for item in list_of_first_residue_positions:
 980                if not isinstance(item, list):
 981                    raise ValueError("The provided input position is not a nested list. Should be a nested list with elements of 3D lists, corresponding to xyz coord.")
 982                elif len(item) != 3:
 983                    raise ValueError("The provided input position is formatted wrong. The elements in the provided list does not have 3 coordinates, corresponding to xyz coord.")
 984
 985            if len(list_of_first_residue_positions) != number_of_molecules:
 986                raise ValueError(f"Number of positions provided in {list_of_first_residue_positions} does not match number of molecules desired, {number_of_molecules}")
 987        # Generate an arbitrary random unit vector
 988        if backbone_vector is None:
 989            backbone_vector = self.generate_random_points_in_a_sphere(center=[0,0,0],
 990                                                                      radius=1, 
 991                                                                      n_samples=1,
 992                                                                      on_surface=True)[0]
 993        else:
 994            backbone_vector = np.array(backbone_vector)
 995        first_residue = True
 996        molecule_tpl = self.db.get_template(pmb_type=pmb_type,
 997                                            name=name)
 998        if reverse_residue_order:
 999            residue_list = molecule_tpl.residue_list[::-1]
1000        else:
1001            residue_list = molecule_tpl.residue_list
1002        pos_index = 0 
1003        molecule_ids = []
1004        for n_mol in range(number_of_molecules):        
1005            molecule_id = self.db._propose_instance_id(pmb_type=pmb_type)
1006            for residue in residue_list:
1007                if first_residue:
1008                    if list_of_first_residue_positions is None:
1009                        central_bead_pos = None
1010                    else:
1011                        central_bead_pos = [np.array(list_of_first_residue_positions[n_mol])]
1012                            
1013                    residue_id = self.create_residue(name=residue,
1014                                                     box_l=box_l, 
1015                                                     central_bead_position=central_bead_pos,  
1016                                                     use_default_bond= use_default_bond, 
1017                                                     backbone_vector=backbone_vector)
1018                    
1019                    # Add molecule_id to the residue instance and all particles associated
1020                    self.db._propagate_id(root_type="residue", 
1021                                          root_id=residue_id,
1022                                          attribute="molecule_id", 
1023                                          value=molecule_id)
1024                    particle_ids_in_residue = self.db._find_instance_ids_by_attribute(pmb_type="particle",
1025                                                                                      attribute="residue_id",
1026                                                                                      value=residue_id)
1027                    prev_central_bead_id = particle_ids_in_residue[0]
1028                    prev_central_bead_name = self.db.get_instance(pmb_type="particle", 
1029                                                                  instance_id=prev_central_bead_id).name
1030                    prev_central_bead_pos = self.db.get_instance(pmb_type="particle", 
1031                                                                  instance_id=prev_central_bead_id).position
1032                    # prev_central_bead_pos = espresso_system.part.by_id(prev_central_bead_id).pos
1033                    first_residue = False          
1034                else:
1035                    
1036                    # Calculate the starting position of the new residue
1037                    residue_tpl = self.db.get_template(pmb_type="residue",
1038                                                       name=residue)
1039                    lj_parameters = self.get_lj_parameters(particle_name1=prev_central_bead_name,
1040                                                           particle_name2=residue_tpl.central_bead)
1041                    bond_tpl = self.get_bond_template(particle_name1=prev_central_bead_name,
1042                                                      particle_name2=residue_tpl.central_bead,
1043                                                      use_default_bond=use_default_bond)
1044                    l0 = hf.calculate_initial_bond_length(lj_parameters=lj_parameters,
1045                                                          bond_type=bond_tpl.bond_type,
1046                                                          bond_parameters=bond_tpl.get_parameters(ureg=self.units))
1047                    central_bead_pos = prev_central_bead_pos+backbone_vector*l0
1048                    # Create the residue
1049                    residue_id = self.create_residue(name=residue, 
1050                                                     box_l=box_l, 
1051                                                     central_bead_position=[central_bead_pos],
1052                                                     use_default_bond= use_default_bond, 
1053                                                     backbone_vector=backbone_vector)
1054                    # Add molecule_id to the residue instance and all particles associated
1055                    self.db._propagate_id(root_type="residue", 
1056                                          root_id=residue_id, 
1057                                          attribute="molecule_id", 
1058                                          value=molecule_id)
1059                    particle_ids_in_residue = self.db._find_instance_ids_by_attribute(pmb_type="particle",
1060                                                                                      attribute="residue_id",
1061                                                                                      value=residue_id)
1062                    central_bead_id = particle_ids_in_residue[0]
1063
1064                    # Bond the central beads of the new and previous residues
1065                    self.create_bond(particle_id1=prev_central_bead_id,
1066                                     particle_id2=central_bead_id,
1067                                     use_default_bond=use_default_bond)
1068                    
1069                    prev_central_bead_id = central_bead_id                    
1070                    prev_central_bead_name = self.db.get_instance(pmb_type="particle", instance_id=central_bead_id).name
1071                    prev_central_bead_pos =central_bead_pos
1072            # Create a Peptide or Molecule instance and register it on the pyMBE database
1073            if pmb_type == "molecule":
1074                inst = MoleculeInstance(molecule_id=molecule_id,
1075                                        name=name)
1076            elif pmb_type == "peptide":
1077                inst = PeptideInstance(name=name,
1078                                       molecule_id=molecule_id)
1079            self.db._register_instance(inst)
1080            if gen_angle:
1081                self._generate_angles_for_entity(
1082                    entity_id=molecule_id,
1083                    entity_id_col='molecule_id')
1084            first_residue = True
1085            pos_index+=1
1086            molecule_ids.append(molecule_id)
1087        return molecule_ids
1088    
1089    def create_particle(self, name, box_l, number_of_particles, position=None, fix=False):
1090        """
1091        Creates one or more particles in an ESPResSo system based on the particle template in the pyMBE database.
1092        
1093        Args:
1094            name ('str'): 
1095                Label of the particle template in the pyMBE database. 
1096            
1097            box_l('list[float,float,float]'): list of floats with the dimensions of the box
1098
1099            number_of_particles ('int'): 
1100                Number of particles to be created.
1101
1102            position (list of ['float','float','float'], optional): 
1103                Initial positions of the particles. If not given, particles are created in random positions. Defaults to None.
1104
1105            fix ('bool', optional): 
1106                Controls if the particle motion is frozen in the integrator, it is used to create rigid objects. Defaults to False.
1107
1108        Returns:
1109            ('list' of 'int'): 
1110                List with the ids of the particles created into 'espresso_system'.
1111        """       
1112        if number_of_particles <=0:
1113            return []
1114        if not self.db._has_template(name=name, pmb_type="particle"):
1115            raise ValueError(f"Particle template with name '{name}' is not defined in the pyMBE database.")
1116        
1117        part_tpl = self.db.get_template(pmb_type="particle",
1118                                        name=name)
1119        part_state = self.db.get_template(pmb_type="particle_state",
1120                                         name=part_tpl.initial_state)
1121        name_state=part_state.name
1122
1123        if fix is False:
1124            fix=[fix]*3
1125
1126        created_pid_list=[]
1127        for index in range(number_of_particles):
1128            if position is None:
1129                particle_position = self.rng.random((1, 3))[0] *np.copy(box_l)
1130            else:
1131                particle_position = np.array(position[index])
1132            
1133            particle_id = self.db._propose_instance_id(pmb_type="particle")
1134            created_pid_list.append(particle_id)
1135            part_inst = ParticleInstance(name=name,
1136                                         particle_id=particle_id,
1137                                         initial_state=name_state,
1138                                         position=particle_position,
1139                                         fix=fix)
1140            self.db._register_instance(part_inst)
1141                              
1142        return created_pid_list
1143
1144    def create_protein(self, name, number_of_proteins, box_l, topology_dict):
1145        """
1146        Creates one or more protein molecules in an ESPResSo system based on the 
1147        protein template in the pyMBE database and a provided topology.
1148
1149        Args:
1150            name (str):
1151                Name of the protein template stored in the pyMBE database.
1152            
1153            number_of_proteins (int):
1154                Number of protein molecules to generate.
1155
1156            box_l('list[float,float,float]'): list of floats with the dimensions of the box  
1157            
1158            topology_dict (dict):
1159                Dictionary defining the internal structure of the protein. Expected format:
1160                    {"ResidueName1": {"initial_pos": np.ndarray,
1161                                      "chain_id": int,
1162                                      "radius": float},
1163                     "ResidueName2": { ... },
1164                        ...
1165                    }
1166                The '"initial_pos"' entry is required and represents the residue’s
1167                reference coordinates before shifting to the protein's center-of-mass.
1168
1169        Returns:
1170            ('list' of 'int'): 
1171                List of the molecule_id of the Protein instances created into ESPResSo.
1172
1173        Notes:
1174            - Particles are created using 'create_particle()' with 'fix=True',
1175            meaning they are initially immobilized.
1176            - The function assumes all residues in 'topology_dict' correspond to
1177            particle templates already defined in the pyMBE database.
1178            - Bonds between residues are not created here; it assumes a rigid body representation of the protein.
1179        """
1180        if number_of_proteins <= 0:
1181            return
1182        if not self.db._has_template(name=name, pmb_type="protein"):
1183            raise ValueError(f"Protein template with name '{name}' is not defined in the pyMBE database.")
1184        protein_tpl = self.db.get_template(pmb_type="protein", name=name)
1185        box_half = box_l[0] / 2.0
1186        # Create protein
1187        mol_ids = []
1188        for _ in range(number_of_proteins):
1189            # create a molecule identifier in pyMBE
1190            molecule_id = self.db._propose_instance_id(pmb_type="protein")
1191            # place protein COM randomly
1192            protein_center = self.generate_coordinates_outside_sphere(radius=1,
1193                                                                      max_dist=box_half,
1194                                                                      n_samples=1,
1195                                                                      center=[box_half]*3)[0]
1196            residues = hf.get_residues_from_topology_dict(topology_dict=topology_dict,
1197                                                         model=protein_tpl.model)
1198            # CREATE RESIDUES + PARTICLES
1199            for _, rdata in residues.items():
1200                base_resname = rdata["resname"]  
1201                residue_name = f"AA-{base_resname}"
1202                # residue instance ID
1203                residue_id = self.db._propose_instance_id("residue")
1204                # register ResidueInstance
1205                self.db._register_instance(ResidueInstance(name=residue_name,
1206                                                           residue_id=residue_id,
1207                                                           molecule_id=molecule_id))
1208                # PARTICLE CREATION
1209                for bead_id in rdata["beads"]:
1210                    bead_type = re.split(r'\d+', bead_id)[0]
1211                    relative_pos = topology_dict[bead_id]["initial_pos"]
1212                    absolute_pos = relative_pos + protein_center
1213                    particle_id = self.create_particle(name=bead_type,
1214                                                       box_l=box_l,
1215                                                       number_of_particles=1,
1216                                                       position=[absolute_pos],
1217                                                       fix=[True,True,True])[0]
1218                    # update metadata
1219                    self.db._update_instance(instance_id=particle_id,
1220                                             pmb_type="particle",
1221                                             attribute="molecule_id",
1222                                             value=molecule_id)
1223                    self.db._update_instance(instance_id=particle_id,
1224                                             pmb_type="particle",
1225                                             attribute="residue_id",
1226                                             value=residue_id)
1227            protein_inst = ProteinInstance(name=name,
1228                                           molecule_id=molecule_id)
1229            self.db._register_instance(protein_inst)
1230            mol_ids.append(molecule_id)
1231        return mol_ids
1232
1233    def create_residue(self, name, box_l, central_bead_position=None,use_default_bond=False, backbone_vector=None, gen_angle=False):
1234        """
1235        Creates a residue  into ESPResSo.
1236
1237        Args:
1238            name ('str'): 
1239                Label of the residue type to be created. 
1240
1241            central_bead_position ('list' of 'float'): 
1242                Position of the central bead.
1243            
1244            box_l('list[float,float,float]'): list of floats with the dimensions of the box
1245
1246            use_default_bond ('bool'): 
1247                Switch to control if a bond of type 'default' is used to bond a particle whose bonds types are not defined in the pyMBE database.
1248
1249            backbone_vector ('list' of 'float'): 
1250                Backbone vector of the molecule. All side chains are created perpendicularly to 'backbone_vector'.
1251
1252        Returns:
1253            (int): 
1254                residue_id of the residue created.
1255        """
1256        if not self.db._has_template(name=name, pmb_type="residue"):
1257            raise ValueError(f"Residue template with name '{name}' is not defined in the pyMBE database.")
1258        res_tpl = self.db.get_template(pmb_type="residue",
1259                                       name=name)
1260        # Assign a residue_id
1261        residue_id = self.db._propose_instance_id(pmb_type="residue")
1262        res_inst = ResidueInstance(name=name,
1263                                   residue_id=residue_id)
1264        self.db._register_instance(res_inst)
1265        # create the principal bead   
1266        central_bead_name = res_tpl.central_bead 
1267        central_bead_id = self.create_particle(name=central_bead_name,
1268                                               box_l=box_l,
1269                                               position=central_bead_position,
1270                                               number_of_particles = 1)[0]
1271        
1272        central_bead_position = self.db.get_instance(pmb_type="particle", 
1273                                                                  instance_id=central_bead_id).position
1274        # # central_bead_position=espresso_system.part.by_id(central_bead_id).pos
1275
1276        # Assigns residue_id to the central_bead particle created.
1277        self.db._update_instance(pmb_type="particle",
1278                                 instance_id=central_bead_id,
1279                                 attribute="residue_id",
1280                                 value=residue_id)
1281        
1282        # create the lateral beads  
1283        side_chain_list = res_tpl.side_chains
1284        side_chain_beads_ids = []
1285        for side_chain_name in side_chain_list:
1286            pmb_type = self._get_template_type(name=side_chain_name,
1287                                               allowed_types={"particle", "residue"})
1288            if pmb_type == 'particle':
1289                lj_parameters = self.get_lj_parameters(particle_name1=central_bead_name,
1290                                                       particle_name2=side_chain_name)
1291                bond_tpl = self.get_bond_template(particle_name1=central_bead_name,
1292                                                  particle_name2=side_chain_name,
1293                                                  use_default_bond=use_default_bond)
1294                l0 = hf.calculate_initial_bond_length(lj_parameters=lj_parameters,
1295                                                      bond_type=bond_tpl.bond_type,
1296                                                      bond_parameters=bond_tpl.get_parameters(ureg=self.units))               
1297                if backbone_vector is None:
1298                    bead_position=self.generate_random_points_in_a_sphere(center=central_bead_position, 
1299                                                                radius=l0, 
1300                                                                n_samples=1,
1301                                                                on_surface=True)[0]
1302                else:
1303                    bead_position=central_bead_position+self.generate_trial_perpendicular_vector(vector=np.array(backbone_vector),
1304                                                                                                magnitude=l0)
1305                    
1306                side_bead_id = self.create_particle(name=side_chain_name, 
1307                                                    box_l=box_l,
1308                                                    position=[bead_position], 
1309                                                    number_of_particles=1)[0]
1310                side_chain_beads_ids.append(side_bead_id)
1311                self.db._update_instance(pmb_type="particle",
1312                                         instance_id=side_bead_id,
1313                                         attribute="residue_id",
1314                                         value=residue_id)
1315                self.create_bond(particle_id1=central_bead_id,
1316                                 particle_id2=side_bead_id,
1317                                 use_default_bond=use_default_bond)
1318                
1319            elif pmb_type == 'residue':
1320
1321                side_residue_tpl = self.db.get_template(name=side_chain_name,
1322                                                        pmb_type=pmb_type)
1323                central_bead_side_chain = side_residue_tpl.central_bead
1324                lj_parameters = self.get_lj_parameters(particle_name1=central_bead_name,
1325                                                       particle_name2=central_bead_side_chain)
1326                bond_tpl = self.get_bond_template(particle_name1=central_bead_name,
1327                                                  particle_name2=central_bead_side_chain,
1328                                                  use_default_bond=use_default_bond)
1329                l0 = hf.calculate_initial_bond_length(lj_parameters=lj_parameters,
1330                                                      bond_type=bond_tpl.bond_type,
1331                                                      bond_parameters=bond_tpl.get_parameters(ureg=self.units))
1332                if backbone_vector is None:
1333                    residue_position=self.generate_random_points_in_a_sphere(center=central_bead_position, 
1334                                                                radius=l0, 
1335                                                                n_samples=1,
1336                                                                on_surface=True)[0]
1337                else:
1338                    residue_position=central_bead_position+self.generate_trial_perpendicular_vector(vector=backbone_vector,
1339                                                                                                    magnitude=l0)
1340                side_residue_id = self.create_residue(name=side_chain_name, 
1341                                                      box_l=box_l,
1342                                                      central_bead_position=[residue_position],
1343                                                      use_default_bond=use_default_bond)
1344                # Find particle ids of the inner residue
1345                side_chain_beads_ids = self.db._find_instance_ids_by_attribute(pmb_type="particle",
1346                                                                               attribute="residue_id",
1347                                                                               value=side_residue_id)
1348                # Change the residue_id of the residue in the side chain to the one of the outer residue
1349                for particle_id in side_chain_beads_ids:
1350                    self.db._update_instance(instance_id=particle_id,
1351                                             pmb_type="particle",
1352                                             attribute="residue_id",
1353                                             value=residue_id)
1354                # Remove the instance of the inner residue
1355                self.db.delete_instance(pmb_type="residue",
1356                                        instance_id=side_residue_id)
1357                self.create_bond(particle_id1=central_bead_id,
1358                                 particle_id2=side_chain_beads_ids[0],
1359                                 use_default_bond=use_default_bond)
1360        if gen_angle:
1361            self._generate_angles_for_entity(
1362                                             entity_id=residue_id,
1363                                             entity_id_col="residue_id")
1364        return  residue_id
1365
1366    def define_bond(self, bond_type, bond_parameters, particle_pairs):
1367        """
1368        Defines bond templates for each particle pair in 'particle_pairs' in the pyMBE database.
1369
1370        Args:
1371            bond_type ('str'): 
1372                label to identify the potential to model the bond.
1373
1374            bond_parameters ('dict'): 
1375                parameters of the potential of the bond.
1376
1377            particle_pairs ('lst'): 
1378                list of the 'names' of the 'particles' to be bonded.
1379
1380        Notes:
1381            -Currently, only HARMONIC and FENE bonds are supported.
1382            - For a HARMONIC bond the dictionary must contain the following parameters:
1383                - k ('pint.Quantity')      : Magnitude of the bond. It should have units of energy/length**2 
1384                using the 'pmb.units' UnitRegistry.
1385                - r_0 ('pint.Quantity')    : Equilibrium bond length. It should have units of length using 
1386                the 'pmb.units' UnitRegistry.
1387           - For a FENE bond the dictionary must contain the same parameters as for a HARMONIC bond and:              
1388                - d_r_max ('pint.Quantity'): Maximal stretching length for FENE. It should have 
1389                units of length using the 'pmb.units' UnitRegistry. Default 'None'.
1390        """
1391        self._check_bond_inputs(bond_parameters=bond_parameters,
1392                                bond_type=bond_type)
1393        parameters_expected_dimensions={"r_0": "length",
1394                                        "k": "energy/length**2",
1395                                        "d_r_max": "length"}
1396
1397        parameters_tpl = {}
1398        for key in bond_parameters.keys():
1399            parameters_tpl[key]= PintQuantity.from_quantity(q=bond_parameters[key],
1400                                                            expected_dimension=parameters_expected_dimensions[key],
1401                                                            ureg=self.units)
1402
1403        bond_names=[]
1404        for particle_name1, particle_name2 in particle_pairs:
1405            
1406            tpl = BondTemplate(particle_name1=particle_name1,
1407                               particle_name2=particle_name2,
1408                               parameters=parameters_tpl,
1409                               bond_type=bond_type)
1410            tpl._make_name()
1411            if tpl.name in bond_names:
1412                raise RuntimeError(f"Bond {tpl.name} has already been defined, please check the list of particle pairs")
1413            bond_names.append(tpl.name)
1414            self.db._register_template(tpl)
1415
1416    
1417    def define_default_bond(self, bond_type, bond_parameters):
1418        """
1419        Defines a bond template as a "default" template in the pyMBE database.
1420        
1421        Args:
1422            bond_type ('str'): 
1423                label to identify the potential to model the bond.
1424
1425            bond_parameters ('dict'): 
1426                parameters of the potential of the bond.
1427            
1428        Notes:
1429            - Currently, only harmonic and FENE bonds are supported. 
1430        """
1431        self._check_bond_inputs(bond_parameters=bond_parameters,
1432                                bond_type=bond_type)
1433        parameters_expected_dimensions={"r_0": "length",
1434                                        "k": "energy/length**2",
1435                                        "d_r_max": "length"}
1436        parameters_tpl = {}
1437        for key in bond_parameters.keys():
1438            parameters_tpl[key]= PintQuantity.from_quantity(q=bond_parameters[key],
1439                                                            expected_dimension=parameters_expected_dimensions[key],
1440                                                            ureg=self.units)
1441        tpl = BondTemplate(parameters=parameters_tpl,
1442                               bond_type=bond_type)
1443        tpl.name = "default"
1444        self.db._register_template(tpl)
1445
1446    def define_angular_potential(self, angle_type, angle_parameters, particle_triplets):
1447        """
1448        Defines angle potential templates for each particle triplet in `particle_triplets`.
1449
1450        Args:
1451            angle_type ('str'):
1452                Type of angle potential. Supported: "harmonic", "cosine", "harmonic_cosine".
1453
1454            angle_parameters ('dict'):
1455                Parameters of the angle potential. Must contain:
1456                    - "k" ('pint.Quantity'): Bending stiffness with dimensions of energy.
1457                    - "phi_0" ('float'): Equilibrium angle in radians.
1458
1459            particle_triplets ('list[tuple[str,str,str]]'):
1460                List of (side_particle1, central_particle, side_particle2) triplets.
1461        """
1462        valid_angle_types = ["harmonic", "cosine", "harmonic_cosine"]
1463        if angle_type not in valid_angle_types:
1464            raise NotImplementedError(f"Angle potential type '{angle_type}' currently not implemented in pyMBE, accepted types are {valid_angle_types}")
1465
1466        if "k" not in angle_parameters:
1467            raise ValueError("Magnitude of the angle potential (k) is missing")
1468        if "phi_0" not in angle_parameters:
1469            raise ValueError("Equilibrium angle (phi_0) is missing")
1470
1471        parameters_tpl = {"k": PintQuantity.from_quantity(q=angle_parameters["k"],
1472                                                          expected_dimension="energy",
1473                                                          ureg=self.units),
1474                          "phi_0": PintQuantity.from_quantity(q=angle_parameters["phi_0"],
1475                                                              expected_dimension="dimensionless",
1476                                                              ureg=self.units),}
1477        angle_names = []
1478        for side1, central, side2 in particle_triplets:
1479            tpl = AngleTemplate(side_particle1=side1,
1480                                central_particle=central,
1481                                side_particle2=side2,
1482                                parameters=parameters_tpl,
1483                                angle_type=angle_type)
1484            tpl._make_name()
1485            if tpl.name in angle_names:
1486                raise RuntimeError(f"Angle {tpl.name} has already been defined, please check the list of particle triplets")
1487            angle_names.append(tpl.name)
1488            self.db._register_template(tpl)
1489
1490    def define_default_angular_potential(self, angle_type, angle_parameters):
1491        """
1492        Defines an angle template as a "default" template in the pyMBE database.
1493
1494        Args:
1495            angle_type ('str'):
1496                Type of angle potential. Supported: "harmonic", "cosine", "harmonic_cosine".
1497
1498            angle_parameters ('dict'):
1499                Parameters of the angle potential (k, phi_0).
1500        """
1501        valid_angle_types = ["harmonic", "cosine", "harmonic_cosine"]
1502        if angle_type not in valid_angle_types:
1503            raise NotImplementedError(f"Angle potential type '{angle_type}' currently not implemented in pyMBE, accepted types are {valid_angle_types}")
1504        if "k" not in angle_parameters:
1505            raise ValueError("Magnitude of the angle potential (k) is missing")
1506        if "phi_0" not in angle_parameters:
1507            raise ValueError("Equilibrium angle (phi_0) is missing")
1508        parameters_tpl = {"k": PintQuantity.from_quantity(q=angle_parameters["k"],
1509                                                          expected_dimension="energy",
1510                                                          ureg=self.units),
1511                         "phi_0": PintQuantity.from_quantity(q=angle_parameters["phi_0"],
1512                                                             expected_dimension="dimensionless",
1513                                                             ureg=self.units),}
1514        tpl = AngleTemplate(parameters=parameters_tpl,
1515                            angle_type=angle_type)
1516        tpl.name = "default"
1517        self.db._register_template(tpl)
1518
1519    def create_angular_potential(self, particle_id1, particle_id2, particle_id3, use_default_angle=False):
1520        """
1521        Creates an angle between three particle instances in an ESPResSo system
1522        and registers it in the pyMBE database.
1523
1524        Args:
1525            particle_id1 ('int'): ID of the first side particle.
1526            particle_id2 ('int'): ID of the central particle.
1527            particle_id3 ('int'): ID of the second side particle.
1528            use_default_angle ('bool', optional): If True, use the default angle if no specific one is found.
1529        """
1530        particle_inst_1 = self.db.get_instance(pmb_type="particle", instance_id=particle_id1)
1531        particle_inst_2 = self.db.get_instance(pmb_type="particle", instance_id=particle_id2)
1532        particle_inst_3 = self.db.get_instance(pmb_type="particle", instance_id=particle_id3)
1533
1534        # Verify that bonds exist between side particles and central particle
1535        bond_instances = self.db.get_instances(pmb_type="bond")
1536        bonded_pairs = set()
1537        for bond in bond_instances.values():
1538            pair = frozenset([bond.particle_id1, bond.particle_id2])
1539            bonded_pairs.add(pair)
1540        if frozenset([particle_id1, particle_id2]) not in bonded_pairs:
1541            raise ValueError(f"Cannot create angle: no bond exists between particle {particle_id1} and central particle {particle_id2}.")
1542        if frozenset([particle_id3, particle_id2]) not in bonded_pairs:
1543            raise ValueError(f"Cannot create angle: no bond exists between particle {particle_id3} and central particle {particle_id2}.")
1544
1545        angle_tpl = self.get_angle_template(side_name1=particle_inst_1.name,
1546                                            central_name=particle_inst_2.name,
1547                                            side_name2=particle_inst_3.name,
1548                                            use_default_angle=use_default_angle)
1549        angle_id = self.db._propose_instance_id(pmb_type="angle")
1550        pmb_angle_instance = AngleInstance(angle_id=angle_id,
1551                                           name=angle_tpl.name,
1552                                           particle_id1=particle_id1,
1553                                           particle_id2=particle_id2,
1554                                           particle_id3=particle_id3)
1555        self.db._register_instance(instance=pmb_angle_instance)
1556
1557    def get_angle_template(self, side_name1, central_name, side_name2, use_default_angle=False):
1558        """
1559        Retrieves an angle template connecting three particle templates.
1560
1561        Args:
1562            side_name1 ('str'): Name of the first side particle.
1563            central_name ('str'): Name of the central particle.
1564            side_name2 ('str'): Name of the second side particle.
1565            use_default_angle ('bool', optional): If True, fall back to the default angle template.
1566
1567        Returns:
1568            ('AngleTemplate'): The matching angle template.
1569        """
1570        angle_key = AngleTemplate.make_angle_key(side1=side_name1, central=central_name, side2=side_name2)
1571        try:
1572            return self.db.get_template(name=angle_key, pmb_type="angle")
1573        except ValueError:
1574            pass
1575
1576        if use_default_angle:
1577            return self.db.get_template(name="default", pmb_type="angle")
1578
1579        raise ValueError(f"No angle template found for '{side_name1}-{central_name}-{side_name2}', and default angles are deactivated.")
1580
1581    def _generate_angles_for_entity(self, entity_id, entity_id_col):
1582        """
1583        Auto-generates angles from bond topology for an entity (molecule or residue).
1584
1585        For each particle in the entity that has two or more bonded neighbors,
1586        this method finds all neighbor pairs and applies any matching angle potential.
1587
1588        Args:
1589            entity_id ('int'): The molecule_id or residue_id to generate angles for.
1590            entity_id_col ('str'): Either "molecule_id" or "residue_id".
1591        """
1592        # Get all particle IDs for this entity
1593        particle_ids = self.db._find_instance_ids_by_attribute(pmb_type="particle",
1594                                                               attribute=entity_id_col,
1595                                                               value=entity_id)
1596        if not particle_ids:
1597            return
1598
1599        # Build neighbor map from bond instances
1600        neighbors = {pid: set() for pid in particle_ids}
1601        pid_set = set(particle_ids)
1602        bond_instances = self.db.get_instances(pmb_type="bond")
1603        for bond in bond_instances.values():
1604            i, j = bond.particle_id1, bond.particle_id2
1605            if i in pid_set and j in pid_set:
1606                neighbors[i].add(j)
1607                neighbors[j].add(i)
1608
1609        # For each particle with 2+ neighbors, generate angles
1610        for j in particle_ids:
1611            nbs = sorted(neighbors[j])
1612            if len(nbs) < 2:
1613                continue
1614
1615            for idx_i in range(len(nbs)):
1616                for idx_k in range(idx_i + 1, len(nbs)):
1617                    i = nbs[idx_i]
1618                    k = nbs[idx_k]
1619                    try:
1620                        self.create_angular_potential(particle_id1=i,
1621                                          particle_id2=j,
1622                                          particle_id3=k,
1623                                          use_default_angle=True)
1624                    except ValueError:
1625                        # No angle template defined for this triplet — skip
1626                        continue
1627
1628    def define_hydrogel(self, name, node_map, chain_map):
1629        """
1630        Defines a hydrogel template in the pyMBE database.
1631
1632        Args:
1633            name ('str'): 
1634                Unique label that identifies the 'hydrogel'.
1635
1636            node_map ('list of dict'): 
1637                [{"particle_name": , "lattice_index": }, ... ]
1638
1639            chain_map ('list of dict'): 
1640                [{"node_start": , "node_end": , "residue_list": , ... ]
1641        """
1642        # Sanity tests
1643        node_indices = {tuple(entry['lattice_index']) for entry in node_map}                
1644        chain_map_connectivity = set()
1645        for entry in chain_map:
1646            start = self.lattice_builder.node_labels[entry['node_start']]
1647            end = self.lattice_builder.node_labels[entry['node_end']]
1648            chain_map_connectivity.add((start,end))
1649        if self.lattice_builder.lattice.connectivity != chain_map_connectivity:
1650            raise ValueError("Incomplete hydrogel: A diamond lattice must contain correct 16 lattice index pairs")
1651        diamond_indices = {tuple(row) for row in self.lattice_builder.lattice.indices}
1652        if node_indices != diamond_indices:
1653            raise ValueError(f"Incomplete hydrogel: A diamond lattice must contain exactly 8 lattice indices, {diamond_indices} ")
1654        # Register information in the pyMBE database
1655        nodes=[]
1656        for entry in node_map:
1657            nodes.append(HydrogelNode(particle_name=entry["particle_name"],
1658                                      lattice_index=entry["lattice_index"]))
1659        chains=[]
1660        for chain in chain_map:
1661            chains.append(HydrogelChain(node_start=chain["node_start"],
1662                                        node_end=chain["node_end"],
1663                                        molecule_name=chain["molecule_name"]))
1664        tpl = HydrogelTemplate(name=name,
1665                               node_map=nodes,
1666                               chain_map=chains)
1667        self.db._register_template(tpl)
1668
1669    def define_molecule(self, name, residue_list):
1670        """
1671        Defines a molecule template in the pyMBE database.
1672
1673        Args:
1674            name('str'): 
1675                Unique label that identifies the 'molecule'.
1676
1677            residue_list ('list' of 'str'): 
1678                List of the 'name's of the 'residue's  in the sequence of the 'molecule'.  
1679        """
1680        tpl = MoleculeTemplate(name=name,
1681                               residue_list=residue_list)
1682        self.db._register_template(tpl)
1683
1684    def define_monoprototic_acidbase_reaction(self, particle_name, pka, acidity, metadata=None):
1685        """
1686        Defines an acid-base reaction for a monoprototic particle in the pyMBE database.
1687
1688        Args:
1689            particle_name ('str'): 
1690                Unique label that identifies the particle template. 
1691
1692            pka ('float'): 
1693                pka-value of the acid or base.
1694
1695            acidity ('str'): 
1696                Identifies whether if the particle is 'acidic' or 'basic'.
1697
1698            metadata ('dict', optional): 
1699                Additional information to be stored in the reaction. Defaults to None.
1700        """
1701        supported_acidities = ["acidic", "basic"]
1702        if acidity not in supported_acidities:
1703            raise ValueError(f"Unsupported acidity '{acidity}' for particle '{particle_name}'. Supported acidities are {supported_acidities}.")
1704        reaction_type = "monoprotic"
1705        if acidity == "basic":
1706            reaction_type += "_base"
1707        else:
1708            reaction_type += "_acid"
1709        reaction = Reaction(participants=[ReactionParticipant(particle_name=particle_name,
1710                                                              state_name=f"{particle_name}H", 
1711                                                              coefficient=-1),
1712                                          ReactionParticipant(particle_name=particle_name,
1713                                                              state_name=f"{particle_name}",
1714                                                              coefficient=1)],
1715                            reaction_type=reaction_type,
1716                            pK=pka,
1717                            metadata=metadata)
1718        self.db._register_reaction(reaction)
1719
1720    def define_monoprototic_particle_states(self, particle_name, acidity):
1721        """
1722        Defines particle states for a monoprotonic particle template including the charges in each of its possible states. 
1723
1724        Args:
1725            particle_name ('str'): 
1726                Unique label that identifies the particle template. 
1727
1728            acidity ('str'): 
1729                Identifies whether the particle is 'acidic' or 'basic'.
1730        """
1731        acidity_valid_keys = ['acidic', 'basic']
1732        if not pd.isna(acidity):
1733            if acidity not in acidity_valid_keys:
1734                raise ValueError(f"Acidity {acidity} provided for particle name  {particle_name} is not supported. Valid keys are: {acidity_valid_keys}")
1735        if acidity == "acidic":
1736            states = [{"name": f"{particle_name}H", "z": 0}, 
1737                      {"name": f"{particle_name}",  "z": -1}]
1738            
1739        elif acidity == "basic":
1740            states = [{"name": f"{particle_name}H", "z": 1}, 
1741                      {"name": f"{particle_name}",  "z": 0}]
1742        self.define_particle_states(particle_name=particle_name, 
1743                                    states=states)
1744
1745    def define_particle(self, name,  sigma, epsilon, z=0, acidity=pd.NA, pka=pd.NA, cutoff=pd.NA, offset=pd.NA):
1746        """
1747        Defines a particle template in the pyMBE database.
1748
1749        Args:
1750            name('str'):
1751                 Unique label that identifies this particle type.  
1752
1753            sigma('pint.Quantity'): 
1754                Sigma parameter used to set up Lennard-Jones interactions for this particle type. 
1755
1756            epsilon('pint.Quantity'): 
1757                Epsilon parameter used to setup Lennard-Jones interactions for this particle tipe.
1758
1759            z('int', optional): 
1760                Permanent charge number of this particle type. Defaults to 0.
1761
1762            acidity('str', optional): 
1763                Identifies whether if the particle is 'acidic' or 'basic', used to setup constant pH simulations. Defaults to pd.NA.
1764
1765            pka('float', optional):
1766                If 'particle' is an acid or a base, it defines its  pka-value. Defaults to pd.NA.
1767
1768            cutoff('pint.Quantity', optional): 
1769                Cutoff parameter used to set up Lennard-Jones interactions for this particle type. Defaults to pd.NA.
1770
1771            offset('pint.Quantity', optional): 
1772                Offset parameter used to set up Lennard-Jones interactions for this particle type. Defaults to pd.NA.
1773            
1774        Notes:
1775            - 'sigma', 'cutoff' and 'offset' must have a dimensitonality of '[length]' and should be defined using pmb.units.
1776            - 'epsilon' must have a dimensitonality of '[energy]' and should be defined using pmb.units.
1777            - 'cutoff' defaults to '2**(1./6.) reduced_length'. 
1778            - 'offset' defaults to 0.
1779            - For more information on 'sigma', 'epsilon', 'cutoff' and 'offset' check 'pmb.setup_lj_interactions()'.
1780        """ 
1781        # If 'cutoff' and 'offset' are not defined, default them to the following values
1782        if pd.isna(cutoff):
1783            cutoff=self.units.Quantity(2**(1./6.), "reduced_length")
1784        if pd.isna(offset):
1785            offset=self.units.Quantity(0, "reduced_length")
1786        # Define particle states
1787        if acidity is pd.NA:
1788            states = [{"name": f"{name}",  "z": z}]
1789            self.define_particle_states(particle_name=name, 
1790                                        states=states)
1791            initial_state = name
1792        else:
1793            self.define_monoprototic_particle_states(particle_name=name,
1794                                                  acidity=acidity)
1795            initial_state = f"{name}H"
1796            if pka is not pd.NA:
1797                self.define_monoprototic_acidbase_reaction(particle_name=name,
1798                                                           acidity=acidity,
1799                                                           pka=pka)
1800        tpl = ParticleTemplate(name=name, 
1801                               sigma=PintQuantity.from_quantity(q=sigma, expected_dimension="length", ureg=self.units), 
1802                               epsilon=PintQuantity.from_quantity(q=epsilon, expected_dimension="energy", ureg=self.units),
1803                               cutoff=PintQuantity.from_quantity(q=cutoff, expected_dimension="length", ureg=self.units), 
1804                               offset=PintQuantity.from_quantity(q=offset, expected_dimension="length", ureg=self.units),
1805                               initial_state=initial_state)
1806        self.db._register_template(tpl)
1807    
1808    def define_particle_states(self, particle_name, states):
1809        """
1810        Define the chemical states of an existing particle template.
1811
1812        Args:
1813            particle_name ('str'):
1814                Name of a particle template. 
1815
1816            states ('list' of 'dict'):
1817                List of dictionaries defining the particle states. Each dictionary
1818                must contain:
1819                - 'name' ('str'): Name of the particle state (e.g. '"H"', '"-"',
1820                '"neutral"').
1821                - 'z' ('int'): Charge number of the particle in this state.
1822                Example:
1823                states = [{"name": "AH", "z": 0},     # protonated
1824                         {"name": "A-", "z": -1}]    # deprotonated
1825        Notes:
1826            - Each state is assigned a unique Espresso 'es_type' automatically.
1827            - Chemical reactions (e.g. acid–base equilibria) are **not** created by
1828            this method and must be defined separately (e.g. via
1829            'set_particle_acidity()' or custom reaction definitions).
1830            - Particles without explicitly defined states are assumed to have a
1831            single, implicit state with their default charge.
1832        """
1833        for s in states:
1834            state = ParticleStateTemplate(particle_name=particle_name,
1835                                          name=s["name"],
1836                                          z=s["z"],
1837                                          es_type=self.propose_unused_type())
1838            self.db._register_template(state)
1839
1840    def define_peptide(self, name, sequence, model):
1841        """
1842        Defines a peptide template in the pyMBE database.
1843
1844        Args:
1845            name ('str'): 
1846                Unique label that identifies the peptide.
1847
1848            sequence ('str'): 
1849                Sequence of the peptide.
1850
1851            model ('str'): 
1852                Model name. Currently only models with 1 bead '1beadAA' or with 2 beads '2beadAA' per amino acid are supported.
1853        """
1854        valid_keys = ['1beadAA','2beadAA']
1855        if model not in valid_keys:
1856            raise ValueError('Invalid label for the peptide model, please choose between 1beadAA or 2beadAA')
1857        clean_sequence = hf.protein_sequence_parser(sequence=sequence)    
1858        residue_list = self._get_residue_list_from_sequence(sequence=clean_sequence)
1859        tpl = PeptideTemplate(name=name,
1860                            residue_list=residue_list,
1861                            model=model,
1862                            sequence=sequence)
1863        self.db._register_template(tpl)        
1864    
1865    def define_protein(self, name, sequence, model):
1866        """
1867        Defines a protein template in the pyMBE database.
1868
1869        Args:
1870            name ('str'): 
1871                Unique label that identifies the protein.
1872
1873            sequence ('str'): 
1874                Sequence of the protein.
1875
1876            model ('string'): 
1877                Model name. Currently only models with 1 bead '1beadAA' or with 2 beads '2beadAA' per amino acid are supported.
1878
1879        Notes:
1880            - Currently, only 'lj_setup_mode="wca"' is supported. This corresponds to setting up the WCA potential.
1881        """
1882        valid_model_keys = ['1beadAA','2beadAA']
1883        if model not in valid_model_keys:
1884            raise ValueError('Invalid key for the protein model, supported models are {valid_model_keys}')
1885        
1886        residue_list = self._get_residue_list_from_sequence(sequence=sequence)
1887        tpl = ProteinTemplate(name=name,
1888                              model=model,
1889                              residue_list=residue_list,
1890                              sequence=sequence)
1891        self.db._register_template(tpl)
1892    
1893    def define_residue(self, name, central_bead, side_chains):
1894        """
1895        Defines a residue template in the pyMBE database.
1896
1897        Args:
1898            name ('str'): 
1899                Unique label that identifies the residue.
1900
1901            central_bead ('str'): 
1902                'name' of the 'particle' to be placed as central_bead of the residue.
1903
1904            side_chains('list' of 'str'): 
1905                List of 'name's of the pmb_objects to be placed as side_chains of the residue. Currently, only pyMBE objects of type 'particle' or 'residue' are supported.
1906        """
1907        tpl = ResidueTemplate(name=name,
1908                              central_bead=central_bead,
1909                              side_chains=side_chains)
1910        self.db._register_template(tpl)
1911
1912    
1913    def delete_instances_in_system(self, instance_id, pmb_type):
1914        """
1915        Deletes the instance with instance_id from the ESPResSo system. 
1916        Related assembly, molecule, residue, particles and bond instances will also be deleted from the pyMBE dataframe.
1917
1918        Args:
1919            instance_id ('int'): 
1920                id of the assembly to be deleted. 
1921
1922            pmb_type ('str'): 
1923                the instance type to be deleted. 
1924
1925            espresso_system ('espressomd.system.System'): 
1926                Instance of a system class from espressomd library.
1927        """
1928        if pmb_type == "particle":
1929            instance_identifier = "particle_id"
1930        elif pmb_type == "residue":
1931            instance_identifier = "residue_id"
1932        elif pmb_type in self.db._molecule_like_types:
1933            instance_identifier = "molecule_id"
1934        elif pmb_type in self.db._assembly_like_types:
1935            instance_identifier = "assembly_id"
1936        particle_ids = self.db._find_instance_ids_by_attribute(pmb_type="particle",
1937                                                               attribute=instance_identifier,
1938                                                               value=instance_id)
1939        self._delete_particles_from_engine(particle_ids=particle_ids)
1940        self.db.delete_instance(pmb_type=pmb_type,
1941                                instance_id=instance_id)
1942
1943    def determine_reservoir_concentrations(self, pH_res, c_salt_res, activity_coefficient_monovalent_pair, max_number_sc_runs=200):
1944        """
1945        Determines ionic concentrations in the reservoir at fixed pH and salt concentration.
1946
1947        Args:
1948            pH_res ('float'):
1949                Target pH value in the reservoir.
1950
1951            c_salt_res ('pint.Quantity'):
1952                Concentration of monovalent salt (e.g., NaCl) in the reservoir.
1953
1954            activity_coefficient_monovalent_pair ('callable'):
1955                Function returning the activity coefficient of a monovalent ion pair
1956                as a function of ionic strength:
1957                'gamma = activity_coefficient_monovalent_pair(I)'.
1958
1959            max_number_sc_runs ('int', optional):
1960                Maximum number of self-consistent iterations allowed before
1961                convergence is enforced. Defaults to 200.
1962
1963        Returns:
1964            tuple:
1965                (cH_res, cOH_res, cNa_res, cCl_res)
1966                - cH_res ('pint.Quantity'): Concentration of H⁺ ions.
1967                - cOH_res ('pint.Quantity'): Concentration of OH⁻ ions.
1968                - cNa_res ('pint.Quantity'): Concentration of Na⁺ ions.
1969                - cCl_res ('pint.Quantity'): Concentration of Cl⁻ ions.
1970
1971        Notess:
1972            - The algorithm enforces electroneutrality in the reservoir.
1973            - Water autodissociation is included via the equilibrium constant 'Kw'.
1974            - Non-ideal effects enter through activity coefficients depending on
1975            ionic strength.
1976            - The implementation follows the self-consistent scheme described in
1977            Landsgesell (PhD thesis, Sec. 5.3, doi:10.18419/opus-10831), adapted
1978            from the original code (doi:10.18419/darus-2237).
1979        """
1980        cH_res, cOH_res, cNa_res, cCl_res = self.simulation_engine.determine_reservoir_concentrations( pH_res, c_salt_res, activity_coefficient_monovalent_pair, max_number_sc_runs)
1981        return cH_res, cOH_res, cNa_res, cCl_res
1982
1983    def enable_motion_of_rigid_object(self, instance_id, pmb_type):
1984        """
1985        Enables translational and rotational motion of a rigid pyMBE object instance
1986        in an ESPResSo system.This method creates a rigid-body center particle at the center of mass of
1987        the specified pyMBE object and attaches all constituent particles to it
1988        using ESPResSo virtual sites. The resulting rigid object can translate and
1989        rotate as a single body.
1990
1991        Args:
1992            instance_id ('int'):
1993                Instance ID of the pyMBE object whose rigid-body motion is enabled.
1994
1995            pmb_type ('str'):
1996                pyMBE object type of the instance (e.g. '"molecule"', '"peptide"',
1997                '"protein"', or any assembly-like type).
1998
1999        Notess:
2000            - This method requires ESPResSo to be compiled with the following
2001            features enabled:
2002                - '"VIRTUAL_SITES_RELATIVE"'
2003                - '"MASS"'
2004            - A new ESPResSo particle is created to represent the rigid-body center.
2005            - The mass of the rigid-body center is set to the number of particles
2006            belonging to the object.
2007            - The rotational inertia tensor is approximated from the squared
2008            distances of the particles to the center of mass.
2009        """
2010        self.simulation_engine.enable_motion_of_rigid_object(instance_id, pmb_type)
2011
2012    def generate_coordinates_outside_sphere(self, center, radius, max_dist, n_samples):
2013        """
2014        Generates random coordinates outside a sphere and inside a larger bounding sphere.
2015
2016        Args:
2017            center ('array-like'):
2018                Coordinates of the center of the spheres.
2019
2020            radius ('float'):
2021                Radius of the inner exclusion sphere. Must be positive.
2022
2023            max_dist ('float'):
2024                Radius of the outer sampling sphere. Must be larger than 'radius'.
2025
2026            n_samples ('int'):
2027                Number of coordinates to generate.
2028
2029        Returns:
2030            'list' of 'numpy.ndarray':
2031                List of coordinates lying outside the inner sphere and inside the
2032                outer sphere.
2033
2034        Notess:
2035            - Points are uniformly sampled inside a sphere of radius 'max_dist' centered at 'center' 
2036            and only those with a distance greater than or equal to 'radius' from the center are retained.
2037        """
2038        if not radius > 0: 
2039            raise ValueError (f'The value of {radius} must be a positive value')
2040        if not radius < max_dist:
2041            raise ValueError(f'The min_dist ({radius} must be lower than the max_dist ({max_dist}))')
2042        coord_list = []
2043        counter = 0
2044        while counter<n_samples:
2045            coord = self.generate_random_points_in_a_sphere(center=center, 
2046                                            radius=max_dist,
2047                                            n_samples=1)[0]
2048            if np.linalg.norm(coord-np.asarray(center))>=radius:
2049                coord_list.append (coord)
2050                counter += 1
2051        return coord_list
2052    
2053    def generate_random_points_in_a_sphere(self, center, radius, n_samples, on_surface=False):
2054        """
2055        Generates uniformly distributed random points inside or on the surface of a sphere.
2056
2057        Args:
2058            center ('array-like'):
2059                Coordinates of the center of the sphere.
2060
2061            radius ('float'):
2062                Radius of the sphere.
2063
2064            n_samples ('int'):
2065                Number of sample points to generate.
2066
2067            on_surface ('bool', optional):
2068                If True, points are uniformly sampled on the surface of the sphere.
2069                If False, points are uniformly sampled within the sphere volume.
2070                Defaults to False.
2071
2072        Returns:
2073            'numpy.ndarray':
2074                Array of shape '(n_samples, d)' containing the generated coordinates,
2075                where 'd' is the dimensionality of 'center'.
2076        Notes:
2077            - Points are sampled in a space whose dimensionality is inferred 
2078            from the length of 'center'.
2079        """
2080        # initial values
2081        center=np.array(center)
2082        d = center.shape[0]
2083        # sample n_samples points in d dimensions from a standard normal distribution
2084        samples = self.rng.normal(size=(n_samples, d))
2085        # make the samples lie on the surface of the unit hypersphere
2086        normalize_radii = np.linalg.norm(samples, axis=1)[:, np.newaxis]
2087        samples /= normalize_radii
2088        if not on_surface:
2089            # make the samples lie inside the hypersphere with the correct density
2090            uniform_points = self.rng.uniform(size=n_samples)[:, np.newaxis]
2091            new_radii = np.power(uniform_points, 1/d)
2092            samples *= new_radii
2093        # scale the points to have the correct radius and center
2094        samples = samples * radius + center
2095        return samples 
2096
2097    def generate_trial_perpendicular_vector(self,vector,magnitude):
2098        """
2099        Generates a random vector perpendicular to a given vector.
2100
2101        Args:
2102            vector ('array-like'):
2103                Reference vector to which the generated vector will be perpendicular.
2104
2105            magnitude ('float'):
2106                Desired magnitude of the perpendicular vector.
2107
2108        Returns:
2109            'numpy.ndarray':
2110                Vector orthogonal to 'vector' with norm equal to 'magnitude'.
2111        """ 
2112        np_vec = np.array(vector) 
2113        if np.all(np_vec == 0):
2114            raise ValueError('Zero vector')
2115        np_vec /= np.linalg.norm(np_vec) 
2116        # Generate a random vector 
2117        random_vector = self.generate_random_points_in_a_sphere(radius=1, 
2118                                                                center=[0,0,0],
2119                                                                n_samples=1, 
2120                                                                on_surface=True)[0]
2121        # Project the random vector onto the input vector and subtract the projection
2122        projection = np.dot(random_vector, np_vec) * np_vec
2123        perpendicular_vector = random_vector - projection
2124        # Normalize the perpendicular vector to have the same magnitude as the input vector
2125        perpendicular_vector /= np.linalg.norm(perpendicular_vector) 
2126        return perpendicular_vector*magnitude
2127            
2128    def get_bond_template(self, particle_name1, particle_name2, use_default_bond=False) :
2129        """
2130        Retrieves a bond template connecting two particle templates.
2131
2132        Args:
2133            particle_name1 ('str'):
2134                Name of the first particle template.
2135
2136            particle_name2 ('str'):
2137                Name of the second particle template.
2138
2139            use_default_bond ('bool', optional):
2140                If True, returns the default bond template when no specific bond
2141                template is found. Defaults to False.
2142
2143        Returns:
2144            'BondTemplate':
2145                Bond template object retrieved from the pyMBE database.
2146            
2147        Notes:
2148            - This method searches the pyMBE database for a bond template defined between particle templates with names 'particle_name1' and 'particle_name2'. 
2149            - If no specific bond template is found and 'use_default_bond' is enabled, a default bond template is returned instead.
2150        """
2151        # Try to find a specific bond template
2152        bond_key = BondTemplate.make_bond_key(pn1=particle_name1,
2153                                              pn2=particle_name2)
2154        try:
2155            return self.db.get_template(name=bond_key, 
2156                                        pmb_type="bond")
2157        except ValueError:
2158            pass
2159
2160        #  Fallback to default bond if allowed
2161        if use_default_bond:
2162            return self.db.get_template(name="default", 
2163                                        pmb_type="bond")
2164
2165        # No bond template found
2166        raise ValueError(f"No bond template found between '{particle_name1}' and '{particle_name2}', and default bonds are deactivated.")
2167    
2168    def get_charge_number_map(self):
2169        """
2170        Construct a mapping from ESPResSo particle types to their charge numbers.
2171
2172        Returns:
2173            'dict[int, float]':
2174                Dictionary mapping ESPResSo particle types to charge numbers,
2175                ''{es_type: z}''.
2176
2177        Notess:
2178            - The mapping is built from particle *states*, not instances.
2179            - If multiple templates define states with the same ''es_type'',
2180            the last encountered definition will overwrite previous ones.
2181            This behavior is intentional and assumes database consistency.
2182            - Neutral particles (''z = 0'') are included in the map.
2183        """
2184        charge_number_map = {}
2185        particle_templates = self.db.get_templates("particle")
2186        for tpl in particle_templates.values():
2187            for state in self.db.get_particle_states_templates(particle_name=tpl.name).values():
2188                charge_number_map[state.es_type] = state.z
2189        return charge_number_map
2190
2191    def get_instances_df(self, pmb_type):
2192        """
2193        Returns a dataframe with all instances of type 'pmb_type' in the pyMBE database.
2194
2195        Args:
2196            pmb_type ('str'): 
2197                pmb type to search instances in the pyMBE database.
2198        
2199        Returns:
2200            ('Pandas.Dataframe'): 
2201                Dataframe with all instances of type 'pmb_type'.
2202        """
2203        return self.db._get_instances_df(pmb_type=pmb_type)
2204
2205    def get_lj_parameters(self, particle_name1, particle_name2, combining_rule='Lorentz-Berthelot'):
2206        """
2207        Returns the Lennard-Jones parameters for the interaction between the particle types given by 
2208        'particle_name1' and 'particle_name2' in the pyMBE database, calculated according to the provided combining rule.
2209
2210        Args:
2211            particle_name1 ('str'): 
2212                label of the type of the first particle type
2213
2214            particle_name2 ('str'): 
2215                label of the type of the second particle type
2216
2217            combining_rule ('string', optional): 
2218                combining rule used to calculate 'sigma' and 'epsilon' for the potential betwen a pair of particles. Defaults to 'Lorentz-Berthelot'.
2219
2220        Returns:
2221            ('dict'):
2222                {"epsilon": epsilon_value, "sigma": sigma_value, "offset": offset_value, "cutoff": cutoff_value}
2223
2224        Notes:
2225            - Currently, the only 'combining_rule' supported is Lorentz-Berthelot.
2226            - If the sigma value of 'particle_name1' or 'particle_name2' is 0, the function will return an empty dictionary. No LJ interactions are set up for particles with sigma = 0.
2227        """
2228        lj_parameters=self.db.get_lj_parameters(particle_name1=particle_name1,particle_name2=particle_name2,combining_rule=combining_rule)
2229        return lj_parameters
2230
2231    def get_particle_id_map(self, object_name):
2232        """
2233        Collect all particle IDs associated with an object of given name in the
2234        pyMBE database. 
2235
2236        Args:
2237            object_name ('str'): 
2238                Name of the object.
2239
2240        Returns:
2241            ('dict'): 
2242                {"all": [particle_ids],
2243                 "residue_map": {residue_id: [particle_ids]},
2244                 "molecule_map": {molecule_id: [particle_ids]},
2245                 "assembly_map": {assembly_id: [particle_ids]},}
2246
2247        Notess:
2248            - Works for all supported pyMBE templates.
2249            - Relies in the internal method Manager.get_particle_id_map, see method for the detailed code.
2250        """
2251        return self.db.get_particle_id_map(object_name=object_name)
2252
2253    def get_pka_set(self):
2254        """
2255        Retrieve the pKa set for all titratable particles in the pyMBE database.
2256
2257        Returns:
2258            ('dict'): 
2259                Dictionary of the form:
2260                {"particle_name": {"pka_value": float,
2261                                   "acidity": "acidic" | "basic"}}
2262        Notes:
2263            - If a particle participates in multiple acid/base reactions, an error is raised.
2264        """
2265        pka_set = {}
2266        supported_reactions = ["monoprotic_acid",
2267                               "monoprotic_base"]
2268        for reaction in self.db._reactions.values():
2269            if reaction.reaction_type not in supported_reactions:
2270                continue
2271            # Identify involved particle(s)
2272            particle_names = {participant.particle_name for participant in reaction.participants}
2273            particle_name = particle_names.pop()
2274            if particle_name in pka_set:
2275                raise ValueError(f"Multiple acid/base reactions found for particle '{particle_name}'.")
2276            pka_set[particle_name] = {"pka_value": reaction.pK}
2277            if reaction.reaction_type == "monoprotic_acid":
2278                acidity = "acidic"
2279            elif reaction.reaction_type == "monoprotic_base":
2280                acidity = "basic"
2281            pka_set[particle_name]["acidity"] = acidity
2282        return pka_set
2283    
2284    def get_radius_map(self, dimensionless=True):
2285        """
2286        Gets the effective radius of each particle defined in the pyMBE database. 
2287
2288        Args:
2289            dimensionless ('bool'):
2290                If ``True``, return magnitudes expressed in ``reduced_length``.
2291                If ``False``, return Pint quantities with units.
2292        
2293        Returns:
2294            ('dict'): 
2295                {espresso_type: radius}.
2296
2297        Notes:
2298            - The radius corresponds to (sigma+offset)/2
2299        """
2300        return self.db.get_radius_map(dimensionless)
2301
2302    def get_reactions_df(self):
2303        """
2304        Returns a dataframe with all reaction templates in the pyMBE database.
2305
2306        Returns:
2307            (Pandas.Dataframe): 
2308                Dataframe with all  reaction templates.
2309        """
2310        return self.db._get_reactions_df()
2311
2312    def get_reduced_units(self):
2313        """
2314        Returns the  current set of reduced units defined in pyMBE.
2315
2316        Returns:
2317            reduced_units_text ('str'): 
2318                text with information about the current set of reduced units.
2319
2320        """
2321        unit_length=self.units.Quantity(1,'reduced_length')
2322        unit_energy=self.units.Quantity(1,'reduced_energy')
2323        unit_charge=self.units.Quantity(1,'reduced_charge')
2324        reduced_units_text = "\n".join(["Current set of reduced units:",
2325                                       f"{unit_length.to('nm'):.5g} = {unit_length}",
2326                                       f"{unit_energy.to('J'):.5g} = {unit_energy}",
2327                                       f"{unit_charge.to('C'):.5g} = {unit_charge}",
2328                                       f"Temperature: {(self.kT/self.kB).to('K'):.5g}"])   
2329        return reduced_units_text
2330
2331    def get_templates_df(self, pmb_type):
2332        """
2333        Returns a dataframe with all templates of type 'pmb_type' in the pyMBE database.
2334
2335        Args:
2336            pmb_type ('str'): 
2337                pmb type to search templates in the pyMBE database.
2338        
2339        Returns:
2340            ('Pandas.Dataframe'): 
2341                Dataframe with all templates of type given by 'pmb_type'.
2342        """
2343        return self.db._get_templates_df(pmb_type=pmb_type)
2344
2345    def get_type_map(self):
2346        """
2347        Return the mapping of ESPResSo types for all particle states defined in the pyMBE database.
2348        
2349        Returns:
2350            'dict[str, int]':
2351                A dictionary mapping each particle state to its corresponding ESPResSo type:
2352                {state_name: es_type, ...}
2353        """
2354        
2355        return self.db.get_es_types_map()
2356
2357    def initialize_lattice_builder(self, diamond_lattice):
2358        """
2359        Initialize the lattice builder with the DiamondLattice object.
2360
2361        Args:
2362            diamond_lattice ('DiamondLattice'): 
2363                DiamondLattice object from the 'lib/lattice' module to be used in the LatticeBuilder.
2364        """
2365        from .lib.lattice import LatticeBuilder, DiamondLattice
2366        if not isinstance(diamond_lattice, DiamondLattice):
2367            raise TypeError("Currently only DiamondLattice objects are supported.")
2368        self.lattice_builder = LatticeBuilder(lattice=diamond_lattice)
2369        logging.info(f"LatticeBuilder initialized with mpc={diamond_lattice.mpc} and box_l={diamond_lattice.box_l}")
2370        return self.lattice_builder
2371
2372    def load_database(self, folder, format='csv'):
2373        """
2374        Loads a pyMBE database stored in 'folder'.
2375
2376        Args:
2377            folder ('str' or 'Path'): 
2378                Path to the folder where the pyMBE database was stored.
2379
2380            format ('str', optional): 
2381                Format of the database to be loaded. Defaults to 'csv'.
2382
2383        Return:
2384            ('dict'): 
2385                metadata with additional information about the source of the information in the database.
2386
2387        Notes:
2388            - The folder must contain the files generated by 'pmb.save_database()'.
2389            - Currently, only 'csv' format is supported.
2390        """
2391        supported_formats = ['csv']
2392        if format not in supported_formats:
2393            raise ValueError(f"Format {format} not supported. Supported formats are {supported_formats}")
2394        if format == 'csv':
2395            metadata =io._load_database_csv(self.db, 
2396                                            folder=folder)
2397        return metadata
2398        
2399    def load_pka_set(self, filename):
2400        """
2401        Load a pKa set and attach chemical states and acid–base reactions
2402        to existing particle templates.
2403
2404        Args:
2405            filename ('str'): 
2406                Path to a JSON file containing the pKa set. Expected format:
2407                {"metadata": {...},
2408                  "data": {"A": {"acidity": "acidic", "pka_value": 4.5},
2409                           "B": {"acidity": "basic",  "pka_value": 9.8}}}
2410
2411        Returns:
2412            ('dict'): 
2413                Dictionary with bibliographic metadata about the original work were the pKa set was determined.
2414
2415        Notes:
2416            - This method is designed for monoprotic acids and bases only.
2417        """
2418        with open(filename, "r") as f:
2419            pka_data = json.load(f)
2420        pka_set = pka_data["data"]
2421        metadata = pka_data.get("metadata", {})
2422        self._check_pka_set(pka_set)
2423        for particle_name, entry in pka_set.items():
2424            acidity = entry["acidity"]
2425            pka = entry["pka_value"]
2426            self.define_monoprototic_acidbase_reaction(particle_name=particle_name,
2427                                                       pka=pka,
2428                                                       acidity=acidity,
2429                                                       metadata=metadata)
2430        return metadata
2431            
2432    def propose_unused_type(self):
2433        """
2434        Propose an unused ESPResSo particle type.
2435
2436        Returns:
2437            ('int'): 
2438                The next available integer ESPResSo type. Returns ''0'' if no integer types are currently defined.
2439        """    
2440        return self.db.propose_unused_type()
2441       
2442    def read_protein_vtf(self, filename, unit_length=None):
2443        """
2444        Loads a coarse-grained protein model from a VTF file.
2445
2446        Args:
2447            filename ('str'): 
2448                Path to the VTF file.
2449                
2450            unit_length ('Pint.Quantity'): 
2451                Unit of length for coordinates (pyMBE UnitRegistry). Defaults to Angstrom.
2452
2453        Returns:
2454            ('tuple'):
2455                ('dict'): Particle topology.    
2456                ('str'):  One-letter amino-acid sequence (including n/c ends).
2457        """
2458        logging.info(f"Loading protein coarse-grain model file: {filename}")
2459        if unit_length is None:
2460            unit_length = 1 * self.units.angstrom
2461        atoms = {}        # atom_id -> atom info
2462        coords = []       # ordered coordinates
2463        residues = {}     # resid -> resname (first occurrence)
2464        has_n_term = False
2465        has_c_term = False
2466        aa_3to1 = {"ALA": "A", "ARG": "R", "ASN": "N", "ASP": "D",
2467                   "CYS": "C", "GLU": "E", "GLN": "Q", "GLY": "G",
2468                   "HIS": "H", "ILE": "I", "LEU": "L", "LYS": "K",
2469                   "MET": "M", "PHE": "F", "PRO": "P", "SER": "S",
2470                   "THR": "T", "TRP": "W", "TYR": "Y", "VAL": "V",
2471                   "n": "n", "c": "c"}
2472        # --- parse VTF ---
2473        with open(filename, "r") as f:
2474            for line in f:
2475                fields = line.split()
2476                if not fields:
2477                    continue
2478                if fields[0] == "atom":
2479                    atom_id = int(fields[1])
2480                    atom_name = fields[3]
2481                    resname = fields[5]
2482                    resid = int(fields[7])
2483                    chain_id = fields[9]
2484                    radius = float(fields[11]) * unit_length
2485                    atoms[atom_id] = {"name": atom_name,
2486                                     "resname": resname,
2487                                     "resid": resid,
2488                                     "chain_id": chain_id,
2489                                     "radius": radius}
2490                    if resname == "n":
2491                        has_n_term = True
2492                    elif resname == "c":
2493                        has_c_term = True
2494                    # register residue 
2495                    if resid not in residues:
2496                        residues[resid] = resname
2497                elif fields[0].isnumeric():
2498                    xyz = [(float(x) * unit_length).to("reduced_length").magnitude
2499                        for x in fields[1:4]]
2500                    coords.append(xyz)
2501        sequence = ""
2502        # N-terminus
2503        if has_n_term:
2504            sequence += "n"
2505        # protein residues only
2506        protein_resids = sorted(resid for resid, resname in residues.items()  if resname not in ("n", "c", "Ca"))
2507        for resid in protein_resids:
2508            resname = residues[resid]
2509            try:
2510                sequence += aa_3to1[resname]
2511            except KeyError:
2512                raise ValueError(f"Unknown residue name '{resname}' in VTF file")
2513        # C-terminus
2514        if has_c_term:
2515            sequence += "c"
2516        last_resid = max(protein_resids)
2517        # --- build topology ---
2518        topology_dict = {}
2519        for atom_id in sorted(atoms.keys()):
2520            atom = atoms[atom_id]
2521            resname = atom["resname"]
2522            resid = atom["resid"]
2523            # apply labeling rules
2524            if resname == "n":
2525                label_resid = 0
2526            elif resname == "c":
2527                label_resid = last_resid + 1
2528            elif resname == "Ca":
2529                label_resid = last_resid + 2
2530            else:
2531                label_resid = resid  # preserve original resid 
2532            label = f"{atom['name']}{label_resid}"
2533            if label in topology_dict:
2534                raise ValueError(f"Duplicate particle label '{label}'. Check VTF residue definitions.")
2535            topology_dict[label] = {"initial_pos": coords[atom_id - 1], "chain_id": atom["chain_id"], "radius": atom["radius"],}
2536        return topology_dict, sequence
2537
2538    
2539    def save_database(self, folder, format='csv'):
2540        """
2541        Saves the current pyMBE database into a file 'filename'.
2542
2543        Args:
2544            folder ('str' or 'Path'): 
2545                Path to the folder where the database files will be saved.
2546
2547        """
2548        supported_formats = ['csv']
2549        if format not in supported_formats:
2550            raise ValueError(f"Format {format} not supported. Supported formats are: {supported_formats}")
2551        if format == 'csv':
2552            io._save_database_csv(self.db, 
2553                                folder=folder)
2554
2555    def set_particle_initial_state(self, particle_name, state_name):
2556        """
2557        Sets the default initial state of a particle template defined in the pyMBE database.
2558
2559        Args:
2560            particle_name ('str'): 
2561                Unique label that identifies the particle template. 
2562
2563            state_name ('str'): 
2564                Name of the state to be set as default initial state.
2565        """
2566        part_tpl = self.db.get_template(name=particle_name,
2567        
2568                                        pmb_type="particle")
2569        part_tpl.initial_state = state_name
2570        logging.info(f"Default initial state of particle {particle_name} set to {state_name}.")
2571
2572    def set_reduced_units(self, unit_length=None, unit_charge=None, temperature=None, Kw=None):
2573        """
2574        Sets the set of reduced units used by pyMBE.units and it prints it.
2575
2576        Args:
2577            unit_length ('pint.Quantity', optional): 
2578                Reduced unit of length defined using the 'pmb.units' UnitRegistry. Defaults to None. 
2579
2580            unit_charge ('pint.Quantity', optional): 
2581                Reduced unit of charge defined using the 'pmb.units' UnitRegistry. Defaults to None. 
2582
2583            temperature ('pint.Quantity', optional): 
2584                Temperature of the system, defined using the 'pmb.units' UnitRegistry. Defaults to None. 
2585
2586            Kw ('pint.Quantity', optional): 
2587                Ionic product of water in mol^2/l^2. Defaults to None. 
2588
2589        Notes:
2590            - If no 'temperature' is given, a value of 298.15 K is assumed by default.
2591            - If no 'unit_length' is given, a value of 0.355 nm is assumed by default.
2592            - If no 'unit_charge' is given, a value of 1 elementary charge is assumed by default. 
2593            - If no 'Kw' is given, a value of 10^(-14) * mol^2 / l^2 is assumed by default. 
2594        """
2595        if unit_length is None:
2596            unit_length= 0.355*self.units.nm
2597        if temperature is None:
2598            temperature = 298.15 * self.units.K
2599        if unit_charge is None:
2600            unit_charge = scipy.constants.e * self.units.C
2601        if Kw is None:
2602            Kw = 1e-14
2603        # Sanity check
2604        variables=[unit_length,temperature,unit_charge]
2605        dimensionalities=["[length]","[temperature]","[charge]"]
2606        for variable,dimensionality in zip(variables,dimensionalities):
2607            self._check_dimensionality(variable,dimensionality)
2608        self.Kw=Kw*self.units.mol**2 / (self.units.l**2)
2609        self.kT=temperature*self.kB
2610        self.units._build_cache()
2611        self.units.define(f'reduced_energy = {self.kT} ')
2612        self.units.define(f'reduced_length = {unit_length}')
2613        self.units.define(f'reduced_charge = {unit_charge}')
2614        logging.info(self.get_reduced_units())
2615
2616    def set_simulation_engine(self,simulation_engine,box_l=None):
2617        """ 
2618        Sets the instance attribute simulation_engine to an instance of a class of type SimulationEngine.
2619            
2620        Args:
2621            simulation_engine (Any): object which contains the methods to setup molecular dynamics and montecarlo simulations
2622            box_l('list[float,float,float]'): list of floats with the dimensions of the box
2623        """
2624
2625        if isinstance(simulation_engine, espressomd.System):
2626            self.simulation_engine=EspressoSimulation(box_l=simulation_engine.box_l,
2627                                                      db=self.db,
2628                                                      espresso_system=simulation_engine,
2629                                                      units=self.units,
2630                                                      kT=self.kT,
2631                                                      Kw=self.Kw,
2632                                                      seed=self.seed)
2633        elif isinstance(simulation_engine,LammpsProtocol): 
2634            self.simulation_engine=LammpsSimulation(box_l=box_l,
2635                                                      db=self.db,
2636                                                      lammps=simulation_engine,
2637                                                      units=self.units,
2638                                                      kT=self.kT,
2639                                                      Kw=self.Kw,
2640                                                      seed=self.seed)
2641        else:
2642            raise ValueError('The specified simulation engine is not implemented yet')
2643
2644    def setup_cpH (self, counter_ion, constant_pH, exclusion_range=None, use_exclusion_radius_per_type = False):
2645        """
2646        Sets up the Acid/Base reactions for acidic/basic particles defined in the pyMBE database
2647        to be sampled in the constant pH ensemble. 
2648
2649        Args:
2650            counter_ion ('str'): 
2651                'name' of the counter_ion 'particle'.
2652
2653            constant_pH ('float'): 
2654                pH-value.
2655
2656            exclusion_range ('pint.Quantity', optional): 
2657                Below this value, no particles will be inserted.
2658
2659            use_exclusion_radius_per_type ('bool', optional): 
2660                Controls if one exclusion_radius for each espresso_type is used. Defaults to 'False'.
2661
2662        Returns:
2663            ('reaction_methods.ConstantpHEnsemble'): 
2664                Instance of a reaction_methods.ConstantpHEnsemble object from the espressomd library.
2665        """
2666       
2667        RE = self.simulation_engine.setup_cpH(counter_ion=counter_ion, 
2668                                        constant_pH=constant_pH, 
2669                                        exclusion_range=exclusion_range, 
2670                                        use_exclusion_radius_per_type = use_exclusion_radius_per_type)
2671        return RE
2672
2673    def setup_gcmc(self, c_salt_res, salt_cation_name, salt_anion_name, activity_coefficient, exclusion_range=None, use_exclusion_radius_per_type = False):
2674        """
2675        Sets up grand-canonical coupling to a reservoir of salt.
2676        For reactive systems coupled to a reservoir, the grand-reaction method has to be used instead.
2677
2678        Args:
2679            c_salt_res ('pint.Quantity'): 
2680                Concentration of monovalent salt (e.g. NaCl) in the reservoir.
2681
2682            salt_cation_name ('str'): 
2683                Name of the salt cation (e.g. Na+) particle.
2684
2685            salt_anion_name ('str'): 
2686                Name of the salt anion (e.g. Cl-) particle.
2687
2688            activity_coefficient ('callable'): 
2689                A function that calculates the activity coefficient of an ion pair as a function of the ionic strength.
2690
2691            exclusion_range('pint.Quantity', optional): 
2692                For distances shorter than this value, no particles will be inserted.
2693
2694            use_exclusion_radius_per_type('bool',optional): 
2695                Controls if one exclusion_radius for each espresso_type is used. Defaults to 'False'.
2696
2697        Returns:
2698            ('reaction_methods.ReactionEnsemble'): 
2699                Instance of a reaction_methods.ReactionEnsemble object from the espressomd library.
2700        """
2701        RE = self.simulation_engine.setup_gcmc(c_salt_res=c_salt_res, 
2702                                                salt_anion_name=salt_anion_name, 
2703                                                salt_cation_name=salt_cation_name, 
2704                                                activity_coefficient=activity_coefficient, 
2705                                                exclusion_range=exclusion_range, 
2706                                                use_exclusion_radius_per_type = use_exclusion_radius_per_type)
2707        return RE
2708        
2709    def setup_grxmc_reactions(self, pH_res, c_salt_res, proton_name, hydroxide_name, salt_cation_name, salt_anion_name, activity_coefficient, exclusion_range=None, use_exclusion_radius_per_type = False):
2710        """
2711        Sets up acid/base reactions for acidic/basic monoprotic particles defined in the pyMBE database, 
2712        as well as a grand-canonical coupling to a reservoir of small ions. 
2713        
2714
2715        Args:
2716            pH_res ('float'): 
2717                pH-value in the reservoir.
2718
2719            c_salt_res ('pint.Quantity'): 
2720                Concentration of monovalent salt (e.g. NaCl) in the reservoir.
2721
2722            proton_name ('str'): 
2723                Name of the proton (H+) particle.
2724
2725            hydroxide_name ('str'): 
2726                Name of the hydroxide (OH-) particle.
2727
2728            salt_cation_name ('str'): 
2729                Name of the salt cation (e.g. Na+) particle.
2730
2731            salt_anion_name ('str'): 
2732                Name of the salt anion (e.g. Cl-) particle.
2733
2734            activity_coefficient ('callable'): 
2735                A function that calculates the activity coefficient of an ion pair as a function of the ionic strength.
2736
2737            exclusion_range('pint.Quantity', optional): 
2738                For distances shorter than this value, no particles will be inserted.
2739
2740            use_exclusion_radius_per_type('bool', optional): 
2741                Controls if one exclusion_radius for each espresso_type is used. Defaults to 'False'.
2742
2743        Returns:
2744            For the Espresso system class:
2745            Output ('tuple(reaction_methods.ReactionEnsemble,pint.Quantity)'):
2746
2747                'reaction_methods.ReactionEnsemble':  
2748                    espressomd reaction_methods object with all reactions necesary to run the GRxMC ensamble.
2749                
2750                'pint.Quantity': 
2751                    Ionic strength of the reservoir (useful for calculating partition coefficients).
2752
2753        Notess:
2754            - This implementation uses the original formulation of the grand-reaction method by Landsgesell et al. [1].
2755
2756        [1] Landsgesell, J., Hebbeker, P., Rud, O., Lunkad, R., Košovan, P., & Holm, C. (2020). Grand-reaction method for simulations of ionization equilibria coupled to ion partitioning. Macromolecules, 53(8), 3007-3020.
2757        """     
2758        output=self.simulation_engine.setup_grxmc_reactions(pH_res=pH_res, 
2759                                                            c_salt_res=c_salt_res, 
2760                                                            proton_name=proton_name, 
2761                                                            hydroxide_name=hydroxide_name, 
2762                                                            salt_cation_name=salt_cation_name, 
2763                                                            salt_anion_name=salt_anion_name, 
2764                                                            activity_coefficient=activity_coefficient, 
2765                                                            exclusion_range=exclusion_range, 
2766                                                            use_exclusion_radius_per_type=use_exclusion_radius_per_type)
2767        
2768        return output
2769        
2770    def setup_grxmc_unified(self, pH_res, c_salt_res, cation_name, anion_name, activity_coefficient, exclusion_range=None, use_exclusion_radius_per_type = False):
2771        """
2772        Sets up acid/base reactions for acidic/basic 'particles' defined in the pyMBE database, as well as a grand-canonical coupling to a 
2773        reservoir of small ions using a unified formulation for small ions.
2774
2775        Args:
2776            pH_res ('float'): 
2777                pH-value in the reservoir.
2778
2779            c_salt_res ('pint.Quantity'): 
2780                Concentration of monovalent salt (e.g. NaCl) in the reservoir.
2781
2782            cation_name ('str'): 
2783                Name of the cationic particle.
2784
2785            anion_name ('str'): 
2786                Name of the anionic particle.
2787
2788            activity_coefficient ('callable'): 
2789                A function that calculates the activity coefficient of an ion pair as a function of the ionic strength.
2790
2791            exclusion_range('pint.Quantity', optional): 
2792                Below this value, no particles will be inserted.
2793            
2794            use_exclusion_radius_per_type('bool', optional): 
2795                Controls if one exclusion_radius per each espresso_type. Defaults to 'False'.
2796
2797        Returns:
2798            For the Espresso system class:
2799            Output ('tuple(reaction_methods.ReactionEnsemble,pint.Quantity)'):
2800
2801                'reaction_methods.ReactionEnsemble':  
2802                    espressomd reaction_methods object with all reactions necesary to run the GRxMC ensamble.
2803                
2804                'pint.Quantity': 
2805                    Ionic strength of the reservoir (useful for calculating partition coefficients).
2806
2807        Notes:
2808            - This implementation uses the formulation of the grand-reaction method by Curk et al. [1], which relies on "unified" ion types X+ = {H+, Na+} and X- = {OH-, Cl-}. 
2809            - A function that implements the original version of the grand-reaction method by Landsgesell et al. [2] is also available under the name 'setup_grxmc_reactions'.
2810
2811        [1] Curk, T., Yuan, J., & Luijten, E. (2022). Accelerated simulation method for charge regulation effects. The Journal of Chemical Physics, 156(4).
2812        [2] Landsgesell, J., Hebbeker, P., Rud, O., Lunkad, R., Košovan, P., & Holm, C. (2020). Grand-reaction method for simulations of ionization equilibria coupled to ion partitioning. Macromolecules, 53(8), 3007-3020.
2813        """       
2814        output=self.simulation_engine.setup_grxmc_unified(pH_res=pH_res, 
2815                                                         c_salt_res=c_salt_res, 
2816                                                         cation_name=cation_name, 
2817                                                         anion_name=anion_name, 
2818                                                         activity_coefficient=activity_coefficient, 
2819                                                         exclusion_range=exclusion_range, 
2820                                                         use_exclusion_radius_per_type = use_exclusion_radius_per_type)
2821        return output
2822
2823    def setup_lj_interactions(self, shift_potential=True, combining_rule='Lorentz-Berthelot'):
2824        """
2825        Sets up the Lennard-Jones (LJ) potential between all pairs of particle states defined in the pyMBE database.
2826
2827        Args:
2828
2829            shift_potential('bool', optional): 
2830                If True, a shift will be automatically computed such that the potential is continuous at the cutoff radius. Otherwise, no shift will be applied. Defaults to True.
2831
2832            combining_rule('string', optional): 
2833                combining rule used to calculate 'sigma' and 'epsilon' for the potential between a pair of particles. Defaults to 'Lorentz-Berthelot'.
2834
2835            warning('bool', optional): 
2836                switch to activate/deactivate warning messages. Defaults to True.
2837
2838        Notes:
2839            - Currently, the only 'combining_rule' supported is Lorentz-Berthelot.
2840            - Check the documentation of ESPResSo for more info about the potential https://espressomd.github.io/doc4.2.0/inter_non-bonded.html
2841
2842        """
2843        self.simulation_engine.setup_lj_interactions(shift_potential=shift_potential, 
2844                                                     combining_rule=combining_rule)
class pymbe_library:
  63class pymbe_library():
  64    """
  65    Core library of the Molecular Builder for ESPResSo (pyMBE).
  66
  67    Attributes:
  68        N_A ('pint.Quantity'):
  69            Avogadro number.
  70
  71        kB ('pint.Quantity'):
  72            Boltzmann constant.
  73
  74        e ('pint.Quantity'):
  75            Elementary charge.
  76
  77        kT ('pint.Quantity'):
  78            Thermal energy corresponding to the set temperature.
  79
  80        Kw ('pint.Quantity'):
  81            Ionic product of water, used in G-RxMC and Donnan-related calculations.
  82
  83        db ('Manager'):
  84            Database manager holding all pyMBE templates, instances and reactions.
  85
  86        rng ('numpy.random.Generator'):
  87            Random number generator initialized with the provided seed.
  88
  89        units ('pint.UnitRegistry'):
  90            Pint unit registry used for unit-aware calculations.
  91
  92        lattice_builder ('pyMBE.lib.lattice.LatticeBuilder'):
  93            Optional lattice builder object (initialized as ''None'').
  94            
  95        root ('importlib.resources.abc.Traversable'):
  96            Root path to the pyMBE package resources.
  97    """
  98
  99    def __init__(self, seed, temperature=None, unit_length=None, unit_charge=None, Kw=None):
 100        """
 101        Initializes the pyMBE library.
 102
 103        Args:
 104            seed ('int'):
 105                Seed for the random number generator.
 106
 107            temperature ('pint.Quantity', optional):
 108                Simulation temperature. If ''None'', defaults to 298.15 K.
 109
 110            unit_length ('pint.Quantity', optional):
 111                Reference length for reduced units. If ''None'', defaults to
 112                0.355 nm.
 113
 114            unit_charge ('pint.Quantity', optional):
 115                Reference charge for reduced units. If ''None'', defaults to
 116                one elementary charge.
 117
 118            Kw ('pint.Quantity', optional):
 119                Ionic product of water (typically in mol²/L²). If ''None'',
 120                defaults to 1e-14 mol²/L².
 121        """
 122        # Seed and RNG
 123        self.seed=seed
 124        self.rng = np.random.default_rng(seed)
 125        self.units=pint.UnitRegistry()
 126        self.N_A=scipy.constants.N_A / self.units.mol
 127        self.kB=scipy.constants.k * self.units.J / self.units.K
 128        self.e=scipy.constants.e * self.units.C
 129        self.set_reduced_units(unit_length=unit_length, 
 130                               unit_charge=unit_charge,
 131                               temperature=temperature, 
 132                               Kw=Kw)
 133        
 134        self.db = Manager(units=self.units)
 135        self.simulation_engine = DummyEngine()
 136        self.lattice_builder = None
 137        self.root = importlib.resources.files(__package__)
 138
 139    def _check_bond_inputs(self, bond_type, bond_parameters):
 140        """
 141        Checks that the input bond parameters are valid within the current pyMBE implementation.
 142
 143        Args:
 144            bond_type ('str'): 
 145                label to identify the potential to model the bond.
 146            
 147            bond_parameters ('dict'): 
 148                parameters of the potential of the bond.
 149        """
 150        valid_bond_types   = ["harmonic", "FENE"] 
 151        if bond_type not in valid_bond_types:
 152            raise NotImplementedError(f"Bond type '{bond_type}' currently not implemented in pyMBE, accepted types are {valid_bond_types}")
 153        required_parameters = {"harmonic": ["r_0","k"],
 154                                "FENE": ["r_0","k","d_r_max"]}
 155        for required_parameter in required_parameters[bond_type]:
 156            if required_parameter not in bond_parameters.keys():
 157                raise ValueError(f"Missing required parameter {required_parameter} for {bond_type} bond")
 158            
 159    def _check_dimensionality(self, variable, expected_dimensionality):
 160        """
 161        Checks if the dimensionality of 'variable' matches 'expected_dimensionality'.
 162
 163        Args:
 164            variable ('pint.Quantity'): 
 165                Quantity to be checked.
 166
 167            expected_dimensionality ('str'): 
 168                Expected dimension of the variable.
 169
 170        Returns:
 171            ('bool'): 
 172                'True' if the variable if of the expected dimensionality, 'False' otherwise.
 173
 174        Notes:
 175            - 'expected_dimensionality' takes dimensionality following the Pint standards [docs](https://pint.readthedocs.io/en/0.10.1/wrapping.html?highlight=dimensionality#checking-dimensionality).
 176            - For example, to check for a variable corresponding to a velocity 'expected_dimensionality = "[length]/[time]"'    
 177        """
 178        correct_dimensionality=variable.check(f"{expected_dimensionality}")      
 179        if not correct_dimensionality:
 180            raise ValueError(f"The variable {variable} should have a dimensionality of {expected_dimensionality}, instead the variable has a dimensionality of {variable.dimensionality}")
 181        return correct_dimensionality   
 182            
 183    def _check_pka_set(self, pka_set):
 184        """
 185        Checks that 'pka_set' has the formatting expected by pyMBE.
 186       
 187        Args:
 188            pka_set ('dict'): 
 189                {"name" : {"pka_value": pka, "acidity": acidity}}
 190        """
 191        required_keys=['pka_value','acidity']
 192        for required_key in required_keys:
 193            for pka_name, pka_entry in pka_set.items():
 194                if required_key not in pka_entry:
 195                    raise ValueError(f'missing a required key "{required_key}" in entry "{pka_name}" of pka_set ("{pka_entry}")')
 196
 197    def _create_hydrogel_chain(self, hydrogel_chain, nodes,box_l, use_default_bond=False, gen_angle=False):
 198        """
 199        Creates a chain between two nodes of a hydrogel.
 200
 201        Args:
 202            hydrogel_chain ('HydrogelChain'): 
 203                template of a hydrogel chain
 204            nodes ('dict'): 
 205                {node_index: {"name": node_particle_name, "pos": node_position, "id": node_particle_instance_id}}
 206            box_l('list[float,float,float]'): side length of the simulation box for x,y and z coordinates.
 207            use_default_bond ('bool', optional): 
 208                If True, use a default bond template if no specific template exists. Defaults to False.
 209
 210            gen_angle ('bool', optional):
 211                If True, generate the angle potentials internal to the created
 212                chain molecule. Junction angles near the hydrogel crosslinkers
 213                are handled separately at the hydrogel level.
 214
 215        Return:
 216            ('int'): 
 217                molecule_id of the created hydrogel chian.
 218
 219        Notes:
 220            - If the chain is defined between node_start = ''[0 0 0]'' and node_end = ''[1 1 1]'', the chain will be placed between these two nodes.
 221            - The chain will be placed in the direction of the vector between 'node_start' and 'node_end'. 
 222        """
 223        if self.lattice_builder is None:
 224            raise ValueError("LatticeBuilder is not initialized. Use 'initialize_lattice_builder' first.")
 225        molecule_tpl = self.db.get_template(pmb_type="molecule",
 226                                            name=hydrogel_chain.molecule_name)
 227        residue_list = molecule_tpl.residue_list
 228        molecule_name = molecule_tpl.name
 229        node_start = hydrogel_chain.node_start
 230        node_end = hydrogel_chain.node_end
 231        node_start_label = self.lattice_builder._create_node_label(node_start)
 232        node_end_label = self.lattice_builder._create_node_label(node_end)
 233        _, reverse = self.lattice_builder._get_node_vector_pair(node_start, node_end)
 234        if node_start == node_end and residue_list != residue_list[::-1]:
 235            raise ValueError(f"Aborted creation of hydrogel chain between '{node_start}' and '{node_end}' because pyMBE could not resolve a unique topology for that chain")
 236        if reverse:
 237            reverse_residue_order=True
 238        else:
 239            reverse_residue_order=False
 240        start_node_id = nodes[node_start_label]["id"]
 241        end_node_id = nodes[node_end_label]["id"]
 242        # Finding a backbone vector between node_start and node_end
 243        vec_between_nodes = np.array(nodes[node_end_label]["pos"]) - np.array(nodes[node_start_label]["pos"])
 244        vec_between_nodes = vec_between_nodes - self.lattice_builder.box_l * np.round(vec_between_nodes/self.lattice_builder.box_l)
 245        backbone_vector = vec_between_nodes / (self.lattice_builder.mpc+1)
 246        if reverse_residue_order:
 247            vec_between_nodes *= -1.0
 248        # Calculate the start position of the chain
 249        chain_residues = self.db.get_template(pmb_type="molecule",
 250                                              name=molecule_name).residue_list
 251        part_start_chain_name = self.db.get_template(pmb_type="residue",
 252                                                     name=chain_residues[0]).central_bead
 253        lj_parameters = self.get_lj_parameters(particle_name1=nodes[node_start_label]["name"],
 254                                               particle_name2=part_start_chain_name)
 255        bond_tpl = self.get_bond_template(particle_name1=nodes[node_start_label]["name"],
 256                                          particle_name2=part_start_chain_name,
 257                                          use_default_bond=use_default_bond)
 258        l0 = hf.calculate_initial_bond_length(lj_parameters=lj_parameters,
 259                                              bond_type=bond_tpl.bond_type,
 260                                              bond_parameters=bond_tpl.get_parameters(ureg=self.units))
 261        first_bead_pos = np.array((nodes[node_start_label]["pos"])) + np.array(backbone_vector)*l0
 262        mol_id = self.create_molecule(name=molecule_name,  # Use the name defined earlier
 263                                      number_of_molecules=1,  # Creating one chain
 264                                      box_l=box_l, ### Add lattice_builder box length size, this should be box_l=[self.lattice_builder.box_l]*3
 265                                      list_of_first_residue_positions=[first_bead_pos.tolist()], #Start at the first node
 266                                      backbone_vector=np.array(backbone_vector)/l0,
 267                                      use_default_bond=use_default_bond,
 268                                      reverse_residue_order=reverse_residue_order,
 269                                      gen_angle=gen_angle)[0]
 270        chain_pids = self.db._find_instance_ids_by_attribute(pmb_type="particle",
 271                                                             attribute="molecule_id",
 272                                                             value=mol_id)
 273        self.create_bond(particle_id1=start_node_id,particle_id2=chain_pids[0],use_default_bond=use_default_bond)
 274        self.create_bond(particle_id1=chain_pids[-1],particle_id2=end_node_id,use_default_bond=use_default_bond)
 275        return mol_id
 276
 277    def _generate_hydrogel_crosslinker_angles(self,  central_particle_ids):
 278        """
 279        Generate hydrogel angles centered on crosslinkers and adjacent terminal beads.
 280
 281        If the user defines any explicit angle template for such junction
 282        triplets, then all required junction triplets must be defined. If none
 283        are defined, hydrogel construction proceeds without crosslinker-adjacent
 284        angles.
 285        """
 286        particle_instances = self.db.get_instances(pmb_type="particle")
 287        bonded_neighbors = {}
 288        for bond in self.db.get_instances(pmb_type="bond").values():
 289            bonded_neighbors.setdefault(bond.particle_id1, set()).add(bond.particle_id2)
 290            bonded_neighbors.setdefault(bond.particle_id2, set()).add(bond.particle_id1)
 291
 292        triplets = []
 293        for central_particle_id in sorted(set(central_particle_ids)):
 294            neighbors = sorted(bonded_neighbors.get(central_particle_id, set()))
 295            central_name = particle_instances[central_particle_id].name
 296            for idx_i in range(len(neighbors)):
 297                for idx_k in range(idx_i + 1, len(neighbors)):
 298                    side_particle_id1 = neighbors[idx_i]
 299                    side_particle_id3 = neighbors[idx_k]
 300                    side_name1 = particle_instances[side_particle_id1].name
 301                    side_name3 = particle_instances[side_particle_id3].name
 302                    angle_key = AngleTemplate.make_angle_key(side1=side_name1,
 303                                                             central=central_name,
 304                                                             side2=side_name3)
 305                    triplets.append((side_particle_id1,
 306                                     central_particle_id,
 307                                     side_particle_id3,
 308                                     angle_key))
 309
 310        defined_angle_templates = self.db.get_templates(pmb_type="angle")
 311        defined_angle_keys = {angle_key for _, _, _, angle_key in triplets if angle_key in defined_angle_templates}
 312        if not defined_angle_keys:
 313            logging.warning("No angle templates defined for hydrogel crosslinkers")
 314            return
 315        missing_angle_keys = sorted({angle_key for _, _, _, angle_key in triplets if angle_key not in defined_angle_keys})
 316        if missing_angle_keys:
 317            raise ValueError("Hydrogel crosslinker-adjacent angle templates must be defined for all required triplets. "
 318                             f"Missing definitions for: {missing_angle_keys}")
 319        for side_particle_id1, central_particle_id, side_particle_id3, _ in triplets:
 320            self.create_angular_potential(particle_id1=side_particle_id1,
 321                                          particle_id2=central_particle_id,
 322                                          particle_id3=side_particle_id3,
 323                                          use_default_angle=False)
 324
 325    def _create_hydrogel_node(self, node_index, node_name,box_l):
 326        """
 327        Set a node residue type.
 328        
 329        Args:
 330            node_index ('str'): 
 331                Lattice node index in the form of a string, e.g. "[0 0 0]".
 332
 333            node_name ('str'): 
 334                name of the node particle defined in pyMBE.
 335            
 336            box_l('list[float,float,float]'): list of floats with the dimensions of the box
 337
 338        Returns:
 339            ('tuple(list,int)'):
 340                ('list'): Position of the node in the lattice.
 341                ('int'): Particle ID of the node.
 342        """
 343        if self.lattice_builder is None:
 344            raise ValueError("LatticeBuilder is not initialized. Use 'initialize_lattice_builder' first.")
 345        node_position = np.array(node_index)*0.25*self.lattice_builder.box_l
 346        p_id = self.create_particle(name = node_name,
 347                                    box_l=box_l,
 348                                    number_of_particles=1,
 349                                    position = [node_position])
 350        key = self.lattice_builder._get_node_by_label(f"[{node_index[0]} {node_index[1]} {node_index[2]}]")
 351        self.lattice_builder.nodes[key] = node_name
 352        return node_position.tolist(), p_id[0]
 353
 354    def _get_residue_list_from_sequence(self, sequence):
 355        """
 356        Convenience function to get a 'residue_list' from a protein or peptide 'sequence'.
 357
 358        Args:
 359            sequence ('lst'): 
 360                 Sequence of the peptide or protein.
 361
 362        Returns:
 363            residue_list ('list' of 'str'): 
 364                List of the 'name's of the 'residue's  in the sequence of the 'molecule'.             
 365        """
 366        residue_list = []
 367        for item in sequence:
 368            residue_name='AA-'+item
 369            residue_list.append(residue_name)
 370        return residue_list
 371    
 372    def _get_template_type(self, name, allowed_types):
 373        """
 374        Validate that a template name resolves unambiguously to exactly one
 375        allowed pmb_type in the pyMBE database and return it.
 376
 377        Args:
 378            name ('str'): 
 379                Name of the template to validate.
 380
 381            allowed_types ('set[str]'):  
 382                Set of allowed pmb_type values (e.g. {"molecule", "peptide"}).
 383
 384        Returns:
 385            ('str'): 
 386                Resolved pmb_type.
 387
 388        Notes:
 389            - This method does *not* return the template itself, only the validated pmb_type. 
 390        """
 391        registered_pmb_types_with_name = self.db._find_template_types(name=name)
 392        filtered_types = allowed_types.intersection(registered_pmb_types_with_name)
 393        if len(filtered_types) > 1:
 394            raise ValueError(f"Ambiguous template name '{name}': found {len(filtered_types)} templates in the pyMBE database. Molecule creation aborted.")  
 395        if len(filtered_types) == 0:
 396            raise ValueError(f"No {allowed_types} template found with name '{name}'. Found templates of types: {filtered_types}.")
 397        return next(iter(filtered_types))
 398
 399    def _delete_particles_from_engine(self, particle_ids):
 400        """
 401        Remove a list of particles from an ESPResSo simulation system.
 402
 403        Args:
 404            particle_ids  ('Iterable[int]'):
 405                A list (or other iterable) of ESPResSo particle IDs to remove.
 406
 407        Notes:
 408            - This method removes particles only from the ESPResSo simulation,
 409            **not** from the pyMBE database. Database cleanup must be handled
 410            separately by the caller.
 411            - Attempting to remove a non-existent particle ID will raise
 412            an ESPResSo error.
 413        """
 414        self.simulation_engine._delete_particles(particle_ids)
 415
 416    def add_instances_to_engine(self):
 417        self.simulation_engine.add_instances_to_engine()
 418
 419    def calculate_center_of_mass(self, instance_id, pmb_type):
 420        """
 421        Calculates the center of mass of a pyMBE object instance in an ESPResSo system.
 422
 423        Args:
 424            instance_id ('int'):
 425                pyMBE instance ID of the object whose center of mass is calculated.
 426
 427            pmb_type ('str'):
 428                Type of the pyMBE object. Must correspond to a particle-aggregating
 429                template type (e.g. '"molecule"', '"residue"', '"peptide"', '"protein"').
 430
 431        Returns:
 432            ('numpy.ndarray'):
 433                Array of shape '(3,)' containing the Cartesian coordinates of the
 434                center of mass.
 435
 436        Notes:
 437            - This method assumes equal mass for all particles.
 438            - Periodic boundary conditions are *not* unfolded; positions are taken
 439            directly from ESPResSo particle coordinates.
 440        """
 441        return self.simulation_engine.calculate_center_of_mass(instance_id=instance_id,
 442                                                               pmb_type=pmb_type)
 443    
 444    def calculate_HH(self, template_name, pH_list=None, pka_set=None):
 445        """
 446        Calculates the charge in the template object according to the ideal  Henderson–Hasselbalch titration curve.
 447
 448        Args:
 449            template_name ('str'):
 450                Name of the template.
 451
 452            pH_list ('list[float]', optional):
 453                pH values at which the charge is evaluated.
 454                Defaults to 50 values between 2 and 12.
 455
 456            pka_set ('dict', optional):
 457                Mapping: {particle_name: {"pka_value": 'float', "acidity": "acidic"|"basic"}}
 458
 459        Returns:
 460            'list[float]':
 461                Net molecular charge at each pH value.
 462        """
 463        if pH_list is None:
 464            pH_list = np.linspace(2, 12, 50)
 465        if pka_set is None:
 466            pka_set = self.get_pka_set()
 467        self._check_pka_set(pka_set=pka_set)
 468        particle_counts = self.db.get_particle_templates_under(template_name=template_name,
 469                                                               return_counts=True)
 470        if not particle_counts:
 471            return [None] * len(pH_list)
 472        charge_number_map = self.get_charge_number_map()
 473        def formal_charge(particle_name):
 474            tpl = self.db.get_template(name=particle_name, 
 475                                       pmb_type="particle")
 476            state = self.db.get_template(name=tpl.initial_state,
 477                                         pmb_type="particle_state")
 478            return charge_number_map[state.es_type]
 479        Z_HH = []
 480        for pH in pH_list:
 481            Z = 0.0
 482            for particle, multiplicity in particle_counts.items():
 483                if particle in pka_set:
 484                    pka = pka_set[particle]["pka_value"]
 485                    acidity = pka_set[particle]["acidity"]
 486                    if acidity == "acidic":
 487                        psi = -1
 488                    elif acidity == "basic":
 489                        psi = +1
 490                    else:
 491                        raise ValueError(f"Unknown acidity '{acidity}' for particle '{particle}'")
 492                    charge = psi / (1.0 + 10.0 ** (psi * (pH - pka)))
 493                    Z += multiplicity * charge
 494                else:
 495                    Z += multiplicity * formal_charge(particle)
 496            Z_HH.append(Z)
 497        return Z_HH   
 498
 499    def calculate_HH_Donnan(self, c_macro, c_salt, pH_list=None, pka_set=None):
 500        """
 501        Computes macromolecular charges using the Henderson–Hasselbalch equation
 502        coupled to ideal Donnan partitioning.
 503
 504        Args:
 505            c_macro ('dict'):
 506                Mapping of macromolecular species names to their concentrations
 507                in the system:
 508                '{molecule_name: concentration}'.
 509                
 510            c_salt ('float' or 'pint.Quantity'):
 511                Salt concentration in the reservoir.
 512
 513            pH_list ('list[float]', optional):
 514                List of pH values in the reservoir at which the calculation is
 515                performed. If 'None', 50 equally spaced values between 2 and 12
 516                are used.
 517
 518            pka_set ('dict', optional):
 519                Dictionary defining the acid–base properties of titratable particle
 520                types:
 521                '{particle_name: {"pka_value": float, "acidity": "acidic" | "basic"}}'.
 522                If 'None', the pKa set is taken from the pyMBE database.
 523
 524        Returns:
 525            'dict':
 526                Dictionary containing:
 527                - '"charges_dict"' ('dict'):
 528                    Mapping '{molecule_name: list}' of Henderson–Hasselbalch–Donnan
 529                    charges evaluated at each pH value.
 530                - '"pH_system_list"' ('list[float]'):
 531                    Effective pH values inside the system phase after Donnan
 532                    partitioning.
 533                - '"partition_coefficients"' ('list[float]'):
 534                    Partition coefficients of monovalent cations at each pH value.
 535
 536        Notes:
 537            - This method assumes **ideal Donnan equilibrium** and **monovalent salt**.
 538            - The ionic strength of the reservoir includes both salt and
 539            pH-dependent H⁺/OH⁻ contributions.
 540            - All charged macromolecular species present in the system must be
 541            included in 'c_macro'; missing species will lead to incorrect results.
 542            - The nonlinear Donnan equilibrium equation is solved using a scalar
 543            root finder ('brentq') in logarithmic form for numerical stability.
 544            - This method is intended for **two-phase systems**; for single-phase
 545            systems use 'calculate_HH' instead.
 546        """
 547        if pH_list is None:
 548            pH_list=np.linspace(2,12,50)
 549        if pka_set is None:
 550            pka_set=self.get_pka_set() 
 551        self._check_pka_set(pka_set=pka_set)
 552        partition_coefficients_list = []
 553        pH_system_list = []
 554        Z_HH_Donnan={}
 555        for key in c_macro:
 556            Z_HH_Donnan[key] = []
 557        def calc_charges(c_macro, pH):
 558            """
 559            Calculates the charges of the different kinds of molecules according to the Henderson-Hasselbalch equation.
 560
 561            Args:
 562                c_macro ('dict'): 
 563                    {"name": concentration} - A dict containing the concentrations of all charged macromolecular species in the system. 
 564
 565                pH ('float'): 
 566                    pH-value that is used in the HH equation.
 567
 568            Returns:
 569                ('dict'): 
 570                    {"molecule_name": charge}
 571            """
 572            charge = {}
 573            for name in c_macro:
 574                charge[name] = self.calculate_HH(name, [pH], pka_set)[0]
 575            return charge
 576
 577        def calc_partition_coefficient(charge, c_macro):
 578            """
 579            Calculates the partition coefficients of positive ions according to the ideal Donnan theory.
 580
 581            Args:
 582                charge ('dict'): 
 583                    {"molecule_name": charge}
 584
 585                c_macro ('dict'): 
 586                    {"name": concentration} - A dict containing the concentrations of all charged macromolecular species in the system. 
 587            """
 588            nonlocal ionic_strength_res
 589            charge_density = 0.0
 590            for key in charge:
 591                charge_density += charge[key] * c_macro[key]
 592            return (-charge_density / (2 * ionic_strength_res) + np.sqrt((charge_density / (2 * ionic_strength_res))**2 + 1)).magnitude
 593        for pH_value in pH_list:    
 594            # calculate the ionic strength of the reservoir
 595            if pH_value <= 7.0:
 596                ionic_strength_res = 10 ** (-pH_value) * self.units.mol/self.units.l + c_salt 
 597            elif pH_value > 7.0:
 598                ionic_strength_res = 10 ** (-(14-pH_value)) * self.units.mol/self.units.l + c_salt
 599            #Determine the partition coefficient of positive ions by solving the system of nonlinear, coupled equations
 600            #consisting of the partition coefficient given by the ideal Donnan theory and the Henderson-Hasselbalch equation.
 601            #The nonlinear equation is formulated for log(xi) since log-operations are not supported for RootResult objects.
 602            equation = lambda logxi: logxi - np.log10(calc_partition_coefficient(calc_charges(c_macro, pH_value - logxi), c_macro))
 603            logxi = scipy.optimize.root_scalar(equation, bracket=[-1e2, 1e2], method="brentq")
 604            partition_coefficient = 10**logxi.root
 605            charges_temp = calc_charges(c_macro, pH_value-np.log10(partition_coefficient))
 606            for key in c_macro:
 607                Z_HH_Donnan[key].append(charges_temp[key])
 608            pH_system_list.append(pH_value - np.log10(partition_coefficient))
 609            partition_coefficients_list.append(partition_coefficient)
 610        return {"charges_dict": Z_HH_Donnan, "pH_system_list": pH_system_list, "partition_coefficients": partition_coefficients_list}
 611
 612    def calculate_net_charge(self,object_name,pmb_type,dimensionless=False):
 613        """
 614        Calculates the net charge per instance of a given pmb object type.
 615
 616        Args:
 617            object_name (str):
 618                Name of the object (e.g. molecule, residue, peptide, protein).
 619            pmb_type (str):
 620                Type of object to analyze. Must be molecule-like.
 621            dimensionless (bool, optional):
 622                If True, return charge as a pure number.
 623                If False, return a quantity with reduced_charge units.
 624
 625        Returns:
 626            dict:
 627                {"mean": mean_net_charge, "instances": {instance_id: net_charge}}
 628        """
 629        return self.simulation_engine.calculate_net_charge(object_name,
 630                                                           pmb_type,
 631                                                           dimensionless)
 632    
 633    def center_object_in_simulation_box(self, instance_id, box_l,pmb_type):
 634        """
 635        Centers a pyMBE object instance in the simulation box of an ESPResSo system.
 636        The object is translated such that its center of mass coincides with the
 637        geometric center of the ESPResSo simulation box.
 638
 639        Args:
 640            instance_id ('int'):
 641                ID of the pyMBE object instance to be centered.
 642            
 643            box_l('list[float,float,float]'): list of floats with the dimensions of the box
 644
 645            pmb_type ('str'):
 646                Type of the pyMBE object.
 647
 648        Notes:
 649            - Works for both cubic and non-cubic simulation boxes.
 650        """
 651        inst = self.db.get_instance(instance_id=instance_id,
 652                                    pmb_type=pmb_type)
 653        center_of_mass = self.calculate_center_of_mass(instance_id=instance_id,
 654                                                       pmb_type=pmb_type)
 655        box_center = [box_l[0]/2.0,
 656                      box_l[1]/2.0,
 657                      box_l[2]/2.0]
 658        particle_id_list = self.get_particle_id_map(object_name=inst.name)["all"]
 659        for pid in particle_id_list:
 660            es_pos=self.db.get_instance(instance_id=pid,
 661                                    pmb_type='particle').position
 662            centered_position=es_pos - center_of_mass + box_center
 663
 664            self.db._update_instance(instance_id=pid,
 665                                     pmb_type='particle',
 666                                     attribute='position',
 667                                     value=centered_position)
 668            if isinstance(self.simulation_engine, EspressoSimulation):
 669                self.simulation_engine._update_particle_position(
 670                    particle_id=pid, position=centered_position)
 671
 672    def create_added_salt(self, box_l, cation_name, anion_name, c_salt):    
 673        """
 674        Creates a 'c_salt' concentration of 'cation_name' and 'anion_name' ions into the 'espresso_system'.
 675
 676        Args:
 677            cation_name('str'): 'name' of a particle with a positive charge.
 678            anion_name('str'): 'name' of a particle with a negative charge.
 679            c_salt('float'): Salt concentration.
 680            
 681        Returns:
 682            c_salt_calculated('float'): Calculated salt concentration added to 'espresso_system'.
 683        """ 
 684        cation_tpl = self.db.get_template(pmb_type="particle",
 685                                          name=cation_name)
 686        cation_state = self.db.get_template(pmb_type="particle_state",
 687                                            name=cation_tpl.initial_state)
 688        cation_charge = cation_state.z
 689        anion_tpl = self.db.get_template(pmb_type="particle",
 690                                          name=anion_name)
 691        anion_state = self.db.get_template(pmb_type="particle_state",
 692                                            name=anion_tpl.initial_state)
 693        anion_charge = anion_state.z
 694        if cation_charge <= 0:
 695            raise ValueError(f'ERROR cation charge must be positive, charge {cation_charge}')
 696        if anion_charge >= 0:
 697            raise ValueError(f'ERROR anion charge must be negative, charge {anion_charge}')
 698        # Calculate the number of ions in the simulation box
 699        volume=self.units.Quantity(np.prod(box_l), 'reduced_length**3')
 700        if c_salt.check('[substance] [length]**-3'):
 701            N_ions= int((volume*c_salt.to('mol/reduced_length**3')*self.N_A).magnitude)
 702            c_salt_calculated=N_ions/(volume*self.N_A)
 703        elif c_salt.check('[length]**-3'):
 704            N_ions= int((volume*c_salt.to('reduced_length**-3')).magnitude)
 705            c_salt_calculated=N_ions/volume
 706        else:
 707            raise ValueError('Unknown units for c_salt, please provided it in [mol / volume] or [particle / volume]', c_salt)
 708        N_cation = N_ions*abs(anion_charge)
 709        N_anion = N_ions*abs(cation_charge)
 710        self.create_particle(box_l=box_l, 
 711                             name=cation_name, 
 712                             number_of_particles=N_cation)
 713        self.create_particle(box_l=box_l, 
 714                             name=anion_name, 
 715                             number_of_particles=N_anion)
 716        if c_salt_calculated.check('[substance] [length]**-3'):
 717            logging.info(f"added salt concentration of {c_salt_calculated.to('mol/L')} given by {N_cation} cations and {N_anion} anions")
 718        elif c_salt_calculated.check('[length]**-3'):
 719            logging.info(f"added salt concentration of {c_salt_calculated.to('reduced_length**-3')} given by {N_cation} cations and {N_anion} anions")
 720        return c_salt_calculated
 721
 722    def create_bond(self, particle_id1, particle_id2, use_default_bond=False):
 723        """
 724        Creates a bond between two particle instances in an ESPResSo system and registers it in the pyMBE database.
 725
 726        This method performs the following steps:
 727            1. Retrieves the particle instances corresponding to 'particle_id1' and 'particle_id2' from the database.
 728            2. Retrieves or creates the corresponding ESPResSo bond instance using the bond template.
 729            3. Adds the ESPResSo bond instance to the ESPResSo system if it was newly created.
 730            4. Adds the bond to the first particle's bond list in ESPResSo.
 731            5. Creates a 'BondInstance' in the database and registers it.
 732
 733        Args:
 734            particle_id1 ('int'): 
 735                pyMBE and ESPResSo ID of the first particle.
 736
 737            particle_id2 ('int'): 
 738                pyMBE and ESPResSo ID of the second particle.
 739
 740            use_default_bond ('bool', optional): 
 741                If True, use a default bond template if no specific template exists. Defaults to False.
 742
 743        Returns:
 744            ('int'): 
 745                bond_id of the bond instance created in the pyMBE database.
 746        """
 747        particle_inst_1 = self.db.get_instance(pmb_type="particle",
 748                                               instance_id=particle_id1)
 749        particle_inst_2 = self.db.get_instance(pmb_type="particle",
 750                                               instance_id=particle_id2)
 751        bond_tpl = self.get_bond_template(particle_name1=particle_inst_1.name,
 752                                          particle_name2=particle_inst_2.name,
 753                                          use_default_bond=use_default_bond)
 754        bond_id = self.db._propose_instance_id(pmb_type="bond")
 755        pmb_bond_instance = BondInstance(bond_id=bond_id,
 756                                         name=bond_tpl.name,
 757                                         particle_id1=particle_id1,
 758                                         particle_id2=particle_id2)
 759        self.db._register_instance(instance=pmb_bond_instance)
 760
 761    def create_counterions(self, object_name, cation_name, anion_name, box_l):
 762        """
 763        Creates particles of 'cation_name' and 'anion_name' in 'espresso_system' to counter the net charge of 'object_name'.
 764        
 765        Args:
 766            object_name ('str'): 
 767                'name' of a pyMBE object.
 768
 769            cation_name ('str'): 
 770                'name' of a particle with a positive charge.
 771
 772            anion_name ('str'): 
 773                'name' of a particle with a negative charge.
 774            
 775            box_l('list[float,float,float]'): list of floats with the dimensions of the box
 776
 777        Returns: 
 778            ('dict'): 
 779                {"name": number}
 780
 781        Notes:
 782            This function currently does not support the creation of counterions for hydrogels.
 783        """ 
 784        cation_tpl = self.db.get_template(pmb_type="particle",
 785                                          name=cation_name)
 786        cation_state = self.db.get_template(pmb_type="particle_state",
 787                                            name=cation_tpl.initial_state)
 788        cation_charge = cation_state.z
 789        anion_tpl = self.db.get_template(pmb_type="particle",
 790                                          name=anion_name)
 791        anion_state = self.db.get_template(pmb_type="particle_state",
 792                                            name=anion_tpl.initial_state)
 793        anion_charge = anion_state.z
 794        object_ids = self.get_particle_id_map(object_name=object_name)["all"]
 795        counterion_number={}
 796        object_charge={}
 797        for name in ['positive', 'negative']:
 798            object_charge[name]=0
 799        for id in object_ids:
 800            object_name = self.db.get_instance(pmb_type="particle", 
 801                                               instance_id=id).name
 802            object_tpl = self.db.get_template(pmb_type="particle",
 803                                              name=object_name)
 804            object_state = self.db.get_template(pmb_type="particle_state",
 805                                                name=object_tpl.initial_state)
 806            object_z = object_state.z
 807            if object_z > 0:
 808                object_charge['positive']+=1*(np.abs(object_z ))
 809            elif object_z < 0:
 810                object_charge['negative']+=1*(np.abs(object_z ))
 811        if object_charge['positive'] % abs(anion_charge) == 0:
 812            counterion_number[anion_name]=int(object_charge['positive']/abs(anion_charge))
 813        else:
 814            raise ValueError('The number of positive charges in the pmb_object must be divisible by the  charge of the anion')
 815        if object_charge['negative'] % abs(cation_charge) == 0:
 816            counterion_number[cation_name]=int(object_charge['negative']/cation_charge)
 817        else:
 818            raise ValueError('The number of negative charges in the pmb_object must be divisible by the  charge of the cation')
 819        if counterion_number[cation_name] > 0: 
 820            self.create_particle(box_l=box_l, 
 821                                 name=cation_name, 
 822                                 number_of_particles=counterion_number[cation_name])
 823        else:
 824            counterion_number[cation_name]=0
 825        if counterion_number[anion_name] > 0:
 826            self.create_particle(box_l=box_l, 
 827                                 name=anion_name, 
 828                                 number_of_particles=counterion_number[anion_name])
 829        else:
 830            counterion_number[anion_name] = 0
 831        logging.info('the following counter-ions have been created: ')
 832        for name in counterion_number.keys():
 833            logging.info(f'Ion type: {name} created number: {counterion_number[name]}')
 834        return counterion_number
 835
 836
 837    def create_hydrogel(self, name, box_l, use_default_bond=False, gen_angle=False):
 838        """ 
 839        Creates a hydrogel in espresso_system using a pyMBE hydrogel template given by 'name'
 840
 841        Args:
 842            box_l('list[float,float,float]'): list of floats with the dimensions of the box
 843
 844            name ('str'): 
 845                name of the hydrogel template in the pyMBE database.
 846
 847            use_default_bond ('bool', optional): 
 848                If True, use a default bond template if no specific template exists. Defaults to False.
 849
 850            gen_angle ('bool', optional):
 851                If True, generate angle potentials for the internal hydrogel
 852                chains and, when explicitly defined, for all crosslinker-adjacent
 853                triplets. Defaults to False.
 854
 855        Returns:
 856            ('int'): id of the hydrogel instance created.
 857        """
 858        if not self.db._has_template(name=name, pmb_type="hydrogel"):
 859            raise ValueError(f"Hydrogel template with name '{name}' is not defined in the pyMBE database.")
 860        hydrogel_tpl = self.db.get_template(pmb_type="hydrogel",
 861                                            name=name)
 862        assembly_id = self.db._propose_instance_id(pmb_type="hydrogel")
 863        # Create the nodes
 864        nodes = {}
 865        hydrogel_angle_centers = set()
 866        node_topology = hydrogel_tpl.node_map
 867        for node in node_topology:
 868            node_index = node.lattice_index
 869            node_name = node.particle_name
 870            node_pos, node_id = self._create_hydrogel_node(node_index=node_index,
 871                                                          node_name=node_name,
 872                                                          box_l=box_l)
 873            node_label = self.lattice_builder._create_node_label(node_index=node_index)
 874            nodes[node_label] = {"name": node_name, "id": node_id, "pos": node_pos} 
 875            self.db._update_instance(instance_id=node_id,
 876                                     pmb_type="particle",
 877                                     attribute="assembly_id",
 878                                     value=assembly_id)
 879        for hydrogel_chain in hydrogel_tpl.chain_map:
 880            molecule_id = self._create_hydrogel_chain(hydrogel_chain=hydrogel_chain,
 881                                                      nodes=nodes,
 882                                                      box_l=box_l,
 883                                                      use_default_bond=use_default_bond,
 884                                                      gen_angle=gen_angle,
 885                                                      )
 886            self.db._update_instance(instance_id=molecule_id,
 887                                     pmb_type="molecule",
 888                                     attribute="assembly_id",
 889                                     value=assembly_id)
 890            if gen_angle:
 891                residue_ids = self.db._find_instance_ids_by_attribute(pmb_type="residue",
 892                                                                      attribute="molecule_id",
 893                                                                      value=molecule_id)
 894                first_residue_id = min(residue_ids)
 895                last_residue_id = max(residue_ids)
 896                first_residue = self.db.get_instance(pmb_type="residue",
 897                                                     instance_id=first_residue_id)
 898                last_residue = self.db.get_instance(pmb_type="residue",
 899                                                    instance_id=last_residue_id)
 900                first_central_bead_name = self.db.get_template(pmb_type="residue",
 901                                                               name=first_residue.name).central_bead
 902                last_central_bead_name = self.db.get_template(pmb_type="residue",
 903                                                              name=last_residue.name).central_bead
 904                particle_instances = self.db.get_instances(pmb_type="particle")
 905                first_residue_particle_ids = self.db._find_instance_ids_by_attribute(pmb_type="particle",
 906                                                                                      attribute="residue_id",
 907                                                                                      value=first_residue_id)
 908                last_residue_particle_ids = self.db._find_instance_ids_by_attribute(pmb_type="particle",
 909                                                                                     attribute="residue_id",
 910                                                                                     value=last_residue_id)
 911                first_bead_id = None
 912                for particle_id in first_residue_particle_ids:
 913                    if particle_instances[particle_id].name == first_central_bead_name:
 914                        first_bead_id = particle_id
 915                        break
 916
 917                last_bead_id = None
 918                for particle_id in last_residue_particle_ids:
 919                    if particle_instances[particle_id].name == last_central_bead_name:
 920                        last_bead_id = particle_id
 921                        break
 922                node_start_label = self.lattice_builder._create_node_label(hydrogel_chain.node_start)
 923                node_end_label = self.lattice_builder._create_node_label(hydrogel_chain.node_end)
 924                hydrogel_angle_centers.update({
 925                    nodes[node_start_label]["id"],
 926                    nodes[node_end_label]["id"],
 927                    first_bead_id,
 928                    last_bead_id,
 929                })
 930        self.db._propagate_id(root_type="hydrogel", 
 931                                root_id=assembly_id, 
 932                                attribute="assembly_id", 
 933                                value=assembly_id)
 934        if gen_angle:
 935            self._generate_hydrogel_crosslinker_angles(
 936                                                       central_particle_ids=hydrogel_angle_centers)
 937        # Register an hydrogel instance in the pyMBE databasegit 
 938        self.db._register_instance(HydrogelInstance(name=name,
 939                                                    assembly_id=assembly_id))
 940        return assembly_id
 941
 942
 943    def create_molecule(self, name, number_of_molecules, box_l, list_of_first_residue_positions=None, backbone_vector=None, use_default_bond=False, reverse_residue_order = False, gen_angle=False):
 944        """
 945        Creates instances of a given molecule template name into ESPResSo.
 946
 947        Args:
 948            name ('str'): 
 949                Label of the molecule type to be created. 'name'.
 950
 951            box_l('list[float,float,float]'): list of floats with the dimensions of the box
 952
 953            number_of_molecules ('int'): 
 954                Number of molecules or peptides of type 'name' to be created.
 955
 956            list_of_first_residue_positions ('list', optional): 
 957                List of coordinates where the central bead of the first_residue_position will be created, random by default.
 958
 959            backbone_vector ('list' of 'float'): 
 960                Backbone vector of the molecule, random by default. Central beads of the residues in the 'residue_list' are placed along this vector. 
 961
 962            use_default_bond('bool', optional): 
 963                Controls if a bond of type 'default' is used to bond particles with undefined bonds in the pyMBE database.
 964
 965            reverse_residue_order('bool', optional): 
 966                Creates residues in reverse sequential order than the one defined in the molecule template. Defaults to False.
 967
 968        Returns:
 969            ('list' of 'int'): 
 970                List with the 'molecule_id' of the pyMBE molecule instances created into 'espresso_system'.
 971
 972        Notes:
 973            - This function can be used to create both molecules and peptides.    
 974        """
 975        pmb_type = self._get_template_type(name=name,
 976                                           allowed_types={"molecule", "peptide"})
 977        if number_of_molecules <= 0:
 978            return {}
 979        if list_of_first_residue_positions is not None:
 980            for item in list_of_first_residue_positions:
 981                if not isinstance(item, list):
 982                    raise ValueError("The provided input position is not a nested list. Should be a nested list with elements of 3D lists, corresponding to xyz coord.")
 983                elif len(item) != 3:
 984                    raise ValueError("The provided input position is formatted wrong. The elements in the provided list does not have 3 coordinates, corresponding to xyz coord.")
 985
 986            if len(list_of_first_residue_positions) != number_of_molecules:
 987                raise ValueError(f"Number of positions provided in {list_of_first_residue_positions} does not match number of molecules desired, {number_of_molecules}")
 988        # Generate an arbitrary random unit vector
 989        if backbone_vector is None:
 990            backbone_vector = self.generate_random_points_in_a_sphere(center=[0,0,0],
 991                                                                      radius=1, 
 992                                                                      n_samples=1,
 993                                                                      on_surface=True)[0]
 994        else:
 995            backbone_vector = np.array(backbone_vector)
 996        first_residue = True
 997        molecule_tpl = self.db.get_template(pmb_type=pmb_type,
 998                                            name=name)
 999        if reverse_residue_order:
1000            residue_list = molecule_tpl.residue_list[::-1]
1001        else:
1002            residue_list = molecule_tpl.residue_list
1003        pos_index = 0 
1004        molecule_ids = []
1005        for n_mol in range(number_of_molecules):        
1006            molecule_id = self.db._propose_instance_id(pmb_type=pmb_type)
1007            for residue in residue_list:
1008                if first_residue:
1009                    if list_of_first_residue_positions is None:
1010                        central_bead_pos = None
1011                    else:
1012                        central_bead_pos = [np.array(list_of_first_residue_positions[n_mol])]
1013                            
1014                    residue_id = self.create_residue(name=residue,
1015                                                     box_l=box_l, 
1016                                                     central_bead_position=central_bead_pos,  
1017                                                     use_default_bond= use_default_bond, 
1018                                                     backbone_vector=backbone_vector)
1019                    
1020                    # Add molecule_id to the residue instance and all particles associated
1021                    self.db._propagate_id(root_type="residue", 
1022                                          root_id=residue_id,
1023                                          attribute="molecule_id", 
1024                                          value=molecule_id)
1025                    particle_ids_in_residue = self.db._find_instance_ids_by_attribute(pmb_type="particle",
1026                                                                                      attribute="residue_id",
1027                                                                                      value=residue_id)
1028                    prev_central_bead_id = particle_ids_in_residue[0]
1029                    prev_central_bead_name = self.db.get_instance(pmb_type="particle", 
1030                                                                  instance_id=prev_central_bead_id).name
1031                    prev_central_bead_pos = self.db.get_instance(pmb_type="particle", 
1032                                                                  instance_id=prev_central_bead_id).position
1033                    # prev_central_bead_pos = espresso_system.part.by_id(prev_central_bead_id).pos
1034                    first_residue = False          
1035                else:
1036                    
1037                    # Calculate the starting position of the new residue
1038                    residue_tpl = self.db.get_template(pmb_type="residue",
1039                                                       name=residue)
1040                    lj_parameters = self.get_lj_parameters(particle_name1=prev_central_bead_name,
1041                                                           particle_name2=residue_tpl.central_bead)
1042                    bond_tpl = self.get_bond_template(particle_name1=prev_central_bead_name,
1043                                                      particle_name2=residue_tpl.central_bead,
1044                                                      use_default_bond=use_default_bond)
1045                    l0 = hf.calculate_initial_bond_length(lj_parameters=lj_parameters,
1046                                                          bond_type=bond_tpl.bond_type,
1047                                                          bond_parameters=bond_tpl.get_parameters(ureg=self.units))
1048                    central_bead_pos = prev_central_bead_pos+backbone_vector*l0
1049                    # Create the residue
1050                    residue_id = self.create_residue(name=residue, 
1051                                                     box_l=box_l, 
1052                                                     central_bead_position=[central_bead_pos],
1053                                                     use_default_bond= use_default_bond, 
1054                                                     backbone_vector=backbone_vector)
1055                    # Add molecule_id to the residue instance and all particles associated
1056                    self.db._propagate_id(root_type="residue", 
1057                                          root_id=residue_id, 
1058                                          attribute="molecule_id", 
1059                                          value=molecule_id)
1060                    particle_ids_in_residue = self.db._find_instance_ids_by_attribute(pmb_type="particle",
1061                                                                                      attribute="residue_id",
1062                                                                                      value=residue_id)
1063                    central_bead_id = particle_ids_in_residue[0]
1064
1065                    # Bond the central beads of the new and previous residues
1066                    self.create_bond(particle_id1=prev_central_bead_id,
1067                                     particle_id2=central_bead_id,
1068                                     use_default_bond=use_default_bond)
1069                    
1070                    prev_central_bead_id = central_bead_id                    
1071                    prev_central_bead_name = self.db.get_instance(pmb_type="particle", instance_id=central_bead_id).name
1072                    prev_central_bead_pos =central_bead_pos
1073            # Create a Peptide or Molecule instance and register it on the pyMBE database
1074            if pmb_type == "molecule":
1075                inst = MoleculeInstance(molecule_id=molecule_id,
1076                                        name=name)
1077            elif pmb_type == "peptide":
1078                inst = PeptideInstance(name=name,
1079                                       molecule_id=molecule_id)
1080            self.db._register_instance(inst)
1081            if gen_angle:
1082                self._generate_angles_for_entity(
1083                    entity_id=molecule_id,
1084                    entity_id_col='molecule_id')
1085            first_residue = True
1086            pos_index+=1
1087            molecule_ids.append(molecule_id)
1088        return molecule_ids
1089    
1090    def create_particle(self, name, box_l, number_of_particles, position=None, fix=False):
1091        """
1092        Creates one or more particles in an ESPResSo system based on the particle template in the pyMBE database.
1093        
1094        Args:
1095            name ('str'): 
1096                Label of the particle template in the pyMBE database. 
1097            
1098            box_l('list[float,float,float]'): list of floats with the dimensions of the box
1099
1100            number_of_particles ('int'): 
1101                Number of particles to be created.
1102
1103            position (list of ['float','float','float'], optional): 
1104                Initial positions of the particles. If not given, particles are created in random positions. Defaults to None.
1105
1106            fix ('bool', optional): 
1107                Controls if the particle motion is frozen in the integrator, it is used to create rigid objects. Defaults to False.
1108
1109        Returns:
1110            ('list' of 'int'): 
1111                List with the ids of the particles created into 'espresso_system'.
1112        """       
1113        if number_of_particles <=0:
1114            return []
1115        if not self.db._has_template(name=name, pmb_type="particle"):
1116            raise ValueError(f"Particle template with name '{name}' is not defined in the pyMBE database.")
1117        
1118        part_tpl = self.db.get_template(pmb_type="particle",
1119                                        name=name)
1120        part_state = self.db.get_template(pmb_type="particle_state",
1121                                         name=part_tpl.initial_state)
1122        name_state=part_state.name
1123
1124        if fix is False:
1125            fix=[fix]*3
1126
1127        created_pid_list=[]
1128        for index in range(number_of_particles):
1129            if position is None:
1130                particle_position = self.rng.random((1, 3))[0] *np.copy(box_l)
1131            else:
1132                particle_position = np.array(position[index])
1133            
1134            particle_id = self.db._propose_instance_id(pmb_type="particle")
1135            created_pid_list.append(particle_id)
1136            part_inst = ParticleInstance(name=name,
1137                                         particle_id=particle_id,
1138                                         initial_state=name_state,
1139                                         position=particle_position,
1140                                         fix=fix)
1141            self.db._register_instance(part_inst)
1142                              
1143        return created_pid_list
1144
1145    def create_protein(self, name, number_of_proteins, box_l, topology_dict):
1146        """
1147        Creates one or more protein molecules in an ESPResSo system based on the 
1148        protein template in the pyMBE database and a provided topology.
1149
1150        Args:
1151            name (str):
1152                Name of the protein template stored in the pyMBE database.
1153            
1154            number_of_proteins (int):
1155                Number of protein molecules to generate.
1156
1157            box_l('list[float,float,float]'): list of floats with the dimensions of the box  
1158            
1159            topology_dict (dict):
1160                Dictionary defining the internal structure of the protein. Expected format:
1161                    {"ResidueName1": {"initial_pos": np.ndarray,
1162                                      "chain_id": int,
1163                                      "radius": float},
1164                     "ResidueName2": { ... },
1165                        ...
1166                    }
1167                The '"initial_pos"' entry is required and represents the residue’s
1168                reference coordinates before shifting to the protein's center-of-mass.
1169
1170        Returns:
1171            ('list' of 'int'): 
1172                List of the molecule_id of the Protein instances created into ESPResSo.
1173
1174        Notes:
1175            - Particles are created using 'create_particle()' with 'fix=True',
1176            meaning they are initially immobilized.
1177            - The function assumes all residues in 'topology_dict' correspond to
1178            particle templates already defined in the pyMBE database.
1179            - Bonds between residues are not created here; it assumes a rigid body representation of the protein.
1180        """
1181        if number_of_proteins <= 0:
1182            return
1183        if not self.db._has_template(name=name, pmb_type="protein"):
1184            raise ValueError(f"Protein template with name '{name}' is not defined in the pyMBE database.")
1185        protein_tpl = self.db.get_template(pmb_type="protein", name=name)
1186        box_half = box_l[0] / 2.0
1187        # Create protein
1188        mol_ids = []
1189        for _ in range(number_of_proteins):
1190            # create a molecule identifier in pyMBE
1191            molecule_id = self.db._propose_instance_id(pmb_type="protein")
1192            # place protein COM randomly
1193            protein_center = self.generate_coordinates_outside_sphere(radius=1,
1194                                                                      max_dist=box_half,
1195                                                                      n_samples=1,
1196                                                                      center=[box_half]*3)[0]
1197            residues = hf.get_residues_from_topology_dict(topology_dict=topology_dict,
1198                                                         model=protein_tpl.model)
1199            # CREATE RESIDUES + PARTICLES
1200            for _, rdata in residues.items():
1201                base_resname = rdata["resname"]  
1202                residue_name = f"AA-{base_resname}"
1203                # residue instance ID
1204                residue_id = self.db._propose_instance_id("residue")
1205                # register ResidueInstance
1206                self.db._register_instance(ResidueInstance(name=residue_name,
1207                                                           residue_id=residue_id,
1208                                                           molecule_id=molecule_id))
1209                # PARTICLE CREATION
1210                for bead_id in rdata["beads"]:
1211                    bead_type = re.split(r'\d+', bead_id)[0]
1212                    relative_pos = topology_dict[bead_id]["initial_pos"]
1213                    absolute_pos = relative_pos + protein_center
1214                    particle_id = self.create_particle(name=bead_type,
1215                                                       box_l=box_l,
1216                                                       number_of_particles=1,
1217                                                       position=[absolute_pos],
1218                                                       fix=[True,True,True])[0]
1219                    # update metadata
1220                    self.db._update_instance(instance_id=particle_id,
1221                                             pmb_type="particle",
1222                                             attribute="molecule_id",
1223                                             value=molecule_id)
1224                    self.db._update_instance(instance_id=particle_id,
1225                                             pmb_type="particle",
1226                                             attribute="residue_id",
1227                                             value=residue_id)
1228            protein_inst = ProteinInstance(name=name,
1229                                           molecule_id=molecule_id)
1230            self.db._register_instance(protein_inst)
1231            mol_ids.append(molecule_id)
1232        return mol_ids
1233
1234    def create_residue(self, name, box_l, central_bead_position=None,use_default_bond=False, backbone_vector=None, gen_angle=False):
1235        """
1236        Creates a residue  into ESPResSo.
1237
1238        Args:
1239            name ('str'): 
1240                Label of the residue type to be created. 
1241
1242            central_bead_position ('list' of 'float'): 
1243                Position of the central bead.
1244            
1245            box_l('list[float,float,float]'): list of floats with the dimensions of the box
1246
1247            use_default_bond ('bool'): 
1248                Switch to control if a bond of type 'default' is used to bond a particle whose bonds types are not defined in the pyMBE database.
1249
1250            backbone_vector ('list' of 'float'): 
1251                Backbone vector of the molecule. All side chains are created perpendicularly to 'backbone_vector'.
1252
1253        Returns:
1254            (int): 
1255                residue_id of the residue created.
1256        """
1257        if not self.db._has_template(name=name, pmb_type="residue"):
1258            raise ValueError(f"Residue template with name '{name}' is not defined in the pyMBE database.")
1259        res_tpl = self.db.get_template(pmb_type="residue",
1260                                       name=name)
1261        # Assign a residue_id
1262        residue_id = self.db._propose_instance_id(pmb_type="residue")
1263        res_inst = ResidueInstance(name=name,
1264                                   residue_id=residue_id)
1265        self.db._register_instance(res_inst)
1266        # create the principal bead   
1267        central_bead_name = res_tpl.central_bead 
1268        central_bead_id = self.create_particle(name=central_bead_name,
1269                                               box_l=box_l,
1270                                               position=central_bead_position,
1271                                               number_of_particles = 1)[0]
1272        
1273        central_bead_position = self.db.get_instance(pmb_type="particle", 
1274                                                                  instance_id=central_bead_id).position
1275        # # central_bead_position=espresso_system.part.by_id(central_bead_id).pos
1276
1277        # Assigns residue_id to the central_bead particle created.
1278        self.db._update_instance(pmb_type="particle",
1279                                 instance_id=central_bead_id,
1280                                 attribute="residue_id",
1281                                 value=residue_id)
1282        
1283        # create the lateral beads  
1284        side_chain_list = res_tpl.side_chains
1285        side_chain_beads_ids = []
1286        for side_chain_name in side_chain_list:
1287            pmb_type = self._get_template_type(name=side_chain_name,
1288                                               allowed_types={"particle", "residue"})
1289            if pmb_type == 'particle':
1290                lj_parameters = self.get_lj_parameters(particle_name1=central_bead_name,
1291                                                       particle_name2=side_chain_name)
1292                bond_tpl = self.get_bond_template(particle_name1=central_bead_name,
1293                                                  particle_name2=side_chain_name,
1294                                                  use_default_bond=use_default_bond)
1295                l0 = hf.calculate_initial_bond_length(lj_parameters=lj_parameters,
1296                                                      bond_type=bond_tpl.bond_type,
1297                                                      bond_parameters=bond_tpl.get_parameters(ureg=self.units))               
1298                if backbone_vector is None:
1299                    bead_position=self.generate_random_points_in_a_sphere(center=central_bead_position, 
1300                                                                radius=l0, 
1301                                                                n_samples=1,
1302                                                                on_surface=True)[0]
1303                else:
1304                    bead_position=central_bead_position+self.generate_trial_perpendicular_vector(vector=np.array(backbone_vector),
1305                                                                                                magnitude=l0)
1306                    
1307                side_bead_id = self.create_particle(name=side_chain_name, 
1308                                                    box_l=box_l,
1309                                                    position=[bead_position], 
1310                                                    number_of_particles=1)[0]
1311                side_chain_beads_ids.append(side_bead_id)
1312                self.db._update_instance(pmb_type="particle",
1313                                         instance_id=side_bead_id,
1314                                         attribute="residue_id",
1315                                         value=residue_id)
1316                self.create_bond(particle_id1=central_bead_id,
1317                                 particle_id2=side_bead_id,
1318                                 use_default_bond=use_default_bond)
1319                
1320            elif pmb_type == 'residue':
1321
1322                side_residue_tpl = self.db.get_template(name=side_chain_name,
1323                                                        pmb_type=pmb_type)
1324                central_bead_side_chain = side_residue_tpl.central_bead
1325                lj_parameters = self.get_lj_parameters(particle_name1=central_bead_name,
1326                                                       particle_name2=central_bead_side_chain)
1327                bond_tpl = self.get_bond_template(particle_name1=central_bead_name,
1328                                                  particle_name2=central_bead_side_chain,
1329                                                  use_default_bond=use_default_bond)
1330                l0 = hf.calculate_initial_bond_length(lj_parameters=lj_parameters,
1331                                                      bond_type=bond_tpl.bond_type,
1332                                                      bond_parameters=bond_tpl.get_parameters(ureg=self.units))
1333                if backbone_vector is None:
1334                    residue_position=self.generate_random_points_in_a_sphere(center=central_bead_position, 
1335                                                                radius=l0, 
1336                                                                n_samples=1,
1337                                                                on_surface=True)[0]
1338                else:
1339                    residue_position=central_bead_position+self.generate_trial_perpendicular_vector(vector=backbone_vector,
1340                                                                                                    magnitude=l0)
1341                side_residue_id = self.create_residue(name=side_chain_name, 
1342                                                      box_l=box_l,
1343                                                      central_bead_position=[residue_position],
1344                                                      use_default_bond=use_default_bond)
1345                # Find particle ids of the inner residue
1346                side_chain_beads_ids = self.db._find_instance_ids_by_attribute(pmb_type="particle",
1347                                                                               attribute="residue_id",
1348                                                                               value=side_residue_id)
1349                # Change the residue_id of the residue in the side chain to the one of the outer residue
1350                for particle_id in side_chain_beads_ids:
1351                    self.db._update_instance(instance_id=particle_id,
1352                                             pmb_type="particle",
1353                                             attribute="residue_id",
1354                                             value=residue_id)
1355                # Remove the instance of the inner residue
1356                self.db.delete_instance(pmb_type="residue",
1357                                        instance_id=side_residue_id)
1358                self.create_bond(particle_id1=central_bead_id,
1359                                 particle_id2=side_chain_beads_ids[0],
1360                                 use_default_bond=use_default_bond)
1361        if gen_angle:
1362            self._generate_angles_for_entity(
1363                                             entity_id=residue_id,
1364                                             entity_id_col="residue_id")
1365        return  residue_id
1366
1367    def define_bond(self, bond_type, bond_parameters, particle_pairs):
1368        """
1369        Defines bond templates for each particle pair in 'particle_pairs' in the pyMBE database.
1370
1371        Args:
1372            bond_type ('str'): 
1373                label to identify the potential to model the bond.
1374
1375            bond_parameters ('dict'): 
1376                parameters of the potential of the bond.
1377
1378            particle_pairs ('lst'): 
1379                list of the 'names' of the 'particles' to be bonded.
1380
1381        Notes:
1382            -Currently, only HARMONIC and FENE bonds are supported.
1383            - For a HARMONIC bond the dictionary must contain the following parameters:
1384                - k ('pint.Quantity')      : Magnitude of the bond. It should have units of energy/length**2 
1385                using the 'pmb.units' UnitRegistry.
1386                - r_0 ('pint.Quantity')    : Equilibrium bond length. It should have units of length using 
1387                the 'pmb.units' UnitRegistry.
1388           - For a FENE bond the dictionary must contain the same parameters as for a HARMONIC bond and:              
1389                - d_r_max ('pint.Quantity'): Maximal stretching length for FENE. It should have 
1390                units of length using the 'pmb.units' UnitRegistry. Default 'None'.
1391        """
1392        self._check_bond_inputs(bond_parameters=bond_parameters,
1393                                bond_type=bond_type)
1394        parameters_expected_dimensions={"r_0": "length",
1395                                        "k": "energy/length**2",
1396                                        "d_r_max": "length"}
1397
1398        parameters_tpl = {}
1399        for key in bond_parameters.keys():
1400            parameters_tpl[key]= PintQuantity.from_quantity(q=bond_parameters[key],
1401                                                            expected_dimension=parameters_expected_dimensions[key],
1402                                                            ureg=self.units)
1403
1404        bond_names=[]
1405        for particle_name1, particle_name2 in particle_pairs:
1406            
1407            tpl = BondTemplate(particle_name1=particle_name1,
1408                               particle_name2=particle_name2,
1409                               parameters=parameters_tpl,
1410                               bond_type=bond_type)
1411            tpl._make_name()
1412            if tpl.name in bond_names:
1413                raise RuntimeError(f"Bond {tpl.name} has already been defined, please check the list of particle pairs")
1414            bond_names.append(tpl.name)
1415            self.db._register_template(tpl)
1416
1417    
1418    def define_default_bond(self, bond_type, bond_parameters):
1419        """
1420        Defines a bond template as a "default" template in the pyMBE database.
1421        
1422        Args:
1423            bond_type ('str'): 
1424                label to identify the potential to model the bond.
1425
1426            bond_parameters ('dict'): 
1427                parameters of the potential of the bond.
1428            
1429        Notes:
1430            - Currently, only harmonic and FENE bonds are supported. 
1431        """
1432        self._check_bond_inputs(bond_parameters=bond_parameters,
1433                                bond_type=bond_type)
1434        parameters_expected_dimensions={"r_0": "length",
1435                                        "k": "energy/length**2",
1436                                        "d_r_max": "length"}
1437        parameters_tpl = {}
1438        for key in bond_parameters.keys():
1439            parameters_tpl[key]= PintQuantity.from_quantity(q=bond_parameters[key],
1440                                                            expected_dimension=parameters_expected_dimensions[key],
1441                                                            ureg=self.units)
1442        tpl = BondTemplate(parameters=parameters_tpl,
1443                               bond_type=bond_type)
1444        tpl.name = "default"
1445        self.db._register_template(tpl)
1446
1447    def define_angular_potential(self, angle_type, angle_parameters, particle_triplets):
1448        """
1449        Defines angle potential templates for each particle triplet in `particle_triplets`.
1450
1451        Args:
1452            angle_type ('str'):
1453                Type of angle potential. Supported: "harmonic", "cosine", "harmonic_cosine".
1454
1455            angle_parameters ('dict'):
1456                Parameters of the angle potential. Must contain:
1457                    - "k" ('pint.Quantity'): Bending stiffness with dimensions of energy.
1458                    - "phi_0" ('float'): Equilibrium angle in radians.
1459
1460            particle_triplets ('list[tuple[str,str,str]]'):
1461                List of (side_particle1, central_particle, side_particle2) triplets.
1462        """
1463        valid_angle_types = ["harmonic", "cosine", "harmonic_cosine"]
1464        if angle_type not in valid_angle_types:
1465            raise NotImplementedError(f"Angle potential type '{angle_type}' currently not implemented in pyMBE, accepted types are {valid_angle_types}")
1466
1467        if "k" not in angle_parameters:
1468            raise ValueError("Magnitude of the angle potential (k) is missing")
1469        if "phi_0" not in angle_parameters:
1470            raise ValueError("Equilibrium angle (phi_0) is missing")
1471
1472        parameters_tpl = {"k": PintQuantity.from_quantity(q=angle_parameters["k"],
1473                                                          expected_dimension="energy",
1474                                                          ureg=self.units),
1475                          "phi_0": PintQuantity.from_quantity(q=angle_parameters["phi_0"],
1476                                                              expected_dimension="dimensionless",
1477                                                              ureg=self.units),}
1478        angle_names = []
1479        for side1, central, side2 in particle_triplets:
1480            tpl = AngleTemplate(side_particle1=side1,
1481                                central_particle=central,
1482                                side_particle2=side2,
1483                                parameters=parameters_tpl,
1484                                angle_type=angle_type)
1485            tpl._make_name()
1486            if tpl.name in angle_names:
1487                raise RuntimeError(f"Angle {tpl.name} has already been defined, please check the list of particle triplets")
1488            angle_names.append(tpl.name)
1489            self.db._register_template(tpl)
1490
1491    def define_default_angular_potential(self, angle_type, angle_parameters):
1492        """
1493        Defines an angle template as a "default" template in the pyMBE database.
1494
1495        Args:
1496            angle_type ('str'):
1497                Type of angle potential. Supported: "harmonic", "cosine", "harmonic_cosine".
1498
1499            angle_parameters ('dict'):
1500                Parameters of the angle potential (k, phi_0).
1501        """
1502        valid_angle_types = ["harmonic", "cosine", "harmonic_cosine"]
1503        if angle_type not in valid_angle_types:
1504            raise NotImplementedError(f"Angle potential type '{angle_type}' currently not implemented in pyMBE, accepted types are {valid_angle_types}")
1505        if "k" not in angle_parameters:
1506            raise ValueError("Magnitude of the angle potential (k) is missing")
1507        if "phi_0" not in angle_parameters:
1508            raise ValueError("Equilibrium angle (phi_0) is missing")
1509        parameters_tpl = {"k": PintQuantity.from_quantity(q=angle_parameters["k"],
1510                                                          expected_dimension="energy",
1511                                                          ureg=self.units),
1512                         "phi_0": PintQuantity.from_quantity(q=angle_parameters["phi_0"],
1513                                                             expected_dimension="dimensionless",
1514                                                             ureg=self.units),}
1515        tpl = AngleTemplate(parameters=parameters_tpl,
1516                            angle_type=angle_type)
1517        tpl.name = "default"
1518        self.db._register_template(tpl)
1519
1520    def create_angular_potential(self, particle_id1, particle_id2, particle_id3, use_default_angle=False):
1521        """
1522        Creates an angle between three particle instances in an ESPResSo system
1523        and registers it in the pyMBE database.
1524
1525        Args:
1526            particle_id1 ('int'): ID of the first side particle.
1527            particle_id2 ('int'): ID of the central particle.
1528            particle_id3 ('int'): ID of the second side particle.
1529            use_default_angle ('bool', optional): If True, use the default angle if no specific one is found.
1530        """
1531        particle_inst_1 = self.db.get_instance(pmb_type="particle", instance_id=particle_id1)
1532        particle_inst_2 = self.db.get_instance(pmb_type="particle", instance_id=particle_id2)
1533        particle_inst_3 = self.db.get_instance(pmb_type="particle", instance_id=particle_id3)
1534
1535        # Verify that bonds exist between side particles and central particle
1536        bond_instances = self.db.get_instances(pmb_type="bond")
1537        bonded_pairs = set()
1538        for bond in bond_instances.values():
1539            pair = frozenset([bond.particle_id1, bond.particle_id2])
1540            bonded_pairs.add(pair)
1541        if frozenset([particle_id1, particle_id2]) not in bonded_pairs:
1542            raise ValueError(f"Cannot create angle: no bond exists between particle {particle_id1} and central particle {particle_id2}.")
1543        if frozenset([particle_id3, particle_id2]) not in bonded_pairs:
1544            raise ValueError(f"Cannot create angle: no bond exists between particle {particle_id3} and central particle {particle_id2}.")
1545
1546        angle_tpl = self.get_angle_template(side_name1=particle_inst_1.name,
1547                                            central_name=particle_inst_2.name,
1548                                            side_name2=particle_inst_3.name,
1549                                            use_default_angle=use_default_angle)
1550        angle_id = self.db._propose_instance_id(pmb_type="angle")
1551        pmb_angle_instance = AngleInstance(angle_id=angle_id,
1552                                           name=angle_tpl.name,
1553                                           particle_id1=particle_id1,
1554                                           particle_id2=particle_id2,
1555                                           particle_id3=particle_id3)
1556        self.db._register_instance(instance=pmb_angle_instance)
1557
1558    def get_angle_template(self, side_name1, central_name, side_name2, use_default_angle=False):
1559        """
1560        Retrieves an angle template connecting three particle templates.
1561
1562        Args:
1563            side_name1 ('str'): Name of the first side particle.
1564            central_name ('str'): Name of the central particle.
1565            side_name2 ('str'): Name of the second side particle.
1566            use_default_angle ('bool', optional): If True, fall back to the default angle template.
1567
1568        Returns:
1569            ('AngleTemplate'): The matching angle template.
1570        """
1571        angle_key = AngleTemplate.make_angle_key(side1=side_name1, central=central_name, side2=side_name2)
1572        try:
1573            return self.db.get_template(name=angle_key, pmb_type="angle")
1574        except ValueError:
1575            pass
1576
1577        if use_default_angle:
1578            return self.db.get_template(name="default", pmb_type="angle")
1579
1580        raise ValueError(f"No angle template found for '{side_name1}-{central_name}-{side_name2}', and default angles are deactivated.")
1581
1582    def _generate_angles_for_entity(self, entity_id, entity_id_col):
1583        """
1584        Auto-generates angles from bond topology for an entity (molecule or residue).
1585
1586        For each particle in the entity that has two or more bonded neighbors,
1587        this method finds all neighbor pairs and applies any matching angle potential.
1588
1589        Args:
1590            entity_id ('int'): The molecule_id or residue_id to generate angles for.
1591            entity_id_col ('str'): Either "molecule_id" or "residue_id".
1592        """
1593        # Get all particle IDs for this entity
1594        particle_ids = self.db._find_instance_ids_by_attribute(pmb_type="particle",
1595                                                               attribute=entity_id_col,
1596                                                               value=entity_id)
1597        if not particle_ids:
1598            return
1599
1600        # Build neighbor map from bond instances
1601        neighbors = {pid: set() for pid in particle_ids}
1602        pid_set = set(particle_ids)
1603        bond_instances = self.db.get_instances(pmb_type="bond")
1604        for bond in bond_instances.values():
1605            i, j = bond.particle_id1, bond.particle_id2
1606            if i in pid_set and j in pid_set:
1607                neighbors[i].add(j)
1608                neighbors[j].add(i)
1609
1610        # For each particle with 2+ neighbors, generate angles
1611        for j in particle_ids:
1612            nbs = sorted(neighbors[j])
1613            if len(nbs) < 2:
1614                continue
1615
1616            for idx_i in range(len(nbs)):
1617                for idx_k in range(idx_i + 1, len(nbs)):
1618                    i = nbs[idx_i]
1619                    k = nbs[idx_k]
1620                    try:
1621                        self.create_angular_potential(particle_id1=i,
1622                                          particle_id2=j,
1623                                          particle_id3=k,
1624                                          use_default_angle=True)
1625                    except ValueError:
1626                        # No angle template defined for this triplet — skip
1627                        continue
1628
1629    def define_hydrogel(self, name, node_map, chain_map):
1630        """
1631        Defines a hydrogel template in the pyMBE database.
1632
1633        Args:
1634            name ('str'): 
1635                Unique label that identifies the 'hydrogel'.
1636
1637            node_map ('list of dict'): 
1638                [{"particle_name": , "lattice_index": }, ... ]
1639
1640            chain_map ('list of dict'): 
1641                [{"node_start": , "node_end": , "residue_list": , ... ]
1642        """
1643        # Sanity tests
1644        node_indices = {tuple(entry['lattice_index']) for entry in node_map}                
1645        chain_map_connectivity = set()
1646        for entry in chain_map:
1647            start = self.lattice_builder.node_labels[entry['node_start']]
1648            end = self.lattice_builder.node_labels[entry['node_end']]
1649            chain_map_connectivity.add((start,end))
1650        if self.lattice_builder.lattice.connectivity != chain_map_connectivity:
1651            raise ValueError("Incomplete hydrogel: A diamond lattice must contain correct 16 lattice index pairs")
1652        diamond_indices = {tuple(row) for row in self.lattice_builder.lattice.indices}
1653        if node_indices != diamond_indices:
1654            raise ValueError(f"Incomplete hydrogel: A diamond lattice must contain exactly 8 lattice indices, {diamond_indices} ")
1655        # Register information in the pyMBE database
1656        nodes=[]
1657        for entry in node_map:
1658            nodes.append(HydrogelNode(particle_name=entry["particle_name"],
1659                                      lattice_index=entry["lattice_index"]))
1660        chains=[]
1661        for chain in chain_map:
1662            chains.append(HydrogelChain(node_start=chain["node_start"],
1663                                        node_end=chain["node_end"],
1664                                        molecule_name=chain["molecule_name"]))
1665        tpl = HydrogelTemplate(name=name,
1666                               node_map=nodes,
1667                               chain_map=chains)
1668        self.db._register_template(tpl)
1669
1670    def define_molecule(self, name, residue_list):
1671        """
1672        Defines a molecule template in the pyMBE database.
1673
1674        Args:
1675            name('str'): 
1676                Unique label that identifies the 'molecule'.
1677
1678            residue_list ('list' of 'str'): 
1679                List of the 'name's of the 'residue's  in the sequence of the 'molecule'.  
1680        """
1681        tpl = MoleculeTemplate(name=name,
1682                               residue_list=residue_list)
1683        self.db._register_template(tpl)
1684
1685    def define_monoprototic_acidbase_reaction(self, particle_name, pka, acidity, metadata=None):
1686        """
1687        Defines an acid-base reaction for a monoprototic particle in the pyMBE database.
1688
1689        Args:
1690            particle_name ('str'): 
1691                Unique label that identifies the particle template. 
1692
1693            pka ('float'): 
1694                pka-value of the acid or base.
1695
1696            acidity ('str'): 
1697                Identifies whether if the particle is 'acidic' or 'basic'.
1698
1699            metadata ('dict', optional): 
1700                Additional information to be stored in the reaction. Defaults to None.
1701        """
1702        supported_acidities = ["acidic", "basic"]
1703        if acidity not in supported_acidities:
1704            raise ValueError(f"Unsupported acidity '{acidity}' for particle '{particle_name}'. Supported acidities are {supported_acidities}.")
1705        reaction_type = "monoprotic"
1706        if acidity == "basic":
1707            reaction_type += "_base"
1708        else:
1709            reaction_type += "_acid"
1710        reaction = Reaction(participants=[ReactionParticipant(particle_name=particle_name,
1711                                                              state_name=f"{particle_name}H", 
1712                                                              coefficient=-1),
1713                                          ReactionParticipant(particle_name=particle_name,
1714                                                              state_name=f"{particle_name}",
1715                                                              coefficient=1)],
1716                            reaction_type=reaction_type,
1717                            pK=pka,
1718                            metadata=metadata)
1719        self.db._register_reaction(reaction)
1720
1721    def define_monoprototic_particle_states(self, particle_name, acidity):
1722        """
1723        Defines particle states for a monoprotonic particle template including the charges in each of its possible states. 
1724
1725        Args:
1726            particle_name ('str'): 
1727                Unique label that identifies the particle template. 
1728
1729            acidity ('str'): 
1730                Identifies whether the particle is 'acidic' or 'basic'.
1731        """
1732        acidity_valid_keys = ['acidic', 'basic']
1733        if not pd.isna(acidity):
1734            if acidity not in acidity_valid_keys:
1735                raise ValueError(f"Acidity {acidity} provided for particle name  {particle_name} is not supported. Valid keys are: {acidity_valid_keys}")
1736        if acidity == "acidic":
1737            states = [{"name": f"{particle_name}H", "z": 0}, 
1738                      {"name": f"{particle_name}",  "z": -1}]
1739            
1740        elif acidity == "basic":
1741            states = [{"name": f"{particle_name}H", "z": 1}, 
1742                      {"name": f"{particle_name}",  "z": 0}]
1743        self.define_particle_states(particle_name=particle_name, 
1744                                    states=states)
1745
1746    def define_particle(self, name,  sigma, epsilon, z=0, acidity=pd.NA, pka=pd.NA, cutoff=pd.NA, offset=pd.NA):
1747        """
1748        Defines a particle template in the pyMBE database.
1749
1750        Args:
1751            name('str'):
1752                 Unique label that identifies this particle type.  
1753
1754            sigma('pint.Quantity'): 
1755                Sigma parameter used to set up Lennard-Jones interactions for this particle type. 
1756
1757            epsilon('pint.Quantity'): 
1758                Epsilon parameter used to setup Lennard-Jones interactions for this particle tipe.
1759
1760            z('int', optional): 
1761                Permanent charge number of this particle type. Defaults to 0.
1762
1763            acidity('str', optional): 
1764                Identifies whether if the particle is 'acidic' or 'basic', used to setup constant pH simulations. Defaults to pd.NA.
1765
1766            pka('float', optional):
1767                If 'particle' is an acid or a base, it defines its  pka-value. Defaults to pd.NA.
1768
1769            cutoff('pint.Quantity', optional): 
1770                Cutoff parameter used to set up Lennard-Jones interactions for this particle type. Defaults to pd.NA.
1771
1772            offset('pint.Quantity', optional): 
1773                Offset parameter used to set up Lennard-Jones interactions for this particle type. Defaults to pd.NA.
1774            
1775        Notes:
1776            - 'sigma', 'cutoff' and 'offset' must have a dimensitonality of '[length]' and should be defined using pmb.units.
1777            - 'epsilon' must have a dimensitonality of '[energy]' and should be defined using pmb.units.
1778            - 'cutoff' defaults to '2**(1./6.) reduced_length'. 
1779            - 'offset' defaults to 0.
1780            - For more information on 'sigma', 'epsilon', 'cutoff' and 'offset' check 'pmb.setup_lj_interactions()'.
1781        """ 
1782        # If 'cutoff' and 'offset' are not defined, default them to the following values
1783        if pd.isna(cutoff):
1784            cutoff=self.units.Quantity(2**(1./6.), "reduced_length")
1785        if pd.isna(offset):
1786            offset=self.units.Quantity(0, "reduced_length")
1787        # Define particle states
1788        if acidity is pd.NA:
1789            states = [{"name": f"{name}",  "z": z}]
1790            self.define_particle_states(particle_name=name, 
1791                                        states=states)
1792            initial_state = name
1793        else:
1794            self.define_monoprototic_particle_states(particle_name=name,
1795                                                  acidity=acidity)
1796            initial_state = f"{name}H"
1797            if pka is not pd.NA:
1798                self.define_monoprototic_acidbase_reaction(particle_name=name,
1799                                                           acidity=acidity,
1800                                                           pka=pka)
1801        tpl = ParticleTemplate(name=name, 
1802                               sigma=PintQuantity.from_quantity(q=sigma, expected_dimension="length", ureg=self.units), 
1803                               epsilon=PintQuantity.from_quantity(q=epsilon, expected_dimension="energy", ureg=self.units),
1804                               cutoff=PintQuantity.from_quantity(q=cutoff, expected_dimension="length", ureg=self.units), 
1805                               offset=PintQuantity.from_quantity(q=offset, expected_dimension="length", ureg=self.units),
1806                               initial_state=initial_state)
1807        self.db._register_template(tpl)
1808    
1809    def define_particle_states(self, particle_name, states):
1810        """
1811        Define the chemical states of an existing particle template.
1812
1813        Args:
1814            particle_name ('str'):
1815                Name of a particle template. 
1816
1817            states ('list' of 'dict'):
1818                List of dictionaries defining the particle states. Each dictionary
1819                must contain:
1820                - 'name' ('str'): Name of the particle state (e.g. '"H"', '"-"',
1821                '"neutral"').
1822                - 'z' ('int'): Charge number of the particle in this state.
1823                Example:
1824                states = [{"name": "AH", "z": 0},     # protonated
1825                         {"name": "A-", "z": -1}]    # deprotonated
1826        Notes:
1827            - Each state is assigned a unique Espresso 'es_type' automatically.
1828            - Chemical reactions (e.g. acid–base equilibria) are **not** created by
1829            this method and must be defined separately (e.g. via
1830            'set_particle_acidity()' or custom reaction definitions).
1831            - Particles without explicitly defined states are assumed to have a
1832            single, implicit state with their default charge.
1833        """
1834        for s in states:
1835            state = ParticleStateTemplate(particle_name=particle_name,
1836                                          name=s["name"],
1837                                          z=s["z"],
1838                                          es_type=self.propose_unused_type())
1839            self.db._register_template(state)
1840
1841    def define_peptide(self, name, sequence, model):
1842        """
1843        Defines a peptide template in the pyMBE database.
1844
1845        Args:
1846            name ('str'): 
1847                Unique label that identifies the peptide.
1848
1849            sequence ('str'): 
1850                Sequence of the peptide.
1851
1852            model ('str'): 
1853                Model name. Currently only models with 1 bead '1beadAA' or with 2 beads '2beadAA' per amino acid are supported.
1854        """
1855        valid_keys = ['1beadAA','2beadAA']
1856        if model not in valid_keys:
1857            raise ValueError('Invalid label for the peptide model, please choose between 1beadAA or 2beadAA')
1858        clean_sequence = hf.protein_sequence_parser(sequence=sequence)    
1859        residue_list = self._get_residue_list_from_sequence(sequence=clean_sequence)
1860        tpl = PeptideTemplate(name=name,
1861                            residue_list=residue_list,
1862                            model=model,
1863                            sequence=sequence)
1864        self.db._register_template(tpl)        
1865    
1866    def define_protein(self, name, sequence, model):
1867        """
1868        Defines a protein template in the pyMBE database.
1869
1870        Args:
1871            name ('str'): 
1872                Unique label that identifies the protein.
1873
1874            sequence ('str'): 
1875                Sequence of the protein.
1876
1877            model ('string'): 
1878                Model name. Currently only models with 1 bead '1beadAA' or with 2 beads '2beadAA' per amino acid are supported.
1879
1880        Notes:
1881            - Currently, only 'lj_setup_mode="wca"' is supported. This corresponds to setting up the WCA potential.
1882        """
1883        valid_model_keys = ['1beadAA','2beadAA']
1884        if model not in valid_model_keys:
1885            raise ValueError('Invalid key for the protein model, supported models are {valid_model_keys}')
1886        
1887        residue_list = self._get_residue_list_from_sequence(sequence=sequence)
1888        tpl = ProteinTemplate(name=name,
1889                              model=model,
1890                              residue_list=residue_list,
1891                              sequence=sequence)
1892        self.db._register_template(tpl)
1893    
1894    def define_residue(self, name, central_bead, side_chains):
1895        """
1896        Defines a residue template in the pyMBE database.
1897
1898        Args:
1899            name ('str'): 
1900                Unique label that identifies the residue.
1901
1902            central_bead ('str'): 
1903                'name' of the 'particle' to be placed as central_bead of the residue.
1904
1905            side_chains('list' of 'str'): 
1906                List of 'name's of the pmb_objects to be placed as side_chains of the residue. Currently, only pyMBE objects of type 'particle' or 'residue' are supported.
1907        """
1908        tpl = ResidueTemplate(name=name,
1909                              central_bead=central_bead,
1910                              side_chains=side_chains)
1911        self.db._register_template(tpl)
1912
1913    
1914    def delete_instances_in_system(self, instance_id, pmb_type):
1915        """
1916        Deletes the instance with instance_id from the ESPResSo system. 
1917        Related assembly, molecule, residue, particles and bond instances will also be deleted from the pyMBE dataframe.
1918
1919        Args:
1920            instance_id ('int'): 
1921                id of the assembly to be deleted. 
1922
1923            pmb_type ('str'): 
1924                the instance type to be deleted. 
1925
1926            espresso_system ('espressomd.system.System'): 
1927                Instance of a system class from espressomd library.
1928        """
1929        if pmb_type == "particle":
1930            instance_identifier = "particle_id"
1931        elif pmb_type == "residue":
1932            instance_identifier = "residue_id"
1933        elif pmb_type in self.db._molecule_like_types:
1934            instance_identifier = "molecule_id"
1935        elif pmb_type in self.db._assembly_like_types:
1936            instance_identifier = "assembly_id"
1937        particle_ids = self.db._find_instance_ids_by_attribute(pmb_type="particle",
1938                                                               attribute=instance_identifier,
1939                                                               value=instance_id)
1940        self._delete_particles_from_engine(particle_ids=particle_ids)
1941        self.db.delete_instance(pmb_type=pmb_type,
1942                                instance_id=instance_id)
1943
1944    def determine_reservoir_concentrations(self, pH_res, c_salt_res, activity_coefficient_monovalent_pair, max_number_sc_runs=200):
1945        """
1946        Determines ionic concentrations in the reservoir at fixed pH and salt concentration.
1947
1948        Args:
1949            pH_res ('float'):
1950                Target pH value in the reservoir.
1951
1952            c_salt_res ('pint.Quantity'):
1953                Concentration of monovalent salt (e.g., NaCl) in the reservoir.
1954
1955            activity_coefficient_monovalent_pair ('callable'):
1956                Function returning the activity coefficient of a monovalent ion pair
1957                as a function of ionic strength:
1958                'gamma = activity_coefficient_monovalent_pair(I)'.
1959
1960            max_number_sc_runs ('int', optional):
1961                Maximum number of self-consistent iterations allowed before
1962                convergence is enforced. Defaults to 200.
1963
1964        Returns:
1965            tuple:
1966                (cH_res, cOH_res, cNa_res, cCl_res)
1967                - cH_res ('pint.Quantity'): Concentration of H⁺ ions.
1968                - cOH_res ('pint.Quantity'): Concentration of OH⁻ ions.
1969                - cNa_res ('pint.Quantity'): Concentration of Na⁺ ions.
1970                - cCl_res ('pint.Quantity'): Concentration of Cl⁻ ions.
1971
1972        Notess:
1973            - The algorithm enforces electroneutrality in the reservoir.
1974            - Water autodissociation is included via the equilibrium constant 'Kw'.
1975            - Non-ideal effects enter through activity coefficients depending on
1976            ionic strength.
1977            - The implementation follows the self-consistent scheme described in
1978            Landsgesell (PhD thesis, Sec. 5.3, doi:10.18419/opus-10831), adapted
1979            from the original code (doi:10.18419/darus-2237).
1980        """
1981        cH_res, cOH_res, cNa_res, cCl_res = self.simulation_engine.determine_reservoir_concentrations( pH_res, c_salt_res, activity_coefficient_monovalent_pair, max_number_sc_runs)
1982        return cH_res, cOH_res, cNa_res, cCl_res
1983
1984    def enable_motion_of_rigid_object(self, instance_id, pmb_type):
1985        """
1986        Enables translational and rotational motion of a rigid pyMBE object instance
1987        in an ESPResSo system.This method creates a rigid-body center particle at the center of mass of
1988        the specified pyMBE object and attaches all constituent particles to it
1989        using ESPResSo virtual sites. The resulting rigid object can translate and
1990        rotate as a single body.
1991
1992        Args:
1993            instance_id ('int'):
1994                Instance ID of the pyMBE object whose rigid-body motion is enabled.
1995
1996            pmb_type ('str'):
1997                pyMBE object type of the instance (e.g. '"molecule"', '"peptide"',
1998                '"protein"', or any assembly-like type).
1999
2000        Notess:
2001            - This method requires ESPResSo to be compiled with the following
2002            features enabled:
2003                - '"VIRTUAL_SITES_RELATIVE"'
2004                - '"MASS"'
2005            - A new ESPResSo particle is created to represent the rigid-body center.
2006            - The mass of the rigid-body center is set to the number of particles
2007            belonging to the object.
2008            - The rotational inertia tensor is approximated from the squared
2009            distances of the particles to the center of mass.
2010        """
2011        self.simulation_engine.enable_motion_of_rigid_object(instance_id, pmb_type)
2012
2013    def generate_coordinates_outside_sphere(self, center, radius, max_dist, n_samples):
2014        """
2015        Generates random coordinates outside a sphere and inside a larger bounding sphere.
2016
2017        Args:
2018            center ('array-like'):
2019                Coordinates of the center of the spheres.
2020
2021            radius ('float'):
2022                Radius of the inner exclusion sphere. Must be positive.
2023
2024            max_dist ('float'):
2025                Radius of the outer sampling sphere. Must be larger than 'radius'.
2026
2027            n_samples ('int'):
2028                Number of coordinates to generate.
2029
2030        Returns:
2031            'list' of 'numpy.ndarray':
2032                List of coordinates lying outside the inner sphere and inside the
2033                outer sphere.
2034
2035        Notess:
2036            - Points are uniformly sampled inside a sphere of radius 'max_dist' centered at 'center' 
2037            and only those with a distance greater than or equal to 'radius' from the center are retained.
2038        """
2039        if not radius > 0: 
2040            raise ValueError (f'The value of {radius} must be a positive value')
2041        if not radius < max_dist:
2042            raise ValueError(f'The min_dist ({radius} must be lower than the max_dist ({max_dist}))')
2043        coord_list = []
2044        counter = 0
2045        while counter<n_samples:
2046            coord = self.generate_random_points_in_a_sphere(center=center, 
2047                                            radius=max_dist,
2048                                            n_samples=1)[0]
2049            if np.linalg.norm(coord-np.asarray(center))>=radius:
2050                coord_list.append (coord)
2051                counter += 1
2052        return coord_list
2053    
2054    def generate_random_points_in_a_sphere(self, center, radius, n_samples, on_surface=False):
2055        """
2056        Generates uniformly distributed random points inside or on the surface of a sphere.
2057
2058        Args:
2059            center ('array-like'):
2060                Coordinates of the center of the sphere.
2061
2062            radius ('float'):
2063                Radius of the sphere.
2064
2065            n_samples ('int'):
2066                Number of sample points to generate.
2067
2068            on_surface ('bool', optional):
2069                If True, points are uniformly sampled on the surface of the sphere.
2070                If False, points are uniformly sampled within the sphere volume.
2071                Defaults to False.
2072
2073        Returns:
2074            'numpy.ndarray':
2075                Array of shape '(n_samples, d)' containing the generated coordinates,
2076                where 'd' is the dimensionality of 'center'.
2077        Notes:
2078            - Points are sampled in a space whose dimensionality is inferred 
2079            from the length of 'center'.
2080        """
2081        # initial values
2082        center=np.array(center)
2083        d = center.shape[0]
2084        # sample n_samples points in d dimensions from a standard normal distribution
2085        samples = self.rng.normal(size=(n_samples, d))
2086        # make the samples lie on the surface of the unit hypersphere
2087        normalize_radii = np.linalg.norm(samples, axis=1)[:, np.newaxis]
2088        samples /= normalize_radii
2089        if not on_surface:
2090            # make the samples lie inside the hypersphere with the correct density
2091            uniform_points = self.rng.uniform(size=n_samples)[:, np.newaxis]
2092            new_radii = np.power(uniform_points, 1/d)
2093            samples *= new_radii
2094        # scale the points to have the correct radius and center
2095        samples = samples * radius + center
2096        return samples 
2097
2098    def generate_trial_perpendicular_vector(self,vector,magnitude):
2099        """
2100        Generates a random vector perpendicular to a given vector.
2101
2102        Args:
2103            vector ('array-like'):
2104                Reference vector to which the generated vector will be perpendicular.
2105
2106            magnitude ('float'):
2107                Desired magnitude of the perpendicular vector.
2108
2109        Returns:
2110            'numpy.ndarray':
2111                Vector orthogonal to 'vector' with norm equal to 'magnitude'.
2112        """ 
2113        np_vec = np.array(vector) 
2114        if np.all(np_vec == 0):
2115            raise ValueError('Zero vector')
2116        np_vec /= np.linalg.norm(np_vec) 
2117        # Generate a random vector 
2118        random_vector = self.generate_random_points_in_a_sphere(radius=1, 
2119                                                                center=[0,0,0],
2120                                                                n_samples=1, 
2121                                                                on_surface=True)[0]
2122        # Project the random vector onto the input vector and subtract the projection
2123        projection = np.dot(random_vector, np_vec) * np_vec
2124        perpendicular_vector = random_vector - projection
2125        # Normalize the perpendicular vector to have the same magnitude as the input vector
2126        perpendicular_vector /= np.linalg.norm(perpendicular_vector) 
2127        return perpendicular_vector*magnitude
2128            
2129    def get_bond_template(self, particle_name1, particle_name2, use_default_bond=False) :
2130        """
2131        Retrieves a bond template connecting two particle templates.
2132
2133        Args:
2134            particle_name1 ('str'):
2135                Name of the first particle template.
2136
2137            particle_name2 ('str'):
2138                Name of the second particle template.
2139
2140            use_default_bond ('bool', optional):
2141                If True, returns the default bond template when no specific bond
2142                template is found. Defaults to False.
2143
2144        Returns:
2145            'BondTemplate':
2146                Bond template object retrieved from the pyMBE database.
2147            
2148        Notes:
2149            - This method searches the pyMBE database for a bond template defined between particle templates with names 'particle_name1' and 'particle_name2'. 
2150            - If no specific bond template is found and 'use_default_bond' is enabled, a default bond template is returned instead.
2151        """
2152        # Try to find a specific bond template
2153        bond_key = BondTemplate.make_bond_key(pn1=particle_name1,
2154                                              pn2=particle_name2)
2155        try:
2156            return self.db.get_template(name=bond_key, 
2157                                        pmb_type="bond")
2158        except ValueError:
2159            pass
2160
2161        #  Fallback to default bond if allowed
2162        if use_default_bond:
2163            return self.db.get_template(name="default", 
2164                                        pmb_type="bond")
2165
2166        # No bond template found
2167        raise ValueError(f"No bond template found between '{particle_name1}' and '{particle_name2}', and default bonds are deactivated.")
2168    
2169    def get_charge_number_map(self):
2170        """
2171        Construct a mapping from ESPResSo particle types to their charge numbers.
2172
2173        Returns:
2174            'dict[int, float]':
2175                Dictionary mapping ESPResSo particle types to charge numbers,
2176                ''{es_type: z}''.
2177
2178        Notess:
2179            - The mapping is built from particle *states*, not instances.
2180            - If multiple templates define states with the same ''es_type'',
2181            the last encountered definition will overwrite previous ones.
2182            This behavior is intentional and assumes database consistency.
2183            - Neutral particles (''z = 0'') are included in the map.
2184        """
2185        charge_number_map = {}
2186        particle_templates = self.db.get_templates("particle")
2187        for tpl in particle_templates.values():
2188            for state in self.db.get_particle_states_templates(particle_name=tpl.name).values():
2189                charge_number_map[state.es_type] = state.z
2190        return charge_number_map
2191
2192    def get_instances_df(self, pmb_type):
2193        """
2194        Returns a dataframe with all instances of type 'pmb_type' in the pyMBE database.
2195
2196        Args:
2197            pmb_type ('str'): 
2198                pmb type to search instances in the pyMBE database.
2199        
2200        Returns:
2201            ('Pandas.Dataframe'): 
2202                Dataframe with all instances of type 'pmb_type'.
2203        """
2204        return self.db._get_instances_df(pmb_type=pmb_type)
2205
2206    def get_lj_parameters(self, particle_name1, particle_name2, combining_rule='Lorentz-Berthelot'):
2207        """
2208        Returns the Lennard-Jones parameters for the interaction between the particle types given by 
2209        'particle_name1' and 'particle_name2' in the pyMBE database, calculated according to the provided combining rule.
2210
2211        Args:
2212            particle_name1 ('str'): 
2213                label of the type of the first particle type
2214
2215            particle_name2 ('str'): 
2216                label of the type of the second particle type
2217
2218            combining_rule ('string', optional): 
2219                combining rule used to calculate 'sigma' and 'epsilon' for the potential betwen a pair of particles. Defaults to 'Lorentz-Berthelot'.
2220
2221        Returns:
2222            ('dict'):
2223                {"epsilon": epsilon_value, "sigma": sigma_value, "offset": offset_value, "cutoff": cutoff_value}
2224
2225        Notes:
2226            - Currently, the only 'combining_rule' supported is Lorentz-Berthelot.
2227            - If the sigma value of 'particle_name1' or 'particle_name2' is 0, the function will return an empty dictionary. No LJ interactions are set up for particles with sigma = 0.
2228        """
2229        lj_parameters=self.db.get_lj_parameters(particle_name1=particle_name1,particle_name2=particle_name2,combining_rule=combining_rule)
2230        return lj_parameters
2231
2232    def get_particle_id_map(self, object_name):
2233        """
2234        Collect all particle IDs associated with an object of given name in the
2235        pyMBE database. 
2236
2237        Args:
2238            object_name ('str'): 
2239                Name of the object.
2240
2241        Returns:
2242            ('dict'): 
2243                {"all": [particle_ids],
2244                 "residue_map": {residue_id: [particle_ids]},
2245                 "molecule_map": {molecule_id: [particle_ids]},
2246                 "assembly_map": {assembly_id: [particle_ids]},}
2247
2248        Notess:
2249            - Works for all supported pyMBE templates.
2250            - Relies in the internal method Manager.get_particle_id_map, see method for the detailed code.
2251        """
2252        return self.db.get_particle_id_map(object_name=object_name)
2253
2254    def get_pka_set(self):
2255        """
2256        Retrieve the pKa set for all titratable particles in the pyMBE database.
2257
2258        Returns:
2259            ('dict'): 
2260                Dictionary of the form:
2261                {"particle_name": {"pka_value": float,
2262                                   "acidity": "acidic" | "basic"}}
2263        Notes:
2264            - If a particle participates in multiple acid/base reactions, an error is raised.
2265        """
2266        pka_set = {}
2267        supported_reactions = ["monoprotic_acid",
2268                               "monoprotic_base"]
2269        for reaction in self.db._reactions.values():
2270            if reaction.reaction_type not in supported_reactions:
2271                continue
2272            # Identify involved particle(s)
2273            particle_names = {participant.particle_name for participant in reaction.participants}
2274            particle_name = particle_names.pop()
2275            if particle_name in pka_set:
2276                raise ValueError(f"Multiple acid/base reactions found for particle '{particle_name}'.")
2277            pka_set[particle_name] = {"pka_value": reaction.pK}
2278            if reaction.reaction_type == "monoprotic_acid":
2279                acidity = "acidic"
2280            elif reaction.reaction_type == "monoprotic_base":
2281                acidity = "basic"
2282            pka_set[particle_name]["acidity"] = acidity
2283        return pka_set
2284    
2285    def get_radius_map(self, dimensionless=True):
2286        """
2287        Gets the effective radius of each particle defined in the pyMBE database. 
2288
2289        Args:
2290            dimensionless ('bool'):
2291                If ``True``, return magnitudes expressed in ``reduced_length``.
2292                If ``False``, return Pint quantities with units.
2293        
2294        Returns:
2295            ('dict'): 
2296                {espresso_type: radius}.
2297
2298        Notes:
2299            - The radius corresponds to (sigma+offset)/2
2300        """
2301        return self.db.get_radius_map(dimensionless)
2302
2303    def get_reactions_df(self):
2304        """
2305        Returns a dataframe with all reaction templates in the pyMBE database.
2306
2307        Returns:
2308            (Pandas.Dataframe): 
2309                Dataframe with all  reaction templates.
2310        """
2311        return self.db._get_reactions_df()
2312
2313    def get_reduced_units(self):
2314        """
2315        Returns the  current set of reduced units defined in pyMBE.
2316
2317        Returns:
2318            reduced_units_text ('str'): 
2319                text with information about the current set of reduced units.
2320
2321        """
2322        unit_length=self.units.Quantity(1,'reduced_length')
2323        unit_energy=self.units.Quantity(1,'reduced_energy')
2324        unit_charge=self.units.Quantity(1,'reduced_charge')
2325        reduced_units_text = "\n".join(["Current set of reduced units:",
2326                                       f"{unit_length.to('nm'):.5g} = {unit_length}",
2327                                       f"{unit_energy.to('J'):.5g} = {unit_energy}",
2328                                       f"{unit_charge.to('C'):.5g} = {unit_charge}",
2329                                       f"Temperature: {(self.kT/self.kB).to('K'):.5g}"])   
2330        return reduced_units_text
2331
2332    def get_templates_df(self, pmb_type):
2333        """
2334        Returns a dataframe with all templates of type 'pmb_type' in the pyMBE database.
2335
2336        Args:
2337            pmb_type ('str'): 
2338                pmb type to search templates in the pyMBE database.
2339        
2340        Returns:
2341            ('Pandas.Dataframe'): 
2342                Dataframe with all templates of type given by 'pmb_type'.
2343        """
2344        return self.db._get_templates_df(pmb_type=pmb_type)
2345
2346    def get_type_map(self):
2347        """
2348        Return the mapping of ESPResSo types for all particle states defined in the pyMBE database.
2349        
2350        Returns:
2351            'dict[str, int]':
2352                A dictionary mapping each particle state to its corresponding ESPResSo type:
2353                {state_name: es_type, ...}
2354        """
2355        
2356        return self.db.get_es_types_map()
2357
2358    def initialize_lattice_builder(self, diamond_lattice):
2359        """
2360        Initialize the lattice builder with the DiamondLattice object.
2361
2362        Args:
2363            diamond_lattice ('DiamondLattice'): 
2364                DiamondLattice object from the 'lib/lattice' module to be used in the LatticeBuilder.
2365        """
2366        from .lib.lattice import LatticeBuilder, DiamondLattice
2367        if not isinstance(diamond_lattice, DiamondLattice):
2368            raise TypeError("Currently only DiamondLattice objects are supported.")
2369        self.lattice_builder = LatticeBuilder(lattice=diamond_lattice)
2370        logging.info(f"LatticeBuilder initialized with mpc={diamond_lattice.mpc} and box_l={diamond_lattice.box_l}")
2371        return self.lattice_builder
2372
2373    def load_database(self, folder, format='csv'):
2374        """
2375        Loads a pyMBE database stored in 'folder'.
2376
2377        Args:
2378            folder ('str' or 'Path'): 
2379                Path to the folder where the pyMBE database was stored.
2380
2381            format ('str', optional): 
2382                Format of the database to be loaded. Defaults to 'csv'.
2383
2384        Return:
2385            ('dict'): 
2386                metadata with additional information about the source of the information in the database.
2387
2388        Notes:
2389            - The folder must contain the files generated by 'pmb.save_database()'.
2390            - Currently, only 'csv' format is supported.
2391        """
2392        supported_formats = ['csv']
2393        if format not in supported_formats:
2394            raise ValueError(f"Format {format} not supported. Supported formats are {supported_formats}")
2395        if format == 'csv':
2396            metadata =io._load_database_csv(self.db, 
2397                                            folder=folder)
2398        return metadata
2399        
2400    def load_pka_set(self, filename):
2401        """
2402        Load a pKa set and attach chemical states and acid–base reactions
2403        to existing particle templates.
2404
2405        Args:
2406            filename ('str'): 
2407                Path to a JSON file containing the pKa set. Expected format:
2408                {"metadata": {...},
2409                  "data": {"A": {"acidity": "acidic", "pka_value": 4.5},
2410                           "B": {"acidity": "basic",  "pka_value": 9.8}}}
2411
2412        Returns:
2413            ('dict'): 
2414                Dictionary with bibliographic metadata about the original work were the pKa set was determined.
2415
2416        Notes:
2417            - This method is designed for monoprotic acids and bases only.
2418        """
2419        with open(filename, "r") as f:
2420            pka_data = json.load(f)
2421        pka_set = pka_data["data"]
2422        metadata = pka_data.get("metadata", {})
2423        self._check_pka_set(pka_set)
2424        for particle_name, entry in pka_set.items():
2425            acidity = entry["acidity"]
2426            pka = entry["pka_value"]
2427            self.define_monoprototic_acidbase_reaction(particle_name=particle_name,
2428                                                       pka=pka,
2429                                                       acidity=acidity,
2430                                                       metadata=metadata)
2431        return metadata
2432            
2433    def propose_unused_type(self):
2434        """
2435        Propose an unused ESPResSo particle type.
2436
2437        Returns:
2438            ('int'): 
2439                The next available integer ESPResSo type. Returns ''0'' if no integer types are currently defined.
2440        """    
2441        return self.db.propose_unused_type()
2442       
2443    def read_protein_vtf(self, filename, unit_length=None):
2444        """
2445        Loads a coarse-grained protein model from a VTF file.
2446
2447        Args:
2448            filename ('str'): 
2449                Path to the VTF file.
2450                
2451            unit_length ('Pint.Quantity'): 
2452                Unit of length for coordinates (pyMBE UnitRegistry). Defaults to Angstrom.
2453
2454        Returns:
2455            ('tuple'):
2456                ('dict'): Particle topology.    
2457                ('str'):  One-letter amino-acid sequence (including n/c ends).
2458        """
2459        logging.info(f"Loading protein coarse-grain model file: {filename}")
2460        if unit_length is None:
2461            unit_length = 1 * self.units.angstrom
2462        atoms = {}        # atom_id -> atom info
2463        coords = []       # ordered coordinates
2464        residues = {}     # resid -> resname (first occurrence)
2465        has_n_term = False
2466        has_c_term = False
2467        aa_3to1 = {"ALA": "A", "ARG": "R", "ASN": "N", "ASP": "D",
2468                   "CYS": "C", "GLU": "E", "GLN": "Q", "GLY": "G",
2469                   "HIS": "H", "ILE": "I", "LEU": "L", "LYS": "K",
2470                   "MET": "M", "PHE": "F", "PRO": "P", "SER": "S",
2471                   "THR": "T", "TRP": "W", "TYR": "Y", "VAL": "V",
2472                   "n": "n", "c": "c"}
2473        # --- parse VTF ---
2474        with open(filename, "r") as f:
2475            for line in f:
2476                fields = line.split()
2477                if not fields:
2478                    continue
2479                if fields[0] == "atom":
2480                    atom_id = int(fields[1])
2481                    atom_name = fields[3]
2482                    resname = fields[5]
2483                    resid = int(fields[7])
2484                    chain_id = fields[9]
2485                    radius = float(fields[11]) * unit_length
2486                    atoms[atom_id] = {"name": atom_name,
2487                                     "resname": resname,
2488                                     "resid": resid,
2489                                     "chain_id": chain_id,
2490                                     "radius": radius}
2491                    if resname == "n":
2492                        has_n_term = True
2493                    elif resname == "c":
2494                        has_c_term = True
2495                    # register residue 
2496                    if resid not in residues:
2497                        residues[resid] = resname
2498                elif fields[0].isnumeric():
2499                    xyz = [(float(x) * unit_length).to("reduced_length").magnitude
2500                        for x in fields[1:4]]
2501                    coords.append(xyz)
2502        sequence = ""
2503        # N-terminus
2504        if has_n_term:
2505            sequence += "n"
2506        # protein residues only
2507        protein_resids = sorted(resid for resid, resname in residues.items()  if resname not in ("n", "c", "Ca"))
2508        for resid in protein_resids:
2509            resname = residues[resid]
2510            try:
2511                sequence += aa_3to1[resname]
2512            except KeyError:
2513                raise ValueError(f"Unknown residue name '{resname}' in VTF file")
2514        # C-terminus
2515        if has_c_term:
2516            sequence += "c"
2517        last_resid = max(protein_resids)
2518        # --- build topology ---
2519        topology_dict = {}
2520        for atom_id in sorted(atoms.keys()):
2521            atom = atoms[atom_id]
2522            resname = atom["resname"]
2523            resid = atom["resid"]
2524            # apply labeling rules
2525            if resname == "n":
2526                label_resid = 0
2527            elif resname == "c":
2528                label_resid = last_resid + 1
2529            elif resname == "Ca":
2530                label_resid = last_resid + 2
2531            else:
2532                label_resid = resid  # preserve original resid 
2533            label = f"{atom['name']}{label_resid}"
2534            if label in topology_dict:
2535                raise ValueError(f"Duplicate particle label '{label}'. Check VTF residue definitions.")
2536            topology_dict[label] = {"initial_pos": coords[atom_id - 1], "chain_id": atom["chain_id"], "radius": atom["radius"],}
2537        return topology_dict, sequence
2538
2539    
2540    def save_database(self, folder, format='csv'):
2541        """
2542        Saves the current pyMBE database into a file 'filename'.
2543
2544        Args:
2545            folder ('str' or 'Path'): 
2546                Path to the folder where the database files will be saved.
2547
2548        """
2549        supported_formats = ['csv']
2550        if format not in supported_formats:
2551            raise ValueError(f"Format {format} not supported. Supported formats are: {supported_formats}")
2552        if format == 'csv':
2553            io._save_database_csv(self.db, 
2554                                folder=folder)
2555
2556    def set_particle_initial_state(self, particle_name, state_name):
2557        """
2558        Sets the default initial state of a particle template defined in the pyMBE database.
2559
2560        Args:
2561            particle_name ('str'): 
2562                Unique label that identifies the particle template. 
2563
2564            state_name ('str'): 
2565                Name of the state to be set as default initial state.
2566        """
2567        part_tpl = self.db.get_template(name=particle_name,
2568        
2569                                        pmb_type="particle")
2570        part_tpl.initial_state = state_name
2571        logging.info(f"Default initial state of particle {particle_name} set to {state_name}.")
2572
2573    def set_reduced_units(self, unit_length=None, unit_charge=None, temperature=None, Kw=None):
2574        """
2575        Sets the set of reduced units used by pyMBE.units and it prints it.
2576
2577        Args:
2578            unit_length ('pint.Quantity', optional): 
2579                Reduced unit of length defined using the 'pmb.units' UnitRegistry. Defaults to None. 
2580
2581            unit_charge ('pint.Quantity', optional): 
2582                Reduced unit of charge defined using the 'pmb.units' UnitRegistry. Defaults to None. 
2583
2584            temperature ('pint.Quantity', optional): 
2585                Temperature of the system, defined using the 'pmb.units' UnitRegistry. Defaults to None. 
2586
2587            Kw ('pint.Quantity', optional): 
2588                Ionic product of water in mol^2/l^2. Defaults to None. 
2589
2590        Notes:
2591            - If no 'temperature' is given, a value of 298.15 K is assumed by default.
2592            - If no 'unit_length' is given, a value of 0.355 nm is assumed by default.
2593            - If no 'unit_charge' is given, a value of 1 elementary charge is assumed by default. 
2594            - If no 'Kw' is given, a value of 10^(-14) * mol^2 / l^2 is assumed by default. 
2595        """
2596        if unit_length is None:
2597            unit_length= 0.355*self.units.nm
2598        if temperature is None:
2599            temperature = 298.15 * self.units.K
2600        if unit_charge is None:
2601            unit_charge = scipy.constants.e * self.units.C
2602        if Kw is None:
2603            Kw = 1e-14
2604        # Sanity check
2605        variables=[unit_length,temperature,unit_charge]
2606        dimensionalities=["[length]","[temperature]","[charge]"]
2607        for variable,dimensionality in zip(variables,dimensionalities):
2608            self._check_dimensionality(variable,dimensionality)
2609        self.Kw=Kw*self.units.mol**2 / (self.units.l**2)
2610        self.kT=temperature*self.kB
2611        self.units._build_cache()
2612        self.units.define(f'reduced_energy = {self.kT} ')
2613        self.units.define(f'reduced_length = {unit_length}')
2614        self.units.define(f'reduced_charge = {unit_charge}')
2615        logging.info(self.get_reduced_units())
2616
2617    def set_simulation_engine(self,simulation_engine,box_l=None):
2618        """ 
2619        Sets the instance attribute simulation_engine to an instance of a class of type SimulationEngine.
2620            
2621        Args:
2622            simulation_engine (Any): object which contains the methods to setup molecular dynamics and montecarlo simulations
2623            box_l('list[float,float,float]'): list of floats with the dimensions of the box
2624        """
2625
2626        if isinstance(simulation_engine, espressomd.System):
2627            self.simulation_engine=EspressoSimulation(box_l=simulation_engine.box_l,
2628                                                      db=self.db,
2629                                                      espresso_system=simulation_engine,
2630                                                      units=self.units,
2631                                                      kT=self.kT,
2632                                                      Kw=self.Kw,
2633                                                      seed=self.seed)
2634        elif isinstance(simulation_engine,LammpsProtocol): 
2635            self.simulation_engine=LammpsSimulation(box_l=box_l,
2636                                                      db=self.db,
2637                                                      lammps=simulation_engine,
2638                                                      units=self.units,
2639                                                      kT=self.kT,
2640                                                      Kw=self.Kw,
2641                                                      seed=self.seed)
2642        else:
2643            raise ValueError('The specified simulation engine is not implemented yet')
2644
2645    def setup_cpH (self, counter_ion, constant_pH, exclusion_range=None, use_exclusion_radius_per_type = False):
2646        """
2647        Sets up the Acid/Base reactions for acidic/basic particles defined in the pyMBE database
2648        to be sampled in the constant pH ensemble. 
2649
2650        Args:
2651            counter_ion ('str'): 
2652                'name' of the counter_ion 'particle'.
2653
2654            constant_pH ('float'): 
2655                pH-value.
2656
2657            exclusion_range ('pint.Quantity', optional): 
2658                Below this value, no particles will be inserted.
2659
2660            use_exclusion_radius_per_type ('bool', optional): 
2661                Controls if one exclusion_radius for each espresso_type is used. Defaults to 'False'.
2662
2663        Returns:
2664            ('reaction_methods.ConstantpHEnsemble'): 
2665                Instance of a reaction_methods.ConstantpHEnsemble object from the espressomd library.
2666        """
2667       
2668        RE = self.simulation_engine.setup_cpH(counter_ion=counter_ion, 
2669                                        constant_pH=constant_pH, 
2670                                        exclusion_range=exclusion_range, 
2671                                        use_exclusion_radius_per_type = use_exclusion_radius_per_type)
2672        return RE
2673
2674    def setup_gcmc(self, c_salt_res, salt_cation_name, salt_anion_name, activity_coefficient, exclusion_range=None, use_exclusion_radius_per_type = False):
2675        """
2676        Sets up grand-canonical coupling to a reservoir of salt.
2677        For reactive systems coupled to a reservoir, the grand-reaction method has to be used instead.
2678
2679        Args:
2680            c_salt_res ('pint.Quantity'): 
2681                Concentration of monovalent salt (e.g. NaCl) in the reservoir.
2682
2683            salt_cation_name ('str'): 
2684                Name of the salt cation (e.g. Na+) particle.
2685
2686            salt_anion_name ('str'): 
2687                Name of the salt anion (e.g. Cl-) particle.
2688
2689            activity_coefficient ('callable'): 
2690                A function that calculates the activity coefficient of an ion pair as a function of the ionic strength.
2691
2692            exclusion_range('pint.Quantity', optional): 
2693                For distances shorter than this value, no particles will be inserted.
2694
2695            use_exclusion_radius_per_type('bool',optional): 
2696                Controls if one exclusion_radius for each espresso_type is used. Defaults to 'False'.
2697
2698        Returns:
2699            ('reaction_methods.ReactionEnsemble'): 
2700                Instance of a reaction_methods.ReactionEnsemble object from the espressomd library.
2701        """
2702        RE = self.simulation_engine.setup_gcmc(c_salt_res=c_salt_res, 
2703                                                salt_anion_name=salt_anion_name, 
2704                                                salt_cation_name=salt_cation_name, 
2705                                                activity_coefficient=activity_coefficient, 
2706                                                exclusion_range=exclusion_range, 
2707                                                use_exclusion_radius_per_type = use_exclusion_radius_per_type)
2708        return RE
2709        
2710    def setup_grxmc_reactions(self, pH_res, c_salt_res, proton_name, hydroxide_name, salt_cation_name, salt_anion_name, activity_coefficient, exclusion_range=None, use_exclusion_radius_per_type = False):
2711        """
2712        Sets up acid/base reactions for acidic/basic monoprotic particles defined in the pyMBE database, 
2713        as well as a grand-canonical coupling to a reservoir of small ions. 
2714        
2715
2716        Args:
2717            pH_res ('float'): 
2718                pH-value in the reservoir.
2719
2720            c_salt_res ('pint.Quantity'): 
2721                Concentration of monovalent salt (e.g. NaCl) in the reservoir.
2722
2723            proton_name ('str'): 
2724                Name of the proton (H+) particle.
2725
2726            hydroxide_name ('str'): 
2727                Name of the hydroxide (OH-) particle.
2728
2729            salt_cation_name ('str'): 
2730                Name of the salt cation (e.g. Na+) particle.
2731
2732            salt_anion_name ('str'): 
2733                Name of the salt anion (e.g. Cl-) particle.
2734
2735            activity_coefficient ('callable'): 
2736                A function that calculates the activity coefficient of an ion pair as a function of the ionic strength.
2737
2738            exclusion_range('pint.Quantity', optional): 
2739                For distances shorter than this value, no particles will be inserted.
2740
2741            use_exclusion_radius_per_type('bool', optional): 
2742                Controls if one exclusion_radius for each espresso_type is used. Defaults to 'False'.
2743
2744        Returns:
2745            For the Espresso system class:
2746            Output ('tuple(reaction_methods.ReactionEnsemble,pint.Quantity)'):
2747
2748                'reaction_methods.ReactionEnsemble':  
2749                    espressomd reaction_methods object with all reactions necesary to run the GRxMC ensamble.
2750                
2751                'pint.Quantity': 
2752                    Ionic strength of the reservoir (useful for calculating partition coefficients).
2753
2754        Notess:
2755            - This implementation uses the original formulation of the grand-reaction method by Landsgesell et al. [1].
2756
2757        [1] Landsgesell, J., Hebbeker, P., Rud, O., Lunkad, R., Košovan, P., & Holm, C. (2020). Grand-reaction method for simulations of ionization equilibria coupled to ion partitioning. Macromolecules, 53(8), 3007-3020.
2758        """     
2759        output=self.simulation_engine.setup_grxmc_reactions(pH_res=pH_res, 
2760                                                            c_salt_res=c_salt_res, 
2761                                                            proton_name=proton_name, 
2762                                                            hydroxide_name=hydroxide_name, 
2763                                                            salt_cation_name=salt_cation_name, 
2764                                                            salt_anion_name=salt_anion_name, 
2765                                                            activity_coefficient=activity_coefficient, 
2766                                                            exclusion_range=exclusion_range, 
2767                                                            use_exclusion_radius_per_type=use_exclusion_radius_per_type)
2768        
2769        return output
2770        
2771    def setup_grxmc_unified(self, pH_res, c_salt_res, cation_name, anion_name, activity_coefficient, exclusion_range=None, use_exclusion_radius_per_type = False):
2772        """
2773        Sets up acid/base reactions for acidic/basic 'particles' defined in the pyMBE database, as well as a grand-canonical coupling to a 
2774        reservoir of small ions using a unified formulation for small ions.
2775
2776        Args:
2777            pH_res ('float'): 
2778                pH-value in the reservoir.
2779
2780            c_salt_res ('pint.Quantity'): 
2781                Concentration of monovalent salt (e.g. NaCl) in the reservoir.
2782
2783            cation_name ('str'): 
2784                Name of the cationic particle.
2785
2786            anion_name ('str'): 
2787                Name of the anionic particle.
2788
2789            activity_coefficient ('callable'): 
2790                A function that calculates the activity coefficient of an ion pair as a function of the ionic strength.
2791
2792            exclusion_range('pint.Quantity', optional): 
2793                Below this value, no particles will be inserted.
2794            
2795            use_exclusion_radius_per_type('bool', optional): 
2796                Controls if one exclusion_radius per each espresso_type. Defaults to 'False'.
2797
2798        Returns:
2799            For the Espresso system class:
2800            Output ('tuple(reaction_methods.ReactionEnsemble,pint.Quantity)'):
2801
2802                'reaction_methods.ReactionEnsemble':  
2803                    espressomd reaction_methods object with all reactions necesary to run the GRxMC ensamble.
2804                
2805                'pint.Quantity': 
2806                    Ionic strength of the reservoir (useful for calculating partition coefficients).
2807
2808        Notes:
2809            - This implementation uses the formulation of the grand-reaction method by Curk et al. [1], which relies on "unified" ion types X+ = {H+, Na+} and X- = {OH-, Cl-}. 
2810            - A function that implements the original version of the grand-reaction method by Landsgesell et al. [2] is also available under the name 'setup_grxmc_reactions'.
2811
2812        [1] Curk, T., Yuan, J., & Luijten, E. (2022). Accelerated simulation method for charge regulation effects. The Journal of Chemical Physics, 156(4).
2813        [2] Landsgesell, J., Hebbeker, P., Rud, O., Lunkad, R., Košovan, P., & Holm, C. (2020). Grand-reaction method for simulations of ionization equilibria coupled to ion partitioning. Macromolecules, 53(8), 3007-3020.
2814        """       
2815        output=self.simulation_engine.setup_grxmc_unified(pH_res=pH_res, 
2816                                                         c_salt_res=c_salt_res, 
2817                                                         cation_name=cation_name, 
2818                                                         anion_name=anion_name, 
2819                                                         activity_coefficient=activity_coefficient, 
2820                                                         exclusion_range=exclusion_range, 
2821                                                         use_exclusion_radius_per_type = use_exclusion_radius_per_type)
2822        return output
2823
2824    def setup_lj_interactions(self, shift_potential=True, combining_rule='Lorentz-Berthelot'):
2825        """
2826        Sets up the Lennard-Jones (LJ) potential between all pairs of particle states defined in the pyMBE database.
2827
2828        Args:
2829
2830            shift_potential('bool', optional): 
2831                If True, a shift will be automatically computed such that the potential is continuous at the cutoff radius. Otherwise, no shift will be applied. Defaults to True.
2832
2833            combining_rule('string', optional): 
2834                combining rule used to calculate 'sigma' and 'epsilon' for the potential between a pair of particles. Defaults to 'Lorentz-Berthelot'.
2835
2836            warning('bool', optional): 
2837                switch to activate/deactivate warning messages. Defaults to True.
2838
2839        Notes:
2840            - Currently, the only 'combining_rule' supported is Lorentz-Berthelot.
2841            - Check the documentation of ESPResSo for more info about the potential https://espressomd.github.io/doc4.2.0/inter_non-bonded.html
2842
2843        """
2844        self.simulation_engine.setup_lj_interactions(shift_potential=shift_potential, 
2845                                                     combining_rule=combining_rule)

Core library of the Molecular Builder for ESPResSo (pyMBE).

Attributes:
  • N_A ('pint.Quantity'): Avogadro number.
  • kB ('pint.Quantity'): Boltzmann constant.
  • e ('pint.Quantity'): Elementary charge.
  • kT ('pint.Quantity'): Thermal energy corresponding to the set temperature.
  • Kw ('pint.Quantity'): Ionic product of water, used in G-RxMC and Donnan-related calculations.
  • db ('Manager'): Database manager holding all pyMBE templates, instances and reactions.
  • rng ('numpy.random.Generator'): Random number generator initialized with the provided seed.
  • units ('pint.UnitRegistry'): Pint unit registry used for unit-aware calculations.
  • lattice_builder ('pyMBE.lib.lattice.LatticeBuilder'): Optional lattice builder object (initialized as ''None'').
  • root ('importlib.resources.abc.Traversable'): Root path to the pyMBE package resources.
pymbe_library(seed, temperature=None, unit_length=None, unit_charge=None, Kw=None)
 99    def __init__(self, seed, temperature=None, unit_length=None, unit_charge=None, Kw=None):
100        """
101        Initializes the pyMBE library.
102
103        Args:
104            seed ('int'):
105                Seed for the random number generator.
106
107            temperature ('pint.Quantity', optional):
108                Simulation temperature. If ''None'', defaults to 298.15 K.
109
110            unit_length ('pint.Quantity', optional):
111                Reference length for reduced units. If ''None'', defaults to
112                0.355 nm.
113
114            unit_charge ('pint.Quantity', optional):
115                Reference charge for reduced units. If ''None'', defaults to
116                one elementary charge.
117
118            Kw ('pint.Quantity', optional):
119                Ionic product of water (typically in mol²/L²). If ''None'',
120                defaults to 1e-14 mol²/L².
121        """
122        # Seed and RNG
123        self.seed=seed
124        self.rng = np.random.default_rng(seed)
125        self.units=pint.UnitRegistry()
126        self.N_A=scipy.constants.N_A / self.units.mol
127        self.kB=scipy.constants.k * self.units.J / self.units.K
128        self.e=scipy.constants.e * self.units.C
129        self.set_reduced_units(unit_length=unit_length, 
130                               unit_charge=unit_charge,
131                               temperature=temperature, 
132                               Kw=Kw)
133        
134        self.db = Manager(units=self.units)
135        self.simulation_engine = DummyEngine()
136        self.lattice_builder = None
137        self.root = importlib.resources.files(__package__)

Initializes the pyMBE library.

Arguments:
  • seed ('int'): Seed for the random number generator.
  • temperature ('pint.Quantity', optional): Simulation temperature. If ''None'', defaults to 298.15 K.
  • unit_length ('pint.Quantity', optional): Reference length for reduced units. If ''None'', defaults to 0.355 nm.
  • unit_charge ('pint.Quantity', optional): Reference charge for reduced units. If ''None'', defaults to one elementary charge.
  • Kw ('pint.Quantity', optional): Ionic product of water (typically in mol²/L²). If ''None'', defaults to 1e-14 mol²/L².
seed
rng
units
N_A
kB
e
db
simulation_engine
lattice_builder
root
def add_instances_to_engine(self):
416    def add_instances_to_engine(self):
417        self.simulation_engine.add_instances_to_engine()
def calculate_center_of_mass(self, instance_id, pmb_type):
419    def calculate_center_of_mass(self, instance_id, pmb_type):
420        """
421        Calculates the center of mass of a pyMBE object instance in an ESPResSo system.
422
423        Args:
424            instance_id ('int'):
425                pyMBE instance ID of the object whose center of mass is calculated.
426
427            pmb_type ('str'):
428                Type of the pyMBE object. Must correspond to a particle-aggregating
429                template type (e.g. '"molecule"', '"residue"', '"peptide"', '"protein"').
430
431        Returns:
432            ('numpy.ndarray'):
433                Array of shape '(3,)' containing the Cartesian coordinates of the
434                center of mass.
435
436        Notes:
437            - This method assumes equal mass for all particles.
438            - Periodic boundary conditions are *not* unfolded; positions are taken
439            directly from ESPResSo particle coordinates.
440        """
441        return self.simulation_engine.calculate_center_of_mass(instance_id=instance_id,
442                                                               pmb_type=pmb_type)

Calculates the center of mass of a pyMBE object instance in an ESPResSo system.

Arguments:
  • instance_id ('int'): pyMBE instance ID of the object whose center of mass is calculated.
  • pmb_type ('str'): Type of the pyMBE object. Must correspond to a particle-aggregating template type (e.g. '"molecule"', '"residue"', '"peptide"', '"protein"').
Returns:

('numpy.ndarray'): Array of shape '(3,)' containing the Cartesian coordinates of the center of mass.

Notes:
  • This method assumes equal mass for all particles.
  • Periodic boundary conditions are not unfolded; positions are taken directly from ESPResSo particle coordinates.
def calculate_HH(self, template_name, pH_list=None, pka_set=None):
444    def calculate_HH(self, template_name, pH_list=None, pka_set=None):
445        """
446        Calculates the charge in the template object according to the ideal  Henderson–Hasselbalch titration curve.
447
448        Args:
449            template_name ('str'):
450                Name of the template.
451
452            pH_list ('list[float]', optional):
453                pH values at which the charge is evaluated.
454                Defaults to 50 values between 2 and 12.
455
456            pka_set ('dict', optional):
457                Mapping: {particle_name: {"pka_value": 'float', "acidity": "acidic"|"basic"}}
458
459        Returns:
460            'list[float]':
461                Net molecular charge at each pH value.
462        """
463        if pH_list is None:
464            pH_list = np.linspace(2, 12, 50)
465        if pka_set is None:
466            pka_set = self.get_pka_set()
467        self._check_pka_set(pka_set=pka_set)
468        particle_counts = self.db.get_particle_templates_under(template_name=template_name,
469                                                               return_counts=True)
470        if not particle_counts:
471            return [None] * len(pH_list)
472        charge_number_map = self.get_charge_number_map()
473        def formal_charge(particle_name):
474            tpl = self.db.get_template(name=particle_name, 
475                                       pmb_type="particle")
476            state = self.db.get_template(name=tpl.initial_state,
477                                         pmb_type="particle_state")
478            return charge_number_map[state.es_type]
479        Z_HH = []
480        for pH in pH_list:
481            Z = 0.0
482            for particle, multiplicity in particle_counts.items():
483                if particle in pka_set:
484                    pka = pka_set[particle]["pka_value"]
485                    acidity = pka_set[particle]["acidity"]
486                    if acidity == "acidic":
487                        psi = -1
488                    elif acidity == "basic":
489                        psi = +1
490                    else:
491                        raise ValueError(f"Unknown acidity '{acidity}' for particle '{particle}'")
492                    charge = psi / (1.0 + 10.0 ** (psi * (pH - pka)))
493                    Z += multiplicity * charge
494                else:
495                    Z += multiplicity * formal_charge(particle)
496            Z_HH.append(Z)
497        return Z_HH   

Calculates the charge in the template object according to the ideal Henderson–Hasselbalch titration curve.

Arguments:
  • template_name ('str'): Name of the template.
  • pH_list ('list[float]', optional): pH values at which the charge is evaluated. Defaults to 50 values between 2 and 12.
  • pka_set ('dict', optional): Mapping: {particle_name: {"pka_value": 'float', "acidity": "acidic"|"basic"}}
Returns:

'list[float]': Net molecular charge at each pH value.

def calculate_HH_Donnan(self, c_macro, c_salt, pH_list=None, pka_set=None):
499    def calculate_HH_Donnan(self, c_macro, c_salt, pH_list=None, pka_set=None):
500        """
501        Computes macromolecular charges using the Henderson–Hasselbalch equation
502        coupled to ideal Donnan partitioning.
503
504        Args:
505            c_macro ('dict'):
506                Mapping of macromolecular species names to their concentrations
507                in the system:
508                '{molecule_name: concentration}'.
509                
510            c_salt ('float' or 'pint.Quantity'):
511                Salt concentration in the reservoir.
512
513            pH_list ('list[float]', optional):
514                List of pH values in the reservoir at which the calculation is
515                performed. If 'None', 50 equally spaced values between 2 and 12
516                are used.
517
518            pka_set ('dict', optional):
519                Dictionary defining the acid–base properties of titratable particle
520                types:
521                '{particle_name: {"pka_value": float, "acidity": "acidic" | "basic"}}'.
522                If 'None', the pKa set is taken from the pyMBE database.
523
524        Returns:
525            'dict':
526                Dictionary containing:
527                - '"charges_dict"' ('dict'):
528                    Mapping '{molecule_name: list}' of Henderson–Hasselbalch–Donnan
529                    charges evaluated at each pH value.
530                - '"pH_system_list"' ('list[float]'):
531                    Effective pH values inside the system phase after Donnan
532                    partitioning.
533                - '"partition_coefficients"' ('list[float]'):
534                    Partition coefficients of monovalent cations at each pH value.
535
536        Notes:
537            - This method assumes **ideal Donnan equilibrium** and **monovalent salt**.
538            - The ionic strength of the reservoir includes both salt and
539            pH-dependent H⁺/OH⁻ contributions.
540            - All charged macromolecular species present in the system must be
541            included in 'c_macro'; missing species will lead to incorrect results.
542            - The nonlinear Donnan equilibrium equation is solved using a scalar
543            root finder ('brentq') in logarithmic form for numerical stability.
544            - This method is intended for **two-phase systems**; for single-phase
545            systems use 'calculate_HH' instead.
546        """
547        if pH_list is None:
548            pH_list=np.linspace(2,12,50)
549        if pka_set is None:
550            pka_set=self.get_pka_set() 
551        self._check_pka_set(pka_set=pka_set)
552        partition_coefficients_list = []
553        pH_system_list = []
554        Z_HH_Donnan={}
555        for key in c_macro:
556            Z_HH_Donnan[key] = []
557        def calc_charges(c_macro, pH):
558            """
559            Calculates the charges of the different kinds of molecules according to the Henderson-Hasselbalch equation.
560
561            Args:
562                c_macro ('dict'): 
563                    {"name": concentration} - A dict containing the concentrations of all charged macromolecular species in the system. 
564
565                pH ('float'): 
566                    pH-value that is used in the HH equation.
567
568            Returns:
569                ('dict'): 
570                    {"molecule_name": charge}
571            """
572            charge = {}
573            for name in c_macro:
574                charge[name] = self.calculate_HH(name, [pH], pka_set)[0]
575            return charge
576
577        def calc_partition_coefficient(charge, c_macro):
578            """
579            Calculates the partition coefficients of positive ions according to the ideal Donnan theory.
580
581            Args:
582                charge ('dict'): 
583                    {"molecule_name": charge}
584
585                c_macro ('dict'): 
586                    {"name": concentration} - A dict containing the concentrations of all charged macromolecular species in the system. 
587            """
588            nonlocal ionic_strength_res
589            charge_density = 0.0
590            for key in charge:
591                charge_density += charge[key] * c_macro[key]
592            return (-charge_density / (2 * ionic_strength_res) + np.sqrt((charge_density / (2 * ionic_strength_res))**2 + 1)).magnitude
593        for pH_value in pH_list:    
594            # calculate the ionic strength of the reservoir
595            if pH_value <= 7.0:
596                ionic_strength_res = 10 ** (-pH_value) * self.units.mol/self.units.l + c_salt 
597            elif pH_value > 7.0:
598                ionic_strength_res = 10 ** (-(14-pH_value)) * self.units.mol/self.units.l + c_salt
599            #Determine the partition coefficient of positive ions by solving the system of nonlinear, coupled equations
600            #consisting of the partition coefficient given by the ideal Donnan theory and the Henderson-Hasselbalch equation.
601            #The nonlinear equation is formulated for log(xi) since log-operations are not supported for RootResult objects.
602            equation = lambda logxi: logxi - np.log10(calc_partition_coefficient(calc_charges(c_macro, pH_value - logxi), c_macro))
603            logxi = scipy.optimize.root_scalar(equation, bracket=[-1e2, 1e2], method="brentq")
604            partition_coefficient = 10**logxi.root
605            charges_temp = calc_charges(c_macro, pH_value-np.log10(partition_coefficient))
606            for key in c_macro:
607                Z_HH_Donnan[key].append(charges_temp[key])
608            pH_system_list.append(pH_value - np.log10(partition_coefficient))
609            partition_coefficients_list.append(partition_coefficient)
610        return {"charges_dict": Z_HH_Donnan, "pH_system_list": pH_system_list, "partition_coefficients": partition_coefficients_list}

Computes macromolecular charges using the Henderson–Hasselbalch equation coupled to ideal Donnan partitioning.

Arguments:
  • c_macro ('dict'): Mapping of macromolecular species names to their concentrations in the system: '{molecule_name: concentration}'.
  • c_salt ('float' or 'pint.Quantity'): Salt concentration in the reservoir.
  • pH_list ('list[float]', optional): List of pH values in the reservoir at which the calculation is performed. If 'None', 50 equally spaced values between 2 and 12 are used.
  • pka_set ('dict', optional): Dictionary defining the acid–base properties of titratable particle types: '{particle_name: {"pka_value": float, "acidity": "acidic" | "basic"}}'. If 'None', the pKa set is taken from the pyMBE database.
Returns:

'dict': Dictionary containing: - '"charges_dict"' ('dict'): Mapping '{molecule_name: list}' of Henderson–Hasselbalch–Donnan charges evaluated at each pH value. - '"pH_system_list"' ('list[float]'): Effective pH values inside the system phase after Donnan partitioning. - '"partition_coefficients"' ('list[float]'): Partition coefficients of monovalent cations at each pH value.

Notes:
  • This method assumes ideal Donnan equilibrium and monovalent salt.
  • The ionic strength of the reservoir includes both salt and pH-dependent H⁺/OH⁻ contributions.
  • All charged macromolecular species present in the system must be included in 'c_macro'; missing species will lead to incorrect results.
  • The nonlinear Donnan equilibrium equation is solved using a scalar root finder ('brentq') in logarithmic form for numerical stability.
  • This method is intended for two-phase systems; for single-phase systems use 'calculate_HH' instead.
def calculate_net_charge(self, object_name, pmb_type, dimensionless=False):
612    def calculate_net_charge(self,object_name,pmb_type,dimensionless=False):
613        """
614        Calculates the net charge per instance of a given pmb object type.
615
616        Args:
617            object_name (str):
618                Name of the object (e.g. molecule, residue, peptide, protein).
619            pmb_type (str):
620                Type of object to analyze. Must be molecule-like.
621            dimensionless (bool, optional):
622                If True, return charge as a pure number.
623                If False, return a quantity with reduced_charge units.
624
625        Returns:
626            dict:
627                {"mean": mean_net_charge, "instances": {instance_id: net_charge}}
628        """
629        return self.simulation_engine.calculate_net_charge(object_name,
630                                                           pmb_type,
631                                                           dimensionless)

Calculates the net charge per instance of a given pmb object type.

Arguments:
  • object_name (str): Name of the object (e.g. molecule, residue, peptide, protein).
  • pmb_type (str): Type of object to analyze. Must be molecule-like.
  • dimensionless (bool, optional): If True, return charge as a pure number. If False, return a quantity with reduced_charge units.
Returns:

dict: {"mean": mean_net_charge, "instances": {instance_id: net_charge}}

def center_object_in_simulation_box(self, instance_id, box_l, pmb_type):
633    def center_object_in_simulation_box(self, instance_id, box_l,pmb_type):
634        """
635        Centers a pyMBE object instance in the simulation box of an ESPResSo system.
636        The object is translated such that its center of mass coincides with the
637        geometric center of the ESPResSo simulation box.
638
639        Args:
640            instance_id ('int'):
641                ID of the pyMBE object instance to be centered.
642            
643            box_l('list[float,float,float]'): list of floats with the dimensions of the box
644
645            pmb_type ('str'):
646                Type of the pyMBE object.
647
648        Notes:
649            - Works for both cubic and non-cubic simulation boxes.
650        """
651        inst = self.db.get_instance(instance_id=instance_id,
652                                    pmb_type=pmb_type)
653        center_of_mass = self.calculate_center_of_mass(instance_id=instance_id,
654                                                       pmb_type=pmb_type)
655        box_center = [box_l[0]/2.0,
656                      box_l[1]/2.0,
657                      box_l[2]/2.0]
658        particle_id_list = self.get_particle_id_map(object_name=inst.name)["all"]
659        for pid in particle_id_list:
660            es_pos=self.db.get_instance(instance_id=pid,
661                                    pmb_type='particle').position
662            centered_position=es_pos - center_of_mass + box_center
663
664            self.db._update_instance(instance_id=pid,
665                                     pmb_type='particle',
666                                     attribute='position',
667                                     value=centered_position)
668            if isinstance(self.simulation_engine, EspressoSimulation):
669                self.simulation_engine._update_particle_position(
670                    particle_id=pid, position=centered_position)

Centers a pyMBE object instance in the simulation box of an ESPResSo system. The object is translated such that its center of mass coincides with the geometric center of the ESPResSo simulation box.

Arguments:
  • instance_id ('int'): ID of the pyMBE object instance to be centered.
  • box_l('list[float,float,float]'): list of floats with the dimensions of the box
  • pmb_type ('str'): Type of the pyMBE object.
Notes:
  • Works for both cubic and non-cubic simulation boxes.
def create_added_salt(self, box_l, cation_name, anion_name, c_salt):
672    def create_added_salt(self, box_l, cation_name, anion_name, c_salt):    
673        """
674        Creates a 'c_salt' concentration of 'cation_name' and 'anion_name' ions into the 'espresso_system'.
675
676        Args:
677            cation_name('str'): 'name' of a particle with a positive charge.
678            anion_name('str'): 'name' of a particle with a negative charge.
679            c_salt('float'): Salt concentration.
680            
681        Returns:
682            c_salt_calculated('float'): Calculated salt concentration added to 'espresso_system'.
683        """ 
684        cation_tpl = self.db.get_template(pmb_type="particle",
685                                          name=cation_name)
686        cation_state = self.db.get_template(pmb_type="particle_state",
687                                            name=cation_tpl.initial_state)
688        cation_charge = cation_state.z
689        anion_tpl = self.db.get_template(pmb_type="particle",
690                                          name=anion_name)
691        anion_state = self.db.get_template(pmb_type="particle_state",
692                                            name=anion_tpl.initial_state)
693        anion_charge = anion_state.z
694        if cation_charge <= 0:
695            raise ValueError(f'ERROR cation charge must be positive, charge {cation_charge}')
696        if anion_charge >= 0:
697            raise ValueError(f'ERROR anion charge must be negative, charge {anion_charge}')
698        # Calculate the number of ions in the simulation box
699        volume=self.units.Quantity(np.prod(box_l), 'reduced_length**3')
700        if c_salt.check('[substance] [length]**-3'):
701            N_ions= int((volume*c_salt.to('mol/reduced_length**3')*self.N_A).magnitude)
702            c_salt_calculated=N_ions/(volume*self.N_A)
703        elif c_salt.check('[length]**-3'):
704            N_ions= int((volume*c_salt.to('reduced_length**-3')).magnitude)
705            c_salt_calculated=N_ions/volume
706        else:
707            raise ValueError('Unknown units for c_salt, please provided it in [mol / volume] or [particle / volume]', c_salt)
708        N_cation = N_ions*abs(anion_charge)
709        N_anion = N_ions*abs(cation_charge)
710        self.create_particle(box_l=box_l, 
711                             name=cation_name, 
712                             number_of_particles=N_cation)
713        self.create_particle(box_l=box_l, 
714                             name=anion_name, 
715                             number_of_particles=N_anion)
716        if c_salt_calculated.check('[substance] [length]**-3'):
717            logging.info(f"added salt concentration of {c_salt_calculated.to('mol/L')} given by {N_cation} cations and {N_anion} anions")
718        elif c_salt_calculated.check('[length]**-3'):
719            logging.info(f"added salt concentration of {c_salt_calculated.to('reduced_length**-3')} given by {N_cation} cations and {N_anion} anions")
720        return c_salt_calculated

Creates a 'c_salt' concentration of 'cation_name' and 'anion_name' ions into the 'espresso_system'.

Arguments:
  • cation_name('str'): 'name' of a particle with a positive charge.
  • anion_name('str'): 'name' of a particle with a negative charge.
  • c_salt('float'): Salt concentration.
Returns:

c_salt_calculated('float'): Calculated salt concentration added to 'espresso_system'.

def create_bond(self, particle_id1, particle_id2, use_default_bond=False):
722    def create_bond(self, particle_id1, particle_id2, use_default_bond=False):
723        """
724        Creates a bond between two particle instances in an ESPResSo system and registers it in the pyMBE database.
725
726        This method performs the following steps:
727            1. Retrieves the particle instances corresponding to 'particle_id1' and 'particle_id2' from the database.
728            2. Retrieves or creates the corresponding ESPResSo bond instance using the bond template.
729            3. Adds the ESPResSo bond instance to the ESPResSo system if it was newly created.
730            4. Adds the bond to the first particle's bond list in ESPResSo.
731            5. Creates a 'BondInstance' in the database and registers it.
732
733        Args:
734            particle_id1 ('int'): 
735                pyMBE and ESPResSo ID of the first particle.
736
737            particle_id2 ('int'): 
738                pyMBE and ESPResSo ID of the second particle.
739
740            use_default_bond ('bool', optional): 
741                If True, use a default bond template if no specific template exists. Defaults to False.
742
743        Returns:
744            ('int'): 
745                bond_id of the bond instance created in the pyMBE database.
746        """
747        particle_inst_1 = self.db.get_instance(pmb_type="particle",
748                                               instance_id=particle_id1)
749        particle_inst_2 = self.db.get_instance(pmb_type="particle",
750                                               instance_id=particle_id2)
751        bond_tpl = self.get_bond_template(particle_name1=particle_inst_1.name,
752                                          particle_name2=particle_inst_2.name,
753                                          use_default_bond=use_default_bond)
754        bond_id = self.db._propose_instance_id(pmb_type="bond")
755        pmb_bond_instance = BondInstance(bond_id=bond_id,
756                                         name=bond_tpl.name,
757                                         particle_id1=particle_id1,
758                                         particle_id2=particle_id2)
759        self.db._register_instance(instance=pmb_bond_instance)

Creates a bond between two particle instances in an ESPResSo system and registers it in the pyMBE database.

This method performs the following steps:
  1. Retrieves the particle instances corresponding to 'particle_id1' and 'particle_id2' from the database.
  2. Retrieves or creates the corresponding ESPResSo bond instance using the bond template.
  3. Adds the ESPResSo bond instance to the ESPResSo system if it was newly created.
  4. Adds the bond to the first particle's bond list in ESPResSo.
  5. Creates a 'BondInstance' in the database and registers it.
Arguments:
  • particle_id1 ('int'): pyMBE and ESPResSo ID of the first particle.
  • particle_id2 ('int'): pyMBE and ESPResSo ID of the second particle.
  • use_default_bond ('bool', optional): If True, use a default bond template if no specific template exists. Defaults to False.
Returns:

('int'): bond_id of the bond instance created in the pyMBE database.

def create_counterions(self, object_name, cation_name, anion_name, box_l):
761    def create_counterions(self, object_name, cation_name, anion_name, box_l):
762        """
763        Creates particles of 'cation_name' and 'anion_name' in 'espresso_system' to counter the net charge of 'object_name'.
764        
765        Args:
766            object_name ('str'): 
767                'name' of a pyMBE object.
768
769            cation_name ('str'): 
770                'name' of a particle with a positive charge.
771
772            anion_name ('str'): 
773                'name' of a particle with a negative charge.
774            
775            box_l('list[float,float,float]'): list of floats with the dimensions of the box
776
777        Returns: 
778            ('dict'): 
779                {"name": number}
780
781        Notes:
782            This function currently does not support the creation of counterions for hydrogels.
783        """ 
784        cation_tpl = self.db.get_template(pmb_type="particle",
785                                          name=cation_name)
786        cation_state = self.db.get_template(pmb_type="particle_state",
787                                            name=cation_tpl.initial_state)
788        cation_charge = cation_state.z
789        anion_tpl = self.db.get_template(pmb_type="particle",
790                                          name=anion_name)
791        anion_state = self.db.get_template(pmb_type="particle_state",
792                                            name=anion_tpl.initial_state)
793        anion_charge = anion_state.z
794        object_ids = self.get_particle_id_map(object_name=object_name)["all"]
795        counterion_number={}
796        object_charge={}
797        for name in ['positive', 'negative']:
798            object_charge[name]=0
799        for id in object_ids:
800            object_name = self.db.get_instance(pmb_type="particle", 
801                                               instance_id=id).name
802            object_tpl = self.db.get_template(pmb_type="particle",
803                                              name=object_name)
804            object_state = self.db.get_template(pmb_type="particle_state",
805                                                name=object_tpl.initial_state)
806            object_z = object_state.z
807            if object_z > 0:
808                object_charge['positive']+=1*(np.abs(object_z ))
809            elif object_z < 0:
810                object_charge['negative']+=1*(np.abs(object_z ))
811        if object_charge['positive'] % abs(anion_charge) == 0:
812            counterion_number[anion_name]=int(object_charge['positive']/abs(anion_charge))
813        else:
814            raise ValueError('The number of positive charges in the pmb_object must be divisible by the  charge of the anion')
815        if object_charge['negative'] % abs(cation_charge) == 0:
816            counterion_number[cation_name]=int(object_charge['negative']/cation_charge)
817        else:
818            raise ValueError('The number of negative charges in the pmb_object must be divisible by the  charge of the cation')
819        if counterion_number[cation_name] > 0: 
820            self.create_particle(box_l=box_l, 
821                                 name=cation_name, 
822                                 number_of_particles=counterion_number[cation_name])
823        else:
824            counterion_number[cation_name]=0
825        if counterion_number[anion_name] > 0:
826            self.create_particle(box_l=box_l, 
827                                 name=anion_name, 
828                                 number_of_particles=counterion_number[anion_name])
829        else:
830            counterion_number[anion_name] = 0
831        logging.info('the following counter-ions have been created: ')
832        for name in counterion_number.keys():
833            logging.info(f'Ion type: {name} created number: {counterion_number[name]}')
834        return counterion_number

Creates particles of 'cation_name' and 'anion_name' in 'espresso_system' to counter the net charge of 'object_name'.

Arguments:
  • object_name ('str'): 'name' of a pyMBE object.
  • cation_name ('str'): 'name' of a particle with a positive charge.
  • anion_name ('str'): 'name' of a particle with a negative charge.
  • box_l('list[float,float,float]'): list of floats with the dimensions of the box

Returns: ('dict'): {"name": number}

Notes:

This function currently does not support the creation of counterions for hydrogels.

def create_hydrogel(self, name, box_l, use_default_bond=False, gen_angle=False):
837    def create_hydrogel(self, name, box_l, use_default_bond=False, gen_angle=False):
838        """ 
839        Creates a hydrogel in espresso_system using a pyMBE hydrogel template given by 'name'
840
841        Args:
842            box_l('list[float,float,float]'): list of floats with the dimensions of the box
843
844            name ('str'): 
845                name of the hydrogel template in the pyMBE database.
846
847            use_default_bond ('bool', optional): 
848                If True, use a default bond template if no specific template exists. Defaults to False.
849
850            gen_angle ('bool', optional):
851                If True, generate angle potentials for the internal hydrogel
852                chains and, when explicitly defined, for all crosslinker-adjacent
853                triplets. Defaults to False.
854
855        Returns:
856            ('int'): id of the hydrogel instance created.
857        """
858        if not self.db._has_template(name=name, pmb_type="hydrogel"):
859            raise ValueError(f"Hydrogel template with name '{name}' is not defined in the pyMBE database.")
860        hydrogel_tpl = self.db.get_template(pmb_type="hydrogel",
861                                            name=name)
862        assembly_id = self.db._propose_instance_id(pmb_type="hydrogel")
863        # Create the nodes
864        nodes = {}
865        hydrogel_angle_centers = set()
866        node_topology = hydrogel_tpl.node_map
867        for node in node_topology:
868            node_index = node.lattice_index
869            node_name = node.particle_name
870            node_pos, node_id = self._create_hydrogel_node(node_index=node_index,
871                                                          node_name=node_name,
872                                                          box_l=box_l)
873            node_label = self.lattice_builder._create_node_label(node_index=node_index)
874            nodes[node_label] = {"name": node_name, "id": node_id, "pos": node_pos} 
875            self.db._update_instance(instance_id=node_id,
876                                     pmb_type="particle",
877                                     attribute="assembly_id",
878                                     value=assembly_id)
879        for hydrogel_chain in hydrogel_tpl.chain_map:
880            molecule_id = self._create_hydrogel_chain(hydrogel_chain=hydrogel_chain,
881                                                      nodes=nodes,
882                                                      box_l=box_l,
883                                                      use_default_bond=use_default_bond,
884                                                      gen_angle=gen_angle,
885                                                      )
886            self.db._update_instance(instance_id=molecule_id,
887                                     pmb_type="molecule",
888                                     attribute="assembly_id",
889                                     value=assembly_id)
890            if gen_angle:
891                residue_ids = self.db._find_instance_ids_by_attribute(pmb_type="residue",
892                                                                      attribute="molecule_id",
893                                                                      value=molecule_id)
894                first_residue_id = min(residue_ids)
895                last_residue_id = max(residue_ids)
896                first_residue = self.db.get_instance(pmb_type="residue",
897                                                     instance_id=first_residue_id)
898                last_residue = self.db.get_instance(pmb_type="residue",
899                                                    instance_id=last_residue_id)
900                first_central_bead_name = self.db.get_template(pmb_type="residue",
901                                                               name=first_residue.name).central_bead
902                last_central_bead_name = self.db.get_template(pmb_type="residue",
903                                                              name=last_residue.name).central_bead
904                particle_instances = self.db.get_instances(pmb_type="particle")
905                first_residue_particle_ids = self.db._find_instance_ids_by_attribute(pmb_type="particle",
906                                                                                      attribute="residue_id",
907                                                                                      value=first_residue_id)
908                last_residue_particle_ids = self.db._find_instance_ids_by_attribute(pmb_type="particle",
909                                                                                     attribute="residue_id",
910                                                                                     value=last_residue_id)
911                first_bead_id = None
912                for particle_id in first_residue_particle_ids:
913                    if particle_instances[particle_id].name == first_central_bead_name:
914                        first_bead_id = particle_id
915                        break
916
917                last_bead_id = None
918                for particle_id in last_residue_particle_ids:
919                    if particle_instances[particle_id].name == last_central_bead_name:
920                        last_bead_id = particle_id
921                        break
922                node_start_label = self.lattice_builder._create_node_label(hydrogel_chain.node_start)
923                node_end_label = self.lattice_builder._create_node_label(hydrogel_chain.node_end)
924                hydrogel_angle_centers.update({
925                    nodes[node_start_label]["id"],
926                    nodes[node_end_label]["id"],
927                    first_bead_id,
928                    last_bead_id,
929                })
930        self.db._propagate_id(root_type="hydrogel", 
931                                root_id=assembly_id, 
932                                attribute="assembly_id", 
933                                value=assembly_id)
934        if gen_angle:
935            self._generate_hydrogel_crosslinker_angles(
936                                                       central_particle_ids=hydrogel_angle_centers)
937        # Register an hydrogel instance in the pyMBE databasegit 
938        self.db._register_instance(HydrogelInstance(name=name,
939                                                    assembly_id=assembly_id))
940        return assembly_id

Creates a hydrogel in espresso_system using a pyMBE hydrogel template given by 'name'

Arguments:
  • box_l('list[float,float,float]'): list of floats with the dimensions of the box
  • name ('str'): name of the hydrogel template in the pyMBE database.
  • use_default_bond ('bool', optional): If True, use a default bond template if no specific template exists. Defaults to False.
  • gen_angle ('bool', optional): If True, generate angle potentials for the internal hydrogel chains and, when explicitly defined, for all crosslinker-adjacent triplets. Defaults to False.
Returns:

('int'): id of the hydrogel instance created.

def create_molecule( self, name, number_of_molecules, box_l, list_of_first_residue_positions=None, backbone_vector=None, use_default_bond=False, reverse_residue_order=False, gen_angle=False):
 943    def create_molecule(self, name, number_of_molecules, box_l, list_of_first_residue_positions=None, backbone_vector=None, use_default_bond=False, reverse_residue_order = False, gen_angle=False):
 944        """
 945        Creates instances of a given molecule template name into ESPResSo.
 946
 947        Args:
 948            name ('str'): 
 949                Label of the molecule type to be created. 'name'.
 950
 951            box_l('list[float,float,float]'): list of floats with the dimensions of the box
 952
 953            number_of_molecules ('int'): 
 954                Number of molecules or peptides of type 'name' to be created.
 955
 956            list_of_first_residue_positions ('list', optional): 
 957                List of coordinates where the central bead of the first_residue_position will be created, random by default.
 958
 959            backbone_vector ('list' of 'float'): 
 960                Backbone vector of the molecule, random by default. Central beads of the residues in the 'residue_list' are placed along this vector. 
 961
 962            use_default_bond('bool', optional): 
 963                Controls if a bond of type 'default' is used to bond particles with undefined bonds in the pyMBE database.
 964
 965            reverse_residue_order('bool', optional): 
 966                Creates residues in reverse sequential order than the one defined in the molecule template. Defaults to False.
 967
 968        Returns:
 969            ('list' of 'int'): 
 970                List with the 'molecule_id' of the pyMBE molecule instances created into 'espresso_system'.
 971
 972        Notes:
 973            - This function can be used to create both molecules and peptides.    
 974        """
 975        pmb_type = self._get_template_type(name=name,
 976                                           allowed_types={"molecule", "peptide"})
 977        if number_of_molecules <= 0:
 978            return {}
 979        if list_of_first_residue_positions is not None:
 980            for item in list_of_first_residue_positions:
 981                if not isinstance(item, list):
 982                    raise ValueError("The provided input position is not a nested list. Should be a nested list with elements of 3D lists, corresponding to xyz coord.")
 983                elif len(item) != 3:
 984                    raise ValueError("The provided input position is formatted wrong. The elements in the provided list does not have 3 coordinates, corresponding to xyz coord.")
 985
 986            if len(list_of_first_residue_positions) != number_of_molecules:
 987                raise ValueError(f"Number of positions provided in {list_of_first_residue_positions} does not match number of molecules desired, {number_of_molecules}")
 988        # Generate an arbitrary random unit vector
 989        if backbone_vector is None:
 990            backbone_vector = self.generate_random_points_in_a_sphere(center=[0,0,0],
 991                                                                      radius=1, 
 992                                                                      n_samples=1,
 993                                                                      on_surface=True)[0]
 994        else:
 995            backbone_vector = np.array(backbone_vector)
 996        first_residue = True
 997        molecule_tpl = self.db.get_template(pmb_type=pmb_type,
 998                                            name=name)
 999        if reverse_residue_order:
1000            residue_list = molecule_tpl.residue_list[::-1]
1001        else:
1002            residue_list = molecule_tpl.residue_list
1003        pos_index = 0 
1004        molecule_ids = []
1005        for n_mol in range(number_of_molecules):        
1006            molecule_id = self.db._propose_instance_id(pmb_type=pmb_type)
1007            for residue in residue_list:
1008                if first_residue:
1009                    if list_of_first_residue_positions is None:
1010                        central_bead_pos = None
1011                    else:
1012                        central_bead_pos = [np.array(list_of_first_residue_positions[n_mol])]
1013                            
1014                    residue_id = self.create_residue(name=residue,
1015                                                     box_l=box_l, 
1016                                                     central_bead_position=central_bead_pos,  
1017                                                     use_default_bond= use_default_bond, 
1018                                                     backbone_vector=backbone_vector)
1019                    
1020                    # Add molecule_id to the residue instance and all particles associated
1021                    self.db._propagate_id(root_type="residue", 
1022                                          root_id=residue_id,
1023                                          attribute="molecule_id", 
1024                                          value=molecule_id)
1025                    particle_ids_in_residue = self.db._find_instance_ids_by_attribute(pmb_type="particle",
1026                                                                                      attribute="residue_id",
1027                                                                                      value=residue_id)
1028                    prev_central_bead_id = particle_ids_in_residue[0]
1029                    prev_central_bead_name = self.db.get_instance(pmb_type="particle", 
1030                                                                  instance_id=prev_central_bead_id).name
1031                    prev_central_bead_pos = self.db.get_instance(pmb_type="particle", 
1032                                                                  instance_id=prev_central_bead_id).position
1033                    # prev_central_bead_pos = espresso_system.part.by_id(prev_central_bead_id).pos
1034                    first_residue = False          
1035                else:
1036                    
1037                    # Calculate the starting position of the new residue
1038                    residue_tpl = self.db.get_template(pmb_type="residue",
1039                                                       name=residue)
1040                    lj_parameters = self.get_lj_parameters(particle_name1=prev_central_bead_name,
1041                                                           particle_name2=residue_tpl.central_bead)
1042                    bond_tpl = self.get_bond_template(particle_name1=prev_central_bead_name,
1043                                                      particle_name2=residue_tpl.central_bead,
1044                                                      use_default_bond=use_default_bond)
1045                    l0 = hf.calculate_initial_bond_length(lj_parameters=lj_parameters,
1046                                                          bond_type=bond_tpl.bond_type,
1047                                                          bond_parameters=bond_tpl.get_parameters(ureg=self.units))
1048                    central_bead_pos = prev_central_bead_pos+backbone_vector*l0
1049                    # Create the residue
1050                    residue_id = self.create_residue(name=residue, 
1051                                                     box_l=box_l, 
1052                                                     central_bead_position=[central_bead_pos],
1053                                                     use_default_bond= use_default_bond, 
1054                                                     backbone_vector=backbone_vector)
1055                    # Add molecule_id to the residue instance and all particles associated
1056                    self.db._propagate_id(root_type="residue", 
1057                                          root_id=residue_id, 
1058                                          attribute="molecule_id", 
1059                                          value=molecule_id)
1060                    particle_ids_in_residue = self.db._find_instance_ids_by_attribute(pmb_type="particle",
1061                                                                                      attribute="residue_id",
1062                                                                                      value=residue_id)
1063                    central_bead_id = particle_ids_in_residue[0]
1064
1065                    # Bond the central beads of the new and previous residues
1066                    self.create_bond(particle_id1=prev_central_bead_id,
1067                                     particle_id2=central_bead_id,
1068                                     use_default_bond=use_default_bond)
1069                    
1070                    prev_central_bead_id = central_bead_id                    
1071                    prev_central_bead_name = self.db.get_instance(pmb_type="particle", instance_id=central_bead_id).name
1072                    prev_central_bead_pos =central_bead_pos
1073            # Create a Peptide or Molecule instance and register it on the pyMBE database
1074            if pmb_type == "molecule":
1075                inst = MoleculeInstance(molecule_id=molecule_id,
1076                                        name=name)
1077            elif pmb_type == "peptide":
1078                inst = PeptideInstance(name=name,
1079                                       molecule_id=molecule_id)
1080            self.db._register_instance(inst)
1081            if gen_angle:
1082                self._generate_angles_for_entity(
1083                    entity_id=molecule_id,
1084                    entity_id_col='molecule_id')
1085            first_residue = True
1086            pos_index+=1
1087            molecule_ids.append(molecule_id)
1088        return molecule_ids

Creates instances of a given molecule template name into ESPResSo.

Arguments:
  • name ('str'): Label of the molecule type to be created. 'name'.
  • box_l('list[float,float,float]'): list of floats with the dimensions of the box
  • number_of_molecules ('int'): Number of molecules or peptides of type 'name' to be created.
  • list_of_first_residue_positions ('list', optional): List of coordinates where the central bead of the first_residue_position will be created, random by default.
  • backbone_vector ('list' of 'float'): Backbone vector of the molecule, random by default. Central beads of the residues in the 'residue_list' are placed along this vector.
  • use_default_bond('bool', optional): Controls if a bond of type 'default' is used to bond particles with undefined bonds in the pyMBE database.
  • reverse_residue_order('bool', optional): Creates residues in reverse sequential order than the one defined in the molecule template. Defaults to False.
Returns:

('list' of 'int'): List with the 'molecule_id' of the pyMBE molecule instances created into 'espresso_system'.

Notes:
  • This function can be used to create both molecules and peptides.
def create_particle(self, name, box_l, number_of_particles, position=None, fix=False):
1090    def create_particle(self, name, box_l, number_of_particles, position=None, fix=False):
1091        """
1092        Creates one or more particles in an ESPResSo system based on the particle template in the pyMBE database.
1093        
1094        Args:
1095            name ('str'): 
1096                Label of the particle template in the pyMBE database. 
1097            
1098            box_l('list[float,float,float]'): list of floats with the dimensions of the box
1099
1100            number_of_particles ('int'): 
1101                Number of particles to be created.
1102
1103            position (list of ['float','float','float'], optional): 
1104                Initial positions of the particles. If not given, particles are created in random positions. Defaults to None.
1105
1106            fix ('bool', optional): 
1107                Controls if the particle motion is frozen in the integrator, it is used to create rigid objects. Defaults to False.
1108
1109        Returns:
1110            ('list' of 'int'): 
1111                List with the ids of the particles created into 'espresso_system'.
1112        """       
1113        if number_of_particles <=0:
1114            return []
1115        if not self.db._has_template(name=name, pmb_type="particle"):
1116            raise ValueError(f"Particle template with name '{name}' is not defined in the pyMBE database.")
1117        
1118        part_tpl = self.db.get_template(pmb_type="particle",
1119                                        name=name)
1120        part_state = self.db.get_template(pmb_type="particle_state",
1121                                         name=part_tpl.initial_state)
1122        name_state=part_state.name
1123
1124        if fix is False:
1125            fix=[fix]*3
1126
1127        created_pid_list=[]
1128        for index in range(number_of_particles):
1129            if position is None:
1130                particle_position = self.rng.random((1, 3))[0] *np.copy(box_l)
1131            else:
1132                particle_position = np.array(position[index])
1133            
1134            particle_id = self.db._propose_instance_id(pmb_type="particle")
1135            created_pid_list.append(particle_id)
1136            part_inst = ParticleInstance(name=name,
1137                                         particle_id=particle_id,
1138                                         initial_state=name_state,
1139                                         position=particle_position,
1140                                         fix=fix)
1141            self.db._register_instance(part_inst)
1142                              
1143        return created_pid_list

Creates one or more particles in an ESPResSo system based on the particle template in the pyMBE database.

Arguments:
  • name ('str'): Label of the particle template in the pyMBE database.
  • box_l('list[float,float,float]'): list of floats with the dimensions of the box
  • number_of_particles ('int'): Number of particles to be created.
  • position (list of ['float','float','float'], optional): Initial positions of the particles. If not given, particles are created in random positions. Defaults to None.
  • fix ('bool', optional): Controls if the particle motion is frozen in the integrator, it is used to create rigid objects. Defaults to False.
Returns:

('list' of 'int'): List with the ids of the particles created into 'espresso_system'.

def create_protein(self, name, number_of_proteins, box_l, topology_dict):
1145    def create_protein(self, name, number_of_proteins, box_l, topology_dict):
1146        """
1147        Creates one or more protein molecules in an ESPResSo system based on the 
1148        protein template in the pyMBE database and a provided topology.
1149
1150        Args:
1151            name (str):
1152                Name of the protein template stored in the pyMBE database.
1153            
1154            number_of_proteins (int):
1155                Number of protein molecules to generate.
1156
1157            box_l('list[float,float,float]'): list of floats with the dimensions of the box  
1158            
1159            topology_dict (dict):
1160                Dictionary defining the internal structure of the protein. Expected format:
1161                    {"ResidueName1": {"initial_pos": np.ndarray,
1162                                      "chain_id": int,
1163                                      "radius": float},
1164                     "ResidueName2": { ... },
1165                        ...
1166                    }
1167                The '"initial_pos"' entry is required and represents the residue’s
1168                reference coordinates before shifting to the protein's center-of-mass.
1169
1170        Returns:
1171            ('list' of 'int'): 
1172                List of the molecule_id of the Protein instances created into ESPResSo.
1173
1174        Notes:
1175            - Particles are created using 'create_particle()' with 'fix=True',
1176            meaning they are initially immobilized.
1177            - The function assumes all residues in 'topology_dict' correspond to
1178            particle templates already defined in the pyMBE database.
1179            - Bonds between residues are not created here; it assumes a rigid body representation of the protein.
1180        """
1181        if number_of_proteins <= 0:
1182            return
1183        if not self.db._has_template(name=name, pmb_type="protein"):
1184            raise ValueError(f"Protein template with name '{name}' is not defined in the pyMBE database.")
1185        protein_tpl = self.db.get_template(pmb_type="protein", name=name)
1186        box_half = box_l[0] / 2.0
1187        # Create protein
1188        mol_ids = []
1189        for _ in range(number_of_proteins):
1190            # create a molecule identifier in pyMBE
1191            molecule_id = self.db._propose_instance_id(pmb_type="protein")
1192            # place protein COM randomly
1193            protein_center = self.generate_coordinates_outside_sphere(radius=1,
1194                                                                      max_dist=box_half,
1195                                                                      n_samples=1,
1196                                                                      center=[box_half]*3)[0]
1197            residues = hf.get_residues_from_topology_dict(topology_dict=topology_dict,
1198                                                         model=protein_tpl.model)
1199            # CREATE RESIDUES + PARTICLES
1200            for _, rdata in residues.items():
1201                base_resname = rdata["resname"]  
1202                residue_name = f"AA-{base_resname}"
1203                # residue instance ID
1204                residue_id = self.db._propose_instance_id("residue")
1205                # register ResidueInstance
1206                self.db._register_instance(ResidueInstance(name=residue_name,
1207                                                           residue_id=residue_id,
1208                                                           molecule_id=molecule_id))
1209                # PARTICLE CREATION
1210                for bead_id in rdata["beads"]:
1211                    bead_type = re.split(r'\d+', bead_id)[0]
1212                    relative_pos = topology_dict[bead_id]["initial_pos"]
1213                    absolute_pos = relative_pos + protein_center
1214                    particle_id = self.create_particle(name=bead_type,
1215                                                       box_l=box_l,
1216                                                       number_of_particles=1,
1217                                                       position=[absolute_pos],
1218                                                       fix=[True,True,True])[0]
1219                    # update metadata
1220                    self.db._update_instance(instance_id=particle_id,
1221                                             pmb_type="particle",
1222                                             attribute="molecule_id",
1223                                             value=molecule_id)
1224                    self.db._update_instance(instance_id=particle_id,
1225                                             pmb_type="particle",
1226                                             attribute="residue_id",
1227                                             value=residue_id)
1228            protein_inst = ProteinInstance(name=name,
1229                                           molecule_id=molecule_id)
1230            self.db._register_instance(protein_inst)
1231            mol_ids.append(molecule_id)
1232        return mol_ids

Creates one or more protein molecules in an ESPResSo system based on the protein template in the pyMBE database and a provided topology.

Arguments:
  • name (str): Name of the protein template stored in the pyMBE database.
  • number_of_proteins (int): Number of protein molecules to generate.
  • box_l('list[float,float,float]'): list of floats with the dimensions of the box
  • topology_dict (dict): Dictionary defining the internal structure of the protein. Expected format: {"ResidueName1": {"initial_pos": np.ndarray, "chain_id": int, "radius": float}, "ResidueName2": { ... }, ... } The '"initial_pos"' entry is required and represents the residue’s reference coordinates before shifting to the protein's center-of-mass.
Returns:

('list' of 'int'): List of the molecule_id of the Protein instances created into ESPResSo.

Notes:
  • Particles are created using 'create_particle()' with 'fix=True', meaning they are initially immobilized.
  • The function assumes all residues in 'topology_dict' correspond to particle templates already defined in the pyMBE database.
  • Bonds between residues are not created here; it assumes a rigid body representation of the protein.
def create_residue( self, name, box_l, central_bead_position=None, use_default_bond=False, backbone_vector=None, gen_angle=False):
1234    def create_residue(self, name, box_l, central_bead_position=None,use_default_bond=False, backbone_vector=None, gen_angle=False):
1235        """
1236        Creates a residue  into ESPResSo.
1237
1238        Args:
1239            name ('str'): 
1240                Label of the residue type to be created. 
1241
1242            central_bead_position ('list' of 'float'): 
1243                Position of the central bead.
1244            
1245            box_l('list[float,float,float]'): list of floats with the dimensions of the box
1246
1247            use_default_bond ('bool'): 
1248                Switch to control if a bond of type 'default' is used to bond a particle whose bonds types are not defined in the pyMBE database.
1249
1250            backbone_vector ('list' of 'float'): 
1251                Backbone vector of the molecule. All side chains are created perpendicularly to 'backbone_vector'.
1252
1253        Returns:
1254            (int): 
1255                residue_id of the residue created.
1256        """
1257        if not self.db._has_template(name=name, pmb_type="residue"):
1258            raise ValueError(f"Residue template with name '{name}' is not defined in the pyMBE database.")
1259        res_tpl = self.db.get_template(pmb_type="residue",
1260                                       name=name)
1261        # Assign a residue_id
1262        residue_id = self.db._propose_instance_id(pmb_type="residue")
1263        res_inst = ResidueInstance(name=name,
1264                                   residue_id=residue_id)
1265        self.db._register_instance(res_inst)
1266        # create the principal bead   
1267        central_bead_name = res_tpl.central_bead 
1268        central_bead_id = self.create_particle(name=central_bead_name,
1269                                               box_l=box_l,
1270                                               position=central_bead_position,
1271                                               number_of_particles = 1)[0]
1272        
1273        central_bead_position = self.db.get_instance(pmb_type="particle", 
1274                                                                  instance_id=central_bead_id).position
1275        # # central_bead_position=espresso_system.part.by_id(central_bead_id).pos
1276
1277        # Assigns residue_id to the central_bead particle created.
1278        self.db._update_instance(pmb_type="particle",
1279                                 instance_id=central_bead_id,
1280                                 attribute="residue_id",
1281                                 value=residue_id)
1282        
1283        # create the lateral beads  
1284        side_chain_list = res_tpl.side_chains
1285        side_chain_beads_ids = []
1286        for side_chain_name in side_chain_list:
1287            pmb_type = self._get_template_type(name=side_chain_name,
1288                                               allowed_types={"particle", "residue"})
1289            if pmb_type == 'particle':
1290                lj_parameters = self.get_lj_parameters(particle_name1=central_bead_name,
1291                                                       particle_name2=side_chain_name)
1292                bond_tpl = self.get_bond_template(particle_name1=central_bead_name,
1293                                                  particle_name2=side_chain_name,
1294                                                  use_default_bond=use_default_bond)
1295                l0 = hf.calculate_initial_bond_length(lj_parameters=lj_parameters,
1296                                                      bond_type=bond_tpl.bond_type,
1297                                                      bond_parameters=bond_tpl.get_parameters(ureg=self.units))               
1298                if backbone_vector is None:
1299                    bead_position=self.generate_random_points_in_a_sphere(center=central_bead_position, 
1300                                                                radius=l0, 
1301                                                                n_samples=1,
1302                                                                on_surface=True)[0]
1303                else:
1304                    bead_position=central_bead_position+self.generate_trial_perpendicular_vector(vector=np.array(backbone_vector),
1305                                                                                                magnitude=l0)
1306                    
1307                side_bead_id = self.create_particle(name=side_chain_name, 
1308                                                    box_l=box_l,
1309                                                    position=[bead_position], 
1310                                                    number_of_particles=1)[0]
1311                side_chain_beads_ids.append(side_bead_id)
1312                self.db._update_instance(pmb_type="particle",
1313                                         instance_id=side_bead_id,
1314                                         attribute="residue_id",
1315                                         value=residue_id)
1316                self.create_bond(particle_id1=central_bead_id,
1317                                 particle_id2=side_bead_id,
1318                                 use_default_bond=use_default_bond)
1319                
1320            elif pmb_type == 'residue':
1321
1322                side_residue_tpl = self.db.get_template(name=side_chain_name,
1323                                                        pmb_type=pmb_type)
1324                central_bead_side_chain = side_residue_tpl.central_bead
1325                lj_parameters = self.get_lj_parameters(particle_name1=central_bead_name,
1326                                                       particle_name2=central_bead_side_chain)
1327                bond_tpl = self.get_bond_template(particle_name1=central_bead_name,
1328                                                  particle_name2=central_bead_side_chain,
1329                                                  use_default_bond=use_default_bond)
1330                l0 = hf.calculate_initial_bond_length(lj_parameters=lj_parameters,
1331                                                      bond_type=bond_tpl.bond_type,
1332                                                      bond_parameters=bond_tpl.get_parameters(ureg=self.units))
1333                if backbone_vector is None:
1334                    residue_position=self.generate_random_points_in_a_sphere(center=central_bead_position, 
1335                                                                radius=l0, 
1336                                                                n_samples=1,
1337                                                                on_surface=True)[0]
1338                else:
1339                    residue_position=central_bead_position+self.generate_trial_perpendicular_vector(vector=backbone_vector,
1340                                                                                                    magnitude=l0)
1341                side_residue_id = self.create_residue(name=side_chain_name, 
1342                                                      box_l=box_l,
1343                                                      central_bead_position=[residue_position],
1344                                                      use_default_bond=use_default_bond)
1345                # Find particle ids of the inner residue
1346                side_chain_beads_ids = self.db._find_instance_ids_by_attribute(pmb_type="particle",
1347                                                                               attribute="residue_id",
1348                                                                               value=side_residue_id)
1349                # Change the residue_id of the residue in the side chain to the one of the outer residue
1350                for particle_id in side_chain_beads_ids:
1351                    self.db._update_instance(instance_id=particle_id,
1352                                             pmb_type="particle",
1353                                             attribute="residue_id",
1354                                             value=residue_id)
1355                # Remove the instance of the inner residue
1356                self.db.delete_instance(pmb_type="residue",
1357                                        instance_id=side_residue_id)
1358                self.create_bond(particle_id1=central_bead_id,
1359                                 particle_id2=side_chain_beads_ids[0],
1360                                 use_default_bond=use_default_bond)
1361        if gen_angle:
1362            self._generate_angles_for_entity(
1363                                             entity_id=residue_id,
1364                                             entity_id_col="residue_id")
1365        return  residue_id

Creates a residue into ESPResSo.

Arguments:
  • name ('str'): Label of the residue type to be created.
  • central_bead_position ('list' of 'float'): Position of the central bead.
  • box_l('list[float,float,float]'): list of floats with the dimensions of the box
  • use_default_bond ('bool'): Switch to control if a bond of type 'default' is used to bond a particle whose bonds types are not defined in the pyMBE database.
  • backbone_vector ('list' of 'float'): Backbone vector of the molecule. All side chains are created perpendicularly to 'backbone_vector'.
Returns:

(int): residue_id of the residue created.

def define_bond(self, bond_type, bond_parameters, particle_pairs):
1367    def define_bond(self, bond_type, bond_parameters, particle_pairs):
1368        """
1369        Defines bond templates for each particle pair in 'particle_pairs' in the pyMBE database.
1370
1371        Args:
1372            bond_type ('str'): 
1373                label to identify the potential to model the bond.
1374
1375            bond_parameters ('dict'): 
1376                parameters of the potential of the bond.
1377
1378            particle_pairs ('lst'): 
1379                list of the 'names' of the 'particles' to be bonded.
1380
1381        Notes:
1382            -Currently, only HARMONIC and FENE bonds are supported.
1383            - For a HARMONIC bond the dictionary must contain the following parameters:
1384                - k ('pint.Quantity')      : Magnitude of the bond. It should have units of energy/length**2 
1385                using the 'pmb.units' UnitRegistry.
1386                - r_0 ('pint.Quantity')    : Equilibrium bond length. It should have units of length using 
1387                the 'pmb.units' UnitRegistry.
1388           - For a FENE bond the dictionary must contain the same parameters as for a HARMONIC bond and:              
1389                - d_r_max ('pint.Quantity'): Maximal stretching length for FENE. It should have 
1390                units of length using the 'pmb.units' UnitRegistry. Default 'None'.
1391        """
1392        self._check_bond_inputs(bond_parameters=bond_parameters,
1393                                bond_type=bond_type)
1394        parameters_expected_dimensions={"r_0": "length",
1395                                        "k": "energy/length**2",
1396                                        "d_r_max": "length"}
1397
1398        parameters_tpl = {}
1399        for key in bond_parameters.keys():
1400            parameters_tpl[key]= PintQuantity.from_quantity(q=bond_parameters[key],
1401                                                            expected_dimension=parameters_expected_dimensions[key],
1402                                                            ureg=self.units)
1403
1404        bond_names=[]
1405        for particle_name1, particle_name2 in particle_pairs:
1406            
1407            tpl = BondTemplate(particle_name1=particle_name1,
1408                               particle_name2=particle_name2,
1409                               parameters=parameters_tpl,
1410                               bond_type=bond_type)
1411            tpl._make_name()
1412            if tpl.name in bond_names:
1413                raise RuntimeError(f"Bond {tpl.name} has already been defined, please check the list of particle pairs")
1414            bond_names.append(tpl.name)
1415            self.db._register_template(tpl)

Defines bond templates for each particle pair in 'particle_pairs' in the pyMBE database.

Arguments:
  • bond_type ('str'): label to identify the potential to model the bond.
  • bond_parameters ('dict'): parameters of the potential of the bond.
  • particle_pairs ('lst'): list of the 'names' of the 'particles' to be bonded.
Notes:

-Currently, only HARMONIC and FENE bonds are supported.

  • For a HARMONIC bond the dictionary must contain the following parameters:
    • k ('pint.Quantity') : Magnitude of the bond. It should have units of energy/length**2 using the 'pmb.units' UnitRegistry.
    • r_0 ('pint.Quantity') : Equilibrium bond length. It should have units of length using the 'pmb.units' UnitRegistry.
    • For a FENE bond the dictionary must contain the same parameters as for a HARMONIC bond and:
    • d_r_max ('pint.Quantity'): Maximal stretching length for FENE. It should have units of length using the 'pmb.units' UnitRegistry. Default 'None'.
def define_default_bond(self, bond_type, bond_parameters):
1418    def define_default_bond(self, bond_type, bond_parameters):
1419        """
1420        Defines a bond template as a "default" template in the pyMBE database.
1421        
1422        Args:
1423            bond_type ('str'): 
1424                label to identify the potential to model the bond.
1425
1426            bond_parameters ('dict'): 
1427                parameters of the potential of the bond.
1428            
1429        Notes:
1430            - Currently, only harmonic and FENE bonds are supported. 
1431        """
1432        self._check_bond_inputs(bond_parameters=bond_parameters,
1433                                bond_type=bond_type)
1434        parameters_expected_dimensions={"r_0": "length",
1435                                        "k": "energy/length**2",
1436                                        "d_r_max": "length"}
1437        parameters_tpl = {}
1438        for key in bond_parameters.keys():
1439            parameters_tpl[key]= PintQuantity.from_quantity(q=bond_parameters[key],
1440                                                            expected_dimension=parameters_expected_dimensions[key],
1441                                                            ureg=self.units)
1442        tpl = BondTemplate(parameters=parameters_tpl,
1443                               bond_type=bond_type)
1444        tpl.name = "default"
1445        self.db._register_template(tpl)

Defines a bond template as a "default" template in the pyMBE database.

Arguments:
  • bond_type ('str'): label to identify the potential to model the bond.
  • bond_parameters ('dict'): parameters of the potential of the bond.
Notes:
  • Currently, only harmonic and FENE bonds are supported.
def define_angular_potential(self, angle_type, angle_parameters, particle_triplets):
1447    def define_angular_potential(self, angle_type, angle_parameters, particle_triplets):
1448        """
1449        Defines angle potential templates for each particle triplet in `particle_triplets`.
1450
1451        Args:
1452            angle_type ('str'):
1453                Type of angle potential. Supported: "harmonic", "cosine", "harmonic_cosine".
1454
1455            angle_parameters ('dict'):
1456                Parameters of the angle potential. Must contain:
1457                    - "k" ('pint.Quantity'): Bending stiffness with dimensions of energy.
1458                    - "phi_0" ('float'): Equilibrium angle in radians.
1459
1460            particle_triplets ('list[tuple[str,str,str]]'):
1461                List of (side_particle1, central_particle, side_particle2) triplets.
1462        """
1463        valid_angle_types = ["harmonic", "cosine", "harmonic_cosine"]
1464        if angle_type not in valid_angle_types:
1465            raise NotImplementedError(f"Angle potential type '{angle_type}' currently not implemented in pyMBE, accepted types are {valid_angle_types}")
1466
1467        if "k" not in angle_parameters:
1468            raise ValueError("Magnitude of the angle potential (k) is missing")
1469        if "phi_0" not in angle_parameters:
1470            raise ValueError("Equilibrium angle (phi_0) is missing")
1471
1472        parameters_tpl = {"k": PintQuantity.from_quantity(q=angle_parameters["k"],
1473                                                          expected_dimension="energy",
1474                                                          ureg=self.units),
1475                          "phi_0": PintQuantity.from_quantity(q=angle_parameters["phi_0"],
1476                                                              expected_dimension="dimensionless",
1477                                                              ureg=self.units),}
1478        angle_names = []
1479        for side1, central, side2 in particle_triplets:
1480            tpl = AngleTemplate(side_particle1=side1,
1481                                central_particle=central,
1482                                side_particle2=side2,
1483                                parameters=parameters_tpl,
1484                                angle_type=angle_type)
1485            tpl._make_name()
1486            if tpl.name in angle_names:
1487                raise RuntimeError(f"Angle {tpl.name} has already been defined, please check the list of particle triplets")
1488            angle_names.append(tpl.name)
1489            self.db._register_template(tpl)

Defines angle potential templates for each particle triplet in particle_triplets.

Arguments:
  • angle_type ('str'): Type of angle potential. Supported: "harmonic", "cosine", "harmonic_cosine".
  • angle_parameters ('dict'): Parameters of the angle potential. Must contain:
    • "k" ('pint.Quantity'): Bending stiffness with dimensions of energy.
    • "phi_0" ('float'): Equilibrium angle in radians.
  • particle_triplets ('list[tuple[str,str,str]]'): List of (side_particle1, central_particle, side_particle2) triplets.
def define_default_angular_potential(self, angle_type, angle_parameters):
1491    def define_default_angular_potential(self, angle_type, angle_parameters):
1492        """
1493        Defines an angle template as a "default" template in the pyMBE database.
1494
1495        Args:
1496            angle_type ('str'):
1497                Type of angle potential. Supported: "harmonic", "cosine", "harmonic_cosine".
1498
1499            angle_parameters ('dict'):
1500                Parameters of the angle potential (k, phi_0).
1501        """
1502        valid_angle_types = ["harmonic", "cosine", "harmonic_cosine"]
1503        if angle_type not in valid_angle_types:
1504            raise NotImplementedError(f"Angle potential type '{angle_type}' currently not implemented in pyMBE, accepted types are {valid_angle_types}")
1505        if "k" not in angle_parameters:
1506            raise ValueError("Magnitude of the angle potential (k) is missing")
1507        if "phi_0" not in angle_parameters:
1508            raise ValueError("Equilibrium angle (phi_0) is missing")
1509        parameters_tpl = {"k": PintQuantity.from_quantity(q=angle_parameters["k"],
1510                                                          expected_dimension="energy",
1511                                                          ureg=self.units),
1512                         "phi_0": PintQuantity.from_quantity(q=angle_parameters["phi_0"],
1513                                                             expected_dimension="dimensionless",
1514                                                             ureg=self.units),}
1515        tpl = AngleTemplate(parameters=parameters_tpl,
1516                            angle_type=angle_type)
1517        tpl.name = "default"
1518        self.db._register_template(tpl)

Defines an angle template as a "default" template in the pyMBE database.

Arguments:
  • angle_type ('str'): Type of angle potential. Supported: "harmonic", "cosine", "harmonic_cosine".
  • angle_parameters ('dict'): Parameters of the angle potential (k, phi_0).
def create_angular_potential( self, particle_id1, particle_id2, particle_id3, use_default_angle=False):
1520    def create_angular_potential(self, particle_id1, particle_id2, particle_id3, use_default_angle=False):
1521        """
1522        Creates an angle between three particle instances in an ESPResSo system
1523        and registers it in the pyMBE database.
1524
1525        Args:
1526            particle_id1 ('int'): ID of the first side particle.
1527            particle_id2 ('int'): ID of the central particle.
1528            particle_id3 ('int'): ID of the second side particle.
1529            use_default_angle ('bool', optional): If True, use the default angle if no specific one is found.
1530        """
1531        particle_inst_1 = self.db.get_instance(pmb_type="particle", instance_id=particle_id1)
1532        particle_inst_2 = self.db.get_instance(pmb_type="particle", instance_id=particle_id2)
1533        particle_inst_3 = self.db.get_instance(pmb_type="particle", instance_id=particle_id3)
1534
1535        # Verify that bonds exist between side particles and central particle
1536        bond_instances = self.db.get_instances(pmb_type="bond")
1537        bonded_pairs = set()
1538        for bond in bond_instances.values():
1539            pair = frozenset([bond.particle_id1, bond.particle_id2])
1540            bonded_pairs.add(pair)
1541        if frozenset([particle_id1, particle_id2]) not in bonded_pairs:
1542            raise ValueError(f"Cannot create angle: no bond exists between particle {particle_id1} and central particle {particle_id2}.")
1543        if frozenset([particle_id3, particle_id2]) not in bonded_pairs:
1544            raise ValueError(f"Cannot create angle: no bond exists between particle {particle_id3} and central particle {particle_id2}.")
1545
1546        angle_tpl = self.get_angle_template(side_name1=particle_inst_1.name,
1547                                            central_name=particle_inst_2.name,
1548                                            side_name2=particle_inst_3.name,
1549                                            use_default_angle=use_default_angle)
1550        angle_id = self.db._propose_instance_id(pmb_type="angle")
1551        pmb_angle_instance = AngleInstance(angle_id=angle_id,
1552                                           name=angle_tpl.name,
1553                                           particle_id1=particle_id1,
1554                                           particle_id2=particle_id2,
1555                                           particle_id3=particle_id3)
1556        self.db._register_instance(instance=pmb_angle_instance)

Creates an angle between three particle instances in an ESPResSo system and registers it in the pyMBE database.

Arguments:
  • particle_id1 ('int'): ID of the first side particle.
  • particle_id2 ('int'): ID of the central particle.
  • particle_id3 ('int'): ID of the second side particle.
  • use_default_angle ('bool', optional): If True, use the default angle if no specific one is found.
def get_angle_template(self, side_name1, central_name, side_name2, use_default_angle=False):
1558    def get_angle_template(self, side_name1, central_name, side_name2, use_default_angle=False):
1559        """
1560        Retrieves an angle template connecting three particle templates.
1561
1562        Args:
1563            side_name1 ('str'): Name of the first side particle.
1564            central_name ('str'): Name of the central particle.
1565            side_name2 ('str'): Name of the second side particle.
1566            use_default_angle ('bool', optional): If True, fall back to the default angle template.
1567
1568        Returns:
1569            ('AngleTemplate'): The matching angle template.
1570        """
1571        angle_key = AngleTemplate.make_angle_key(side1=side_name1, central=central_name, side2=side_name2)
1572        try:
1573            return self.db.get_template(name=angle_key, pmb_type="angle")
1574        except ValueError:
1575            pass
1576
1577        if use_default_angle:
1578            return self.db.get_template(name="default", pmb_type="angle")
1579
1580        raise ValueError(f"No angle template found for '{side_name1}-{central_name}-{side_name2}', and default angles are deactivated.")

Retrieves an angle template connecting three particle templates.

Arguments:
  • side_name1 ('str'): Name of the first side particle.
  • central_name ('str'): Name of the central particle.
  • side_name2 ('str'): Name of the second side particle.
  • use_default_angle ('bool', optional): If True, fall back to the default angle template.
Returns:

('AngleTemplate'): The matching angle template.

def define_hydrogel(self, name, node_map, chain_map):
1629    def define_hydrogel(self, name, node_map, chain_map):
1630        """
1631        Defines a hydrogel template in the pyMBE database.
1632
1633        Args:
1634            name ('str'): 
1635                Unique label that identifies the 'hydrogel'.
1636
1637            node_map ('list of dict'): 
1638                [{"particle_name": , "lattice_index": }, ... ]
1639
1640            chain_map ('list of dict'): 
1641                [{"node_start": , "node_end": , "residue_list": , ... ]
1642        """
1643        # Sanity tests
1644        node_indices = {tuple(entry['lattice_index']) for entry in node_map}                
1645        chain_map_connectivity = set()
1646        for entry in chain_map:
1647            start = self.lattice_builder.node_labels[entry['node_start']]
1648            end = self.lattice_builder.node_labels[entry['node_end']]
1649            chain_map_connectivity.add((start,end))
1650        if self.lattice_builder.lattice.connectivity != chain_map_connectivity:
1651            raise ValueError("Incomplete hydrogel: A diamond lattice must contain correct 16 lattice index pairs")
1652        diamond_indices = {tuple(row) for row in self.lattice_builder.lattice.indices}
1653        if node_indices != diamond_indices:
1654            raise ValueError(f"Incomplete hydrogel: A diamond lattice must contain exactly 8 lattice indices, {diamond_indices} ")
1655        # Register information in the pyMBE database
1656        nodes=[]
1657        for entry in node_map:
1658            nodes.append(HydrogelNode(particle_name=entry["particle_name"],
1659                                      lattice_index=entry["lattice_index"]))
1660        chains=[]
1661        for chain in chain_map:
1662            chains.append(HydrogelChain(node_start=chain["node_start"],
1663                                        node_end=chain["node_end"],
1664                                        molecule_name=chain["molecule_name"]))
1665        tpl = HydrogelTemplate(name=name,
1666                               node_map=nodes,
1667                               chain_map=chains)
1668        self.db._register_template(tpl)

Defines a hydrogel template in the pyMBE database.

Arguments:
  • name ('str'): Unique label that identifies the 'hydrogel'.
  • node_map ('list of dict'): [{"particle_name": , "lattice_index": }, ... ]
  • chain_map ('list of dict'): [{"node_start": , "node_end": , "residue_list": , ... ]
def define_molecule(self, name, residue_list):
1670    def define_molecule(self, name, residue_list):
1671        """
1672        Defines a molecule template in the pyMBE database.
1673
1674        Args:
1675            name('str'): 
1676                Unique label that identifies the 'molecule'.
1677
1678            residue_list ('list' of 'str'): 
1679                List of the 'name's of the 'residue's  in the sequence of the 'molecule'.  
1680        """
1681        tpl = MoleculeTemplate(name=name,
1682                               residue_list=residue_list)
1683        self.db._register_template(tpl)

Defines a molecule template in the pyMBE database.

Arguments:
  • name('str'): Unique label that identifies the 'molecule'.
  • residue_list ('list' of 'str'): List of the 'name's of the 'residue's in the sequence of the 'molecule'.
def define_monoprototic_acidbase_reaction(self, particle_name, pka, acidity, metadata=None):
1685    def define_monoprototic_acidbase_reaction(self, particle_name, pka, acidity, metadata=None):
1686        """
1687        Defines an acid-base reaction for a monoprototic particle in the pyMBE database.
1688
1689        Args:
1690            particle_name ('str'): 
1691                Unique label that identifies the particle template. 
1692
1693            pka ('float'): 
1694                pka-value of the acid or base.
1695
1696            acidity ('str'): 
1697                Identifies whether if the particle is 'acidic' or 'basic'.
1698
1699            metadata ('dict', optional): 
1700                Additional information to be stored in the reaction. Defaults to None.
1701        """
1702        supported_acidities = ["acidic", "basic"]
1703        if acidity not in supported_acidities:
1704            raise ValueError(f"Unsupported acidity '{acidity}' for particle '{particle_name}'. Supported acidities are {supported_acidities}.")
1705        reaction_type = "monoprotic"
1706        if acidity == "basic":
1707            reaction_type += "_base"
1708        else:
1709            reaction_type += "_acid"
1710        reaction = Reaction(participants=[ReactionParticipant(particle_name=particle_name,
1711                                                              state_name=f"{particle_name}H", 
1712                                                              coefficient=-1),
1713                                          ReactionParticipant(particle_name=particle_name,
1714                                                              state_name=f"{particle_name}",
1715                                                              coefficient=1)],
1716                            reaction_type=reaction_type,
1717                            pK=pka,
1718                            metadata=metadata)
1719        self.db._register_reaction(reaction)

Defines an acid-base reaction for a monoprototic particle in the pyMBE database.

Arguments:
  • particle_name ('str'): Unique label that identifies the particle template.
  • pka ('float'): pka-value of the acid or base.
  • acidity ('str'): Identifies whether if the particle is 'acidic' or 'basic'.
  • metadata ('dict', optional): Additional information to be stored in the reaction. Defaults to None.
def define_monoprototic_particle_states(self, particle_name, acidity):
1721    def define_monoprototic_particle_states(self, particle_name, acidity):
1722        """
1723        Defines particle states for a monoprotonic particle template including the charges in each of its possible states. 
1724
1725        Args:
1726            particle_name ('str'): 
1727                Unique label that identifies the particle template. 
1728
1729            acidity ('str'): 
1730                Identifies whether the particle is 'acidic' or 'basic'.
1731        """
1732        acidity_valid_keys = ['acidic', 'basic']
1733        if not pd.isna(acidity):
1734            if acidity not in acidity_valid_keys:
1735                raise ValueError(f"Acidity {acidity} provided for particle name  {particle_name} is not supported. Valid keys are: {acidity_valid_keys}")
1736        if acidity == "acidic":
1737            states = [{"name": f"{particle_name}H", "z": 0}, 
1738                      {"name": f"{particle_name}",  "z": -1}]
1739            
1740        elif acidity == "basic":
1741            states = [{"name": f"{particle_name}H", "z": 1}, 
1742                      {"name": f"{particle_name}",  "z": 0}]
1743        self.define_particle_states(particle_name=particle_name, 
1744                                    states=states)

Defines particle states for a monoprotonic particle template including the charges in each of its possible states.

Arguments:
  • particle_name ('str'): Unique label that identifies the particle template.
  • acidity ('str'): Identifies whether the particle is 'acidic' or 'basic'.
def define_particle( self, name, sigma, epsilon, z=0, acidity=<NA>, pka=<NA>, cutoff=<NA>, offset=<NA>):
1746    def define_particle(self, name,  sigma, epsilon, z=0, acidity=pd.NA, pka=pd.NA, cutoff=pd.NA, offset=pd.NA):
1747        """
1748        Defines a particle template in the pyMBE database.
1749
1750        Args:
1751            name('str'):
1752                 Unique label that identifies this particle type.  
1753
1754            sigma('pint.Quantity'): 
1755                Sigma parameter used to set up Lennard-Jones interactions for this particle type. 
1756
1757            epsilon('pint.Quantity'): 
1758                Epsilon parameter used to setup Lennard-Jones interactions for this particle tipe.
1759
1760            z('int', optional): 
1761                Permanent charge number of this particle type. Defaults to 0.
1762
1763            acidity('str', optional): 
1764                Identifies whether if the particle is 'acidic' or 'basic', used to setup constant pH simulations. Defaults to pd.NA.
1765
1766            pka('float', optional):
1767                If 'particle' is an acid or a base, it defines its  pka-value. Defaults to pd.NA.
1768
1769            cutoff('pint.Quantity', optional): 
1770                Cutoff parameter used to set up Lennard-Jones interactions for this particle type. Defaults to pd.NA.
1771
1772            offset('pint.Quantity', optional): 
1773                Offset parameter used to set up Lennard-Jones interactions for this particle type. Defaults to pd.NA.
1774            
1775        Notes:
1776            - 'sigma', 'cutoff' and 'offset' must have a dimensitonality of '[length]' and should be defined using pmb.units.
1777            - 'epsilon' must have a dimensitonality of '[energy]' and should be defined using pmb.units.
1778            - 'cutoff' defaults to '2**(1./6.) reduced_length'. 
1779            - 'offset' defaults to 0.
1780            - For more information on 'sigma', 'epsilon', 'cutoff' and 'offset' check 'pmb.setup_lj_interactions()'.
1781        """ 
1782        # If 'cutoff' and 'offset' are not defined, default them to the following values
1783        if pd.isna(cutoff):
1784            cutoff=self.units.Quantity(2**(1./6.), "reduced_length")
1785        if pd.isna(offset):
1786            offset=self.units.Quantity(0, "reduced_length")
1787        # Define particle states
1788        if acidity is pd.NA:
1789            states = [{"name": f"{name}",  "z": z}]
1790            self.define_particle_states(particle_name=name, 
1791                                        states=states)
1792            initial_state = name
1793        else:
1794            self.define_monoprototic_particle_states(particle_name=name,
1795                                                  acidity=acidity)
1796            initial_state = f"{name}H"
1797            if pka is not pd.NA:
1798                self.define_monoprototic_acidbase_reaction(particle_name=name,
1799                                                           acidity=acidity,
1800                                                           pka=pka)
1801        tpl = ParticleTemplate(name=name, 
1802                               sigma=PintQuantity.from_quantity(q=sigma, expected_dimension="length", ureg=self.units), 
1803                               epsilon=PintQuantity.from_quantity(q=epsilon, expected_dimension="energy", ureg=self.units),
1804                               cutoff=PintQuantity.from_quantity(q=cutoff, expected_dimension="length", ureg=self.units), 
1805                               offset=PintQuantity.from_quantity(q=offset, expected_dimension="length", ureg=self.units),
1806                               initial_state=initial_state)
1807        self.db._register_template(tpl)

Defines a particle template in the pyMBE database.

Arguments:
  • name('str'): Unique label that identifies this particle type.
  • sigma('pint.Quantity'): Sigma parameter used to set up Lennard-Jones interactions for this particle type.
  • epsilon('pint.Quantity'): Epsilon parameter used to setup Lennard-Jones interactions for this particle tipe.
  • z('int', optional): Permanent charge number of this particle type. Defaults to 0.
  • acidity('str', optional): Identifies whether if the particle is 'acidic' or 'basic', used to setup constant pH simulations. Defaults to pd.NA.
  • pka('float', optional): If 'particle' is an acid or a base, it defines its pka-value. Defaults to pd.NA.
  • cutoff('pint.Quantity', optional): Cutoff parameter used to set up Lennard-Jones interactions for this particle type. Defaults to pd.NA.
  • offset('pint.Quantity', optional): Offset parameter used to set up Lennard-Jones interactions for this particle type. Defaults to pd.NA.
Notes:
  • 'sigma', 'cutoff' and 'offset' must have a dimensitonality of '[length]' and should be defined using pmb.units.
  • 'epsilon' must have a dimensitonality of '[energy]' and should be defined using pmb.units.
  • 'cutoff' defaults to '2**(1./6.) reduced_length'.
  • 'offset' defaults to 0.
  • For more information on 'sigma', 'epsilon', 'cutoff' and 'offset' check 'pmb.setup_lj_interactions()'.
def define_particle_states(self, particle_name, states):
1809    def define_particle_states(self, particle_name, states):
1810        """
1811        Define the chemical states of an existing particle template.
1812
1813        Args:
1814            particle_name ('str'):
1815                Name of a particle template. 
1816
1817            states ('list' of 'dict'):
1818                List of dictionaries defining the particle states. Each dictionary
1819                must contain:
1820                - 'name' ('str'): Name of the particle state (e.g. '"H"', '"-"',
1821                '"neutral"').
1822                - 'z' ('int'): Charge number of the particle in this state.
1823                Example:
1824                states = [{"name": "AH", "z": 0},     # protonated
1825                         {"name": "A-", "z": -1}]    # deprotonated
1826        Notes:
1827            - Each state is assigned a unique Espresso 'es_type' automatically.
1828            - Chemical reactions (e.g. acid–base equilibria) are **not** created by
1829            this method and must be defined separately (e.g. via
1830            'set_particle_acidity()' or custom reaction definitions).
1831            - Particles without explicitly defined states are assumed to have a
1832            single, implicit state with their default charge.
1833        """
1834        for s in states:
1835            state = ParticleStateTemplate(particle_name=particle_name,
1836                                          name=s["name"],
1837                                          z=s["z"],
1838                                          es_type=self.propose_unused_type())
1839            self.db._register_template(state)

Define the chemical states of an existing particle template.

Arguments:
  • particle_name ('str'): Name of a particle template.
  • states ('list' of 'dict'): List of dictionaries defining the particle states. Each dictionary must contain:
    • 'name' ('str'): Name of the particle state (e.g. '"H"', '"-"', '"neutral"').
    • 'z' ('int'): Charge number of the particle in this state. Example: states = [{"name": "AH", "z": 0}, # protonated {"name": "A-", "z": -1}] # deprotonated
Notes:
  • Each state is assigned a unique Espresso 'es_type' automatically.
  • Chemical reactions (e.g. acid–base equilibria) are not created by this method and must be defined separately (e.g. via 'set_particle_acidity()' or custom reaction definitions).
  • Particles without explicitly defined states are assumed to have a single, implicit state with their default charge.
def define_peptide(self, name, sequence, model):
1841    def define_peptide(self, name, sequence, model):
1842        """
1843        Defines a peptide template in the pyMBE database.
1844
1845        Args:
1846            name ('str'): 
1847                Unique label that identifies the peptide.
1848
1849            sequence ('str'): 
1850                Sequence of the peptide.
1851
1852            model ('str'): 
1853                Model name. Currently only models with 1 bead '1beadAA' or with 2 beads '2beadAA' per amino acid are supported.
1854        """
1855        valid_keys = ['1beadAA','2beadAA']
1856        if model not in valid_keys:
1857            raise ValueError('Invalid label for the peptide model, please choose between 1beadAA or 2beadAA')
1858        clean_sequence = hf.protein_sequence_parser(sequence=sequence)    
1859        residue_list = self._get_residue_list_from_sequence(sequence=clean_sequence)
1860        tpl = PeptideTemplate(name=name,
1861                            residue_list=residue_list,
1862                            model=model,
1863                            sequence=sequence)
1864        self.db._register_template(tpl)        

Defines a peptide template in the pyMBE database.

Arguments:
  • name ('str'): Unique label that identifies the peptide.
  • sequence ('str'): Sequence of the peptide.
  • model ('str'): Model name. Currently only models with 1 bead '1beadAA' or with 2 beads '2beadAA' per amino acid are supported.
def define_protein(self, name, sequence, model):
1866    def define_protein(self, name, sequence, model):
1867        """
1868        Defines a protein template in the pyMBE database.
1869
1870        Args:
1871            name ('str'): 
1872                Unique label that identifies the protein.
1873
1874            sequence ('str'): 
1875                Sequence of the protein.
1876
1877            model ('string'): 
1878                Model name. Currently only models with 1 bead '1beadAA' or with 2 beads '2beadAA' per amino acid are supported.
1879
1880        Notes:
1881            - Currently, only 'lj_setup_mode="wca"' is supported. This corresponds to setting up the WCA potential.
1882        """
1883        valid_model_keys = ['1beadAA','2beadAA']
1884        if model not in valid_model_keys:
1885            raise ValueError('Invalid key for the protein model, supported models are {valid_model_keys}')
1886        
1887        residue_list = self._get_residue_list_from_sequence(sequence=sequence)
1888        tpl = ProteinTemplate(name=name,
1889                              model=model,
1890                              residue_list=residue_list,
1891                              sequence=sequence)
1892        self.db._register_template(tpl)

Defines a protein template in the pyMBE database.

Arguments:
  • name ('str'): Unique label that identifies the protein.
  • sequence ('str'): Sequence of the protein.
  • model ('string'): Model name. Currently only models with 1 bead '1beadAA' or with 2 beads '2beadAA' per amino acid are supported.
Notes:
  • Currently, only 'lj_setup_mode="wca"' is supported. This corresponds to setting up the WCA potential.
def define_residue(self, name, central_bead, side_chains):
1894    def define_residue(self, name, central_bead, side_chains):
1895        """
1896        Defines a residue template in the pyMBE database.
1897
1898        Args:
1899            name ('str'): 
1900                Unique label that identifies the residue.
1901
1902            central_bead ('str'): 
1903                'name' of the 'particle' to be placed as central_bead of the residue.
1904
1905            side_chains('list' of 'str'): 
1906                List of 'name's of the pmb_objects to be placed as side_chains of the residue. Currently, only pyMBE objects of type 'particle' or 'residue' are supported.
1907        """
1908        tpl = ResidueTemplate(name=name,
1909                              central_bead=central_bead,
1910                              side_chains=side_chains)
1911        self.db._register_template(tpl)

Defines a residue template in the pyMBE database.

Arguments:
  • name ('str'): Unique label that identifies the residue.
  • central_bead ('str'): 'name' of the 'particle' to be placed as central_bead of the residue.
  • side_chains('list' of 'str'): List of 'name's of the pmb_objects to be placed as side_chains of the residue. Currently, only pyMBE objects of type 'particle' or 'residue' are supported.
def delete_instances_in_system(self, instance_id, pmb_type):
1914    def delete_instances_in_system(self, instance_id, pmb_type):
1915        """
1916        Deletes the instance with instance_id from the ESPResSo system. 
1917        Related assembly, molecule, residue, particles and bond instances will also be deleted from the pyMBE dataframe.
1918
1919        Args:
1920            instance_id ('int'): 
1921                id of the assembly to be deleted. 
1922
1923            pmb_type ('str'): 
1924                the instance type to be deleted. 
1925
1926            espresso_system ('espressomd.system.System'): 
1927                Instance of a system class from espressomd library.
1928        """
1929        if pmb_type == "particle":
1930            instance_identifier = "particle_id"
1931        elif pmb_type == "residue":
1932            instance_identifier = "residue_id"
1933        elif pmb_type in self.db._molecule_like_types:
1934            instance_identifier = "molecule_id"
1935        elif pmb_type in self.db._assembly_like_types:
1936            instance_identifier = "assembly_id"
1937        particle_ids = self.db._find_instance_ids_by_attribute(pmb_type="particle",
1938                                                               attribute=instance_identifier,
1939                                                               value=instance_id)
1940        self._delete_particles_from_engine(particle_ids=particle_ids)
1941        self.db.delete_instance(pmb_type=pmb_type,
1942                                instance_id=instance_id)

Deletes the instance with instance_id from the ESPResSo system. Related assembly, molecule, residue, particles and bond instances will also be deleted from the pyMBE dataframe.

Arguments:
  • instance_id ('int'): id of the assembly to be deleted.
  • pmb_type ('str'): the instance type to be deleted.
  • espresso_system ('espressomd.system.System'): Instance of a system class from espressomd library.
def determine_reservoir_concentrations( self, pH_res, c_salt_res, activity_coefficient_monovalent_pair, max_number_sc_runs=200):
1944    def determine_reservoir_concentrations(self, pH_res, c_salt_res, activity_coefficient_monovalent_pair, max_number_sc_runs=200):
1945        """
1946        Determines ionic concentrations in the reservoir at fixed pH and salt concentration.
1947
1948        Args:
1949            pH_res ('float'):
1950                Target pH value in the reservoir.
1951
1952            c_salt_res ('pint.Quantity'):
1953                Concentration of monovalent salt (e.g., NaCl) in the reservoir.
1954
1955            activity_coefficient_monovalent_pair ('callable'):
1956                Function returning the activity coefficient of a monovalent ion pair
1957                as a function of ionic strength:
1958                'gamma = activity_coefficient_monovalent_pair(I)'.
1959
1960            max_number_sc_runs ('int', optional):
1961                Maximum number of self-consistent iterations allowed before
1962                convergence is enforced. Defaults to 200.
1963
1964        Returns:
1965            tuple:
1966                (cH_res, cOH_res, cNa_res, cCl_res)
1967                - cH_res ('pint.Quantity'): Concentration of H⁺ ions.
1968                - cOH_res ('pint.Quantity'): Concentration of OH⁻ ions.
1969                - cNa_res ('pint.Quantity'): Concentration of Na⁺ ions.
1970                - cCl_res ('pint.Quantity'): Concentration of Cl⁻ ions.
1971
1972        Notess:
1973            - The algorithm enforces electroneutrality in the reservoir.
1974            - Water autodissociation is included via the equilibrium constant 'Kw'.
1975            - Non-ideal effects enter through activity coefficients depending on
1976            ionic strength.
1977            - The implementation follows the self-consistent scheme described in
1978            Landsgesell (PhD thesis, Sec. 5.3, doi:10.18419/opus-10831), adapted
1979            from the original code (doi:10.18419/darus-2237).
1980        """
1981        cH_res, cOH_res, cNa_res, cCl_res = self.simulation_engine.determine_reservoir_concentrations( pH_res, c_salt_res, activity_coefficient_monovalent_pair, max_number_sc_runs)
1982        return cH_res, cOH_res, cNa_res, cCl_res

Determines ionic concentrations in the reservoir at fixed pH and salt concentration.

Arguments:
  • pH_res ('float'): Target pH value in the reservoir.
  • c_salt_res ('pint.Quantity'): Concentration of monovalent salt (e.g., NaCl) in the reservoir.
  • activity_coefficient_monovalent_pair ('callable'): Function returning the activity coefficient of a monovalent ion pair as a function of ionic strength: 'gamma = activity_coefficient_monovalent_pair(I)'.
  • max_number_sc_runs ('int', optional): Maximum number of self-consistent iterations allowed before convergence is enforced. Defaults to 200.
Returns:

tuple: (cH_res, cOH_res, cNa_res, cCl_res) - cH_res ('pint.Quantity'): Concentration of H⁺ ions. - cOH_res ('pint.Quantity'): Concentration of OH⁻ ions. - cNa_res ('pint.Quantity'): Concentration of Na⁺ ions. - cCl_res ('pint.Quantity'): Concentration of Cl⁻ ions.

Notess:
  • The algorithm enforces electroneutrality in the reservoir.
  • Water autodissociation is included via the equilibrium constant 'Kw'.
  • Non-ideal effects enter through activity coefficients depending on ionic strength.
  • The implementation follows the self-consistent scheme described in Landsgesell (PhD thesis, Sec. 5.3, doi:10.18419/opus-10831), adapted from the original code (doi:10.18419/darus-2237).
def enable_motion_of_rigid_object(self, instance_id, pmb_type):
1984    def enable_motion_of_rigid_object(self, instance_id, pmb_type):
1985        """
1986        Enables translational and rotational motion of a rigid pyMBE object instance
1987        in an ESPResSo system.This method creates a rigid-body center particle at the center of mass of
1988        the specified pyMBE object and attaches all constituent particles to it
1989        using ESPResSo virtual sites. The resulting rigid object can translate and
1990        rotate as a single body.
1991
1992        Args:
1993            instance_id ('int'):
1994                Instance ID of the pyMBE object whose rigid-body motion is enabled.
1995
1996            pmb_type ('str'):
1997                pyMBE object type of the instance (e.g. '"molecule"', '"peptide"',
1998                '"protein"', or any assembly-like type).
1999
2000        Notess:
2001            - This method requires ESPResSo to be compiled with the following
2002            features enabled:
2003                - '"VIRTUAL_SITES_RELATIVE"'
2004                - '"MASS"'
2005            - A new ESPResSo particle is created to represent the rigid-body center.
2006            - The mass of the rigid-body center is set to the number of particles
2007            belonging to the object.
2008            - The rotational inertia tensor is approximated from the squared
2009            distances of the particles to the center of mass.
2010        """
2011        self.simulation_engine.enable_motion_of_rigid_object(instance_id, pmb_type)

Enables translational and rotational motion of a rigid pyMBE object instance in an ESPResSo system.This method creates a rigid-body center particle at the center of mass of the specified pyMBE object and attaches all constituent particles to it using ESPResSo virtual sites. The resulting rigid object can translate and rotate as a single body.

Arguments:
  • instance_id ('int'): Instance ID of the pyMBE object whose rigid-body motion is enabled.
  • pmb_type ('str'): pyMBE object type of the instance (e.g. '"molecule"', '"peptide"', '"protein"', or any assembly-like type).
Notess:
  • This method requires ESPResSo to be compiled with the following features enabled:
    • '"VIRTUAL_SITES_RELATIVE"'
    • '"MASS"'
  • A new ESPResSo particle is created to represent the rigid-body center.
  • The mass of the rigid-body center is set to the number of particles belonging to the object.
  • The rotational inertia tensor is approximated from the squared distances of the particles to the center of mass.
def generate_coordinates_outside_sphere(self, center, radius, max_dist, n_samples):
2013    def generate_coordinates_outside_sphere(self, center, radius, max_dist, n_samples):
2014        """
2015        Generates random coordinates outside a sphere and inside a larger bounding sphere.
2016
2017        Args:
2018            center ('array-like'):
2019                Coordinates of the center of the spheres.
2020
2021            radius ('float'):
2022                Radius of the inner exclusion sphere. Must be positive.
2023
2024            max_dist ('float'):
2025                Radius of the outer sampling sphere. Must be larger than 'radius'.
2026
2027            n_samples ('int'):
2028                Number of coordinates to generate.
2029
2030        Returns:
2031            'list' of 'numpy.ndarray':
2032                List of coordinates lying outside the inner sphere and inside the
2033                outer sphere.
2034
2035        Notess:
2036            - Points are uniformly sampled inside a sphere of radius 'max_dist' centered at 'center' 
2037            and only those with a distance greater than or equal to 'radius' from the center are retained.
2038        """
2039        if not radius > 0: 
2040            raise ValueError (f'The value of {radius} must be a positive value')
2041        if not radius < max_dist:
2042            raise ValueError(f'The min_dist ({radius} must be lower than the max_dist ({max_dist}))')
2043        coord_list = []
2044        counter = 0
2045        while counter<n_samples:
2046            coord = self.generate_random_points_in_a_sphere(center=center, 
2047                                            radius=max_dist,
2048                                            n_samples=1)[0]
2049            if np.linalg.norm(coord-np.asarray(center))>=radius:
2050                coord_list.append (coord)
2051                counter += 1
2052        return coord_list

Generates random coordinates outside a sphere and inside a larger bounding sphere.

Arguments:
  • center ('array-like'): Coordinates of the center of the spheres.
  • radius ('float'): Radius of the inner exclusion sphere. Must be positive.
  • max_dist ('float'): Radius of the outer sampling sphere. Must be larger than 'radius'.
  • n_samples ('int'): Number of coordinates to generate.
Returns:

'list' of 'numpy.ndarray': List of coordinates lying outside the inner sphere and inside the outer sphere.

Notess:
  • Points are uniformly sampled inside a sphere of radius 'max_dist' centered at 'center' and only those with a distance greater than or equal to 'radius' from the center are retained.
def generate_random_points_in_a_sphere(self, center, radius, n_samples, on_surface=False):
2054    def generate_random_points_in_a_sphere(self, center, radius, n_samples, on_surface=False):
2055        """
2056        Generates uniformly distributed random points inside or on the surface of a sphere.
2057
2058        Args:
2059            center ('array-like'):
2060                Coordinates of the center of the sphere.
2061
2062            radius ('float'):
2063                Radius of the sphere.
2064
2065            n_samples ('int'):
2066                Number of sample points to generate.
2067
2068            on_surface ('bool', optional):
2069                If True, points are uniformly sampled on the surface of the sphere.
2070                If False, points are uniformly sampled within the sphere volume.
2071                Defaults to False.
2072
2073        Returns:
2074            'numpy.ndarray':
2075                Array of shape '(n_samples, d)' containing the generated coordinates,
2076                where 'd' is the dimensionality of 'center'.
2077        Notes:
2078            - Points are sampled in a space whose dimensionality is inferred 
2079            from the length of 'center'.
2080        """
2081        # initial values
2082        center=np.array(center)
2083        d = center.shape[0]
2084        # sample n_samples points in d dimensions from a standard normal distribution
2085        samples = self.rng.normal(size=(n_samples, d))
2086        # make the samples lie on the surface of the unit hypersphere
2087        normalize_radii = np.linalg.norm(samples, axis=1)[:, np.newaxis]
2088        samples /= normalize_radii
2089        if not on_surface:
2090            # make the samples lie inside the hypersphere with the correct density
2091            uniform_points = self.rng.uniform(size=n_samples)[:, np.newaxis]
2092            new_radii = np.power(uniform_points, 1/d)
2093            samples *= new_radii
2094        # scale the points to have the correct radius and center
2095        samples = samples * radius + center
2096        return samples 

Generates uniformly distributed random points inside or on the surface of a sphere.

Arguments:
  • center ('array-like'): Coordinates of the center of the sphere.
  • radius ('float'): Radius of the sphere.
  • n_samples ('int'): Number of sample points to generate.
  • on_surface ('bool', optional): If True, points are uniformly sampled on the surface of the sphere. If False, points are uniformly sampled within the sphere volume. Defaults to False.
Returns:

'numpy.ndarray': Array of shape '(n_samples, d)' containing the generated coordinates, where 'd' is the dimensionality of 'center'.

Notes:
  • Points are sampled in a space whose dimensionality is inferred from the length of 'center'.
def generate_trial_perpendicular_vector(self, vector, magnitude):
2098    def generate_trial_perpendicular_vector(self,vector,magnitude):
2099        """
2100        Generates a random vector perpendicular to a given vector.
2101
2102        Args:
2103            vector ('array-like'):
2104                Reference vector to which the generated vector will be perpendicular.
2105
2106            magnitude ('float'):
2107                Desired magnitude of the perpendicular vector.
2108
2109        Returns:
2110            'numpy.ndarray':
2111                Vector orthogonal to 'vector' with norm equal to 'magnitude'.
2112        """ 
2113        np_vec = np.array(vector) 
2114        if np.all(np_vec == 0):
2115            raise ValueError('Zero vector')
2116        np_vec /= np.linalg.norm(np_vec) 
2117        # Generate a random vector 
2118        random_vector = self.generate_random_points_in_a_sphere(radius=1, 
2119                                                                center=[0,0,0],
2120                                                                n_samples=1, 
2121                                                                on_surface=True)[0]
2122        # Project the random vector onto the input vector and subtract the projection
2123        projection = np.dot(random_vector, np_vec) * np_vec
2124        perpendicular_vector = random_vector - projection
2125        # Normalize the perpendicular vector to have the same magnitude as the input vector
2126        perpendicular_vector /= np.linalg.norm(perpendicular_vector) 
2127        return perpendicular_vector*magnitude

Generates a random vector perpendicular to a given vector.

Arguments:
  • vector ('array-like'): Reference vector to which the generated vector will be perpendicular.
  • magnitude ('float'): Desired magnitude of the perpendicular vector.
Returns:

'numpy.ndarray': Vector orthogonal to 'vector' with norm equal to 'magnitude'.

def get_bond_template(self, particle_name1, particle_name2, use_default_bond=False):
2129    def get_bond_template(self, particle_name1, particle_name2, use_default_bond=False) :
2130        """
2131        Retrieves a bond template connecting two particle templates.
2132
2133        Args:
2134            particle_name1 ('str'):
2135                Name of the first particle template.
2136
2137            particle_name2 ('str'):
2138                Name of the second particle template.
2139
2140            use_default_bond ('bool', optional):
2141                If True, returns the default bond template when no specific bond
2142                template is found. Defaults to False.
2143
2144        Returns:
2145            'BondTemplate':
2146                Bond template object retrieved from the pyMBE database.
2147            
2148        Notes:
2149            - This method searches the pyMBE database for a bond template defined between particle templates with names 'particle_name1' and 'particle_name2'. 
2150            - If no specific bond template is found and 'use_default_bond' is enabled, a default bond template is returned instead.
2151        """
2152        # Try to find a specific bond template
2153        bond_key = BondTemplate.make_bond_key(pn1=particle_name1,
2154                                              pn2=particle_name2)
2155        try:
2156            return self.db.get_template(name=bond_key, 
2157                                        pmb_type="bond")
2158        except ValueError:
2159            pass
2160
2161        #  Fallback to default bond if allowed
2162        if use_default_bond:
2163            return self.db.get_template(name="default", 
2164                                        pmb_type="bond")
2165
2166        # No bond template found
2167        raise ValueError(f"No bond template found between '{particle_name1}' and '{particle_name2}', and default bonds are deactivated.")

Retrieves a bond template connecting two particle templates.

Arguments:
  • particle_name1 ('str'): Name of the first particle template.
  • particle_name2 ('str'): Name of the second particle template.
  • use_default_bond ('bool', optional): If True, returns the default bond template when no specific bond template is found. Defaults to False.
Returns:

'BondTemplate': Bond template object retrieved from the pyMBE database.

Notes:
  • This method searches the pyMBE database for a bond template defined between particle templates with names 'particle_name1' and 'particle_name2'.
  • If no specific bond template is found and 'use_default_bond' is enabled, a default bond template is returned instead.
def get_charge_number_map(self):
2169    def get_charge_number_map(self):
2170        """
2171        Construct a mapping from ESPResSo particle types to their charge numbers.
2172
2173        Returns:
2174            'dict[int, float]':
2175                Dictionary mapping ESPResSo particle types to charge numbers,
2176                ''{es_type: z}''.
2177
2178        Notess:
2179            - The mapping is built from particle *states*, not instances.
2180            - If multiple templates define states with the same ''es_type'',
2181            the last encountered definition will overwrite previous ones.
2182            This behavior is intentional and assumes database consistency.
2183            - Neutral particles (''z = 0'') are included in the map.
2184        """
2185        charge_number_map = {}
2186        particle_templates = self.db.get_templates("particle")
2187        for tpl in particle_templates.values():
2188            for state in self.db.get_particle_states_templates(particle_name=tpl.name).values():
2189                charge_number_map[state.es_type] = state.z
2190        return charge_number_map

Construct a mapping from ESPResSo particle types to their charge numbers.

Returns:

'dict[int, float]': Dictionary mapping ESPResSo particle types to charge numbers, ''{es_type: z}''.

Notess:
  • The mapping is built from particle states, not instances.
  • If multiple templates define states with the same ''es_type'', the last encountered definition will overwrite previous ones. This behavior is intentional and assumes database consistency.
  • Neutral particles (''z = 0'') are included in the map.
def get_instances_df(self, pmb_type):
2192    def get_instances_df(self, pmb_type):
2193        """
2194        Returns a dataframe with all instances of type 'pmb_type' in the pyMBE database.
2195
2196        Args:
2197            pmb_type ('str'): 
2198                pmb type to search instances in the pyMBE database.
2199        
2200        Returns:
2201            ('Pandas.Dataframe'): 
2202                Dataframe with all instances of type 'pmb_type'.
2203        """
2204        return self.db._get_instances_df(pmb_type=pmb_type)

Returns a dataframe with all instances of type 'pmb_type' in the pyMBE database.

Arguments:
  • pmb_type ('str'): pmb type to search instances in the pyMBE database.
Returns:

('Pandas.Dataframe'): Dataframe with all instances of type 'pmb_type'.

def get_lj_parameters( self, particle_name1, particle_name2, combining_rule='Lorentz-Berthelot'):
2206    def get_lj_parameters(self, particle_name1, particle_name2, combining_rule='Lorentz-Berthelot'):
2207        """
2208        Returns the Lennard-Jones parameters for the interaction between the particle types given by 
2209        'particle_name1' and 'particle_name2' in the pyMBE database, calculated according to the provided combining rule.
2210
2211        Args:
2212            particle_name1 ('str'): 
2213                label of the type of the first particle type
2214
2215            particle_name2 ('str'): 
2216                label of the type of the second particle type
2217
2218            combining_rule ('string', optional): 
2219                combining rule used to calculate 'sigma' and 'epsilon' for the potential betwen a pair of particles. Defaults to 'Lorentz-Berthelot'.
2220
2221        Returns:
2222            ('dict'):
2223                {"epsilon": epsilon_value, "sigma": sigma_value, "offset": offset_value, "cutoff": cutoff_value}
2224
2225        Notes:
2226            - Currently, the only 'combining_rule' supported is Lorentz-Berthelot.
2227            - If the sigma value of 'particle_name1' or 'particle_name2' is 0, the function will return an empty dictionary. No LJ interactions are set up for particles with sigma = 0.
2228        """
2229        lj_parameters=self.db.get_lj_parameters(particle_name1=particle_name1,particle_name2=particle_name2,combining_rule=combining_rule)
2230        return lj_parameters

Returns the Lennard-Jones parameters for the interaction between the particle types given by 'particle_name1' and 'particle_name2' in the pyMBE database, calculated according to the provided combining rule.

Arguments:
  • particle_name1 ('str'): label of the type of the first particle type
  • particle_name2 ('str'): label of the type of the second particle type
  • combining_rule ('string', optional): combining rule used to calculate 'sigma' and 'epsilon' for the potential betwen a pair of particles. Defaults to 'Lorentz-Berthelot'.
Returns:

('dict'): {"epsilon": epsilon_value, "sigma": sigma_value, "offset": offset_value, "cutoff": cutoff_value}

Notes:
  • Currently, the only 'combining_rule' supported is Lorentz-Berthelot.
  • If the sigma value of 'particle_name1' or 'particle_name2' is 0, the function will return an empty dictionary. No LJ interactions are set up for particles with sigma = 0.
def get_particle_id_map(self, object_name):
2232    def get_particle_id_map(self, object_name):
2233        """
2234        Collect all particle IDs associated with an object of given name in the
2235        pyMBE database. 
2236
2237        Args:
2238            object_name ('str'): 
2239                Name of the object.
2240
2241        Returns:
2242            ('dict'): 
2243                {"all": [particle_ids],
2244                 "residue_map": {residue_id: [particle_ids]},
2245                 "molecule_map": {molecule_id: [particle_ids]},
2246                 "assembly_map": {assembly_id: [particle_ids]},}
2247
2248        Notess:
2249            - Works for all supported pyMBE templates.
2250            - Relies in the internal method Manager.get_particle_id_map, see method for the detailed code.
2251        """
2252        return self.db.get_particle_id_map(object_name=object_name)

Collect all particle IDs associated with an object of given name in the pyMBE database.

Arguments:
  • object_name ('str'): Name of the object.
Returns:

('dict'): {"all": [particle_ids], "residue_map": {residue_id: [particle_ids]}, "molecule_map": {molecule_id: [particle_ids]}, "assembly_map": {assembly_id: [particle_ids]},}

Notess:
  • Works for all supported pyMBE templates.
  • Relies in the internal method Manager.get_particle_id_map, see method for the detailed code.
def get_pka_set(self):
2254    def get_pka_set(self):
2255        """
2256        Retrieve the pKa set for all titratable particles in the pyMBE database.
2257
2258        Returns:
2259            ('dict'): 
2260                Dictionary of the form:
2261                {"particle_name": {"pka_value": float,
2262                                   "acidity": "acidic" | "basic"}}
2263        Notes:
2264            - If a particle participates in multiple acid/base reactions, an error is raised.
2265        """
2266        pka_set = {}
2267        supported_reactions = ["monoprotic_acid",
2268                               "monoprotic_base"]
2269        for reaction in self.db._reactions.values():
2270            if reaction.reaction_type not in supported_reactions:
2271                continue
2272            # Identify involved particle(s)
2273            particle_names = {participant.particle_name for participant in reaction.participants}
2274            particle_name = particle_names.pop()
2275            if particle_name in pka_set:
2276                raise ValueError(f"Multiple acid/base reactions found for particle '{particle_name}'.")
2277            pka_set[particle_name] = {"pka_value": reaction.pK}
2278            if reaction.reaction_type == "monoprotic_acid":
2279                acidity = "acidic"
2280            elif reaction.reaction_type == "monoprotic_base":
2281                acidity = "basic"
2282            pka_set[particle_name]["acidity"] = acidity
2283        return pka_set

Retrieve the pKa set for all titratable particles in the pyMBE database.

Returns:

('dict'): Dictionary of the form: {"particle_name": {"pka_value": float, "acidity": "acidic" | "basic"}}

Notes:
  • If a particle participates in multiple acid/base reactions, an error is raised.
def get_radius_map(self, dimensionless=True):
2285    def get_radius_map(self, dimensionless=True):
2286        """
2287        Gets the effective radius of each particle defined in the pyMBE database. 
2288
2289        Args:
2290            dimensionless ('bool'):
2291                If ``True``, return magnitudes expressed in ``reduced_length``.
2292                If ``False``, return Pint quantities with units.
2293        
2294        Returns:
2295            ('dict'): 
2296                {espresso_type: radius}.
2297
2298        Notes:
2299            - The radius corresponds to (sigma+offset)/2
2300        """
2301        return self.db.get_radius_map(dimensionless)

Gets the effective radius of each particle defined in the pyMBE database.

Arguments:
  • dimensionless ('bool'): If True, return magnitudes expressed in reduced_length. If False, return Pint quantities with units.
Returns:

('dict'): {espresso_type: radius}.

Notes:
  • The radius corresponds to (sigma+offset)/2
def get_reactions_df(self):
2303    def get_reactions_df(self):
2304        """
2305        Returns a dataframe with all reaction templates in the pyMBE database.
2306
2307        Returns:
2308            (Pandas.Dataframe): 
2309                Dataframe with all  reaction templates.
2310        """
2311        return self.db._get_reactions_df()

Returns a dataframe with all reaction templates in the pyMBE database.

Returns:

(Pandas.Dataframe): Dataframe with all reaction templates.

def get_reduced_units(self):
2313    def get_reduced_units(self):
2314        """
2315        Returns the  current set of reduced units defined in pyMBE.
2316
2317        Returns:
2318            reduced_units_text ('str'): 
2319                text with information about the current set of reduced units.
2320
2321        """
2322        unit_length=self.units.Quantity(1,'reduced_length')
2323        unit_energy=self.units.Quantity(1,'reduced_energy')
2324        unit_charge=self.units.Quantity(1,'reduced_charge')
2325        reduced_units_text = "\n".join(["Current set of reduced units:",
2326                                       f"{unit_length.to('nm'):.5g} = {unit_length}",
2327                                       f"{unit_energy.to('J'):.5g} = {unit_energy}",
2328                                       f"{unit_charge.to('C'):.5g} = {unit_charge}",
2329                                       f"Temperature: {(self.kT/self.kB).to('K'):.5g}"])   
2330        return reduced_units_text

Returns the current set of reduced units defined in pyMBE.

Returns:

reduced_units_text ('str'): text with information about the current set of reduced units.

def get_templates_df(self, pmb_type):
2332    def get_templates_df(self, pmb_type):
2333        """
2334        Returns a dataframe with all templates of type 'pmb_type' in the pyMBE database.
2335
2336        Args:
2337            pmb_type ('str'): 
2338                pmb type to search templates in the pyMBE database.
2339        
2340        Returns:
2341            ('Pandas.Dataframe'): 
2342                Dataframe with all templates of type given by 'pmb_type'.
2343        """
2344        return self.db._get_templates_df(pmb_type=pmb_type)

Returns a dataframe with all templates of type 'pmb_type' in the pyMBE database.

Arguments:
  • pmb_type ('str'): pmb type to search templates in the pyMBE database.
Returns:

('Pandas.Dataframe'): Dataframe with all templates of type given by 'pmb_type'.

def get_type_map(self):
2346    def get_type_map(self):
2347        """
2348        Return the mapping of ESPResSo types for all particle states defined in the pyMBE database.
2349        
2350        Returns:
2351            'dict[str, int]':
2352                A dictionary mapping each particle state to its corresponding ESPResSo type:
2353                {state_name: es_type, ...}
2354        """
2355        
2356        return self.db.get_es_types_map()

Return the mapping of ESPResSo types for all particle states defined in the pyMBE database.

Returns:

'dict[str, int]': A dictionary mapping each particle state to its corresponding ESPResSo type: {state_name: es_type, ...}

def initialize_lattice_builder(self, diamond_lattice):
2358    def initialize_lattice_builder(self, diamond_lattice):
2359        """
2360        Initialize the lattice builder with the DiamondLattice object.
2361
2362        Args:
2363            diamond_lattice ('DiamondLattice'): 
2364                DiamondLattice object from the 'lib/lattice' module to be used in the LatticeBuilder.
2365        """
2366        from .lib.lattice import LatticeBuilder, DiamondLattice
2367        if not isinstance(diamond_lattice, DiamondLattice):
2368            raise TypeError("Currently only DiamondLattice objects are supported.")
2369        self.lattice_builder = LatticeBuilder(lattice=diamond_lattice)
2370        logging.info(f"LatticeBuilder initialized with mpc={diamond_lattice.mpc} and box_l={diamond_lattice.box_l}")
2371        return self.lattice_builder

Initialize the lattice builder with the DiamondLattice object.

Arguments:
  • diamond_lattice ('DiamondLattice'): DiamondLattice object from the 'lib/lattice' module to be used in the LatticeBuilder.
def load_database(self, folder, format='csv'):
2373    def load_database(self, folder, format='csv'):
2374        """
2375        Loads a pyMBE database stored in 'folder'.
2376
2377        Args:
2378            folder ('str' or 'Path'): 
2379                Path to the folder where the pyMBE database was stored.
2380
2381            format ('str', optional): 
2382                Format of the database to be loaded. Defaults to 'csv'.
2383
2384        Return:
2385            ('dict'): 
2386                metadata with additional information about the source of the information in the database.
2387
2388        Notes:
2389            - The folder must contain the files generated by 'pmb.save_database()'.
2390            - Currently, only 'csv' format is supported.
2391        """
2392        supported_formats = ['csv']
2393        if format not in supported_formats:
2394            raise ValueError(f"Format {format} not supported. Supported formats are {supported_formats}")
2395        if format == 'csv':
2396            metadata =io._load_database_csv(self.db, 
2397                                            folder=folder)
2398        return metadata

Loads a pyMBE database stored in 'folder'.

Arguments:
  • folder ('str' or 'Path'): Path to the folder where the pyMBE database was stored.
  • format ('str', optional): Format of the database to be loaded. Defaults to 'csv'.
Return:

('dict'): metadata with additional information about the source of the information in the database.

Notes:
  • The folder must contain the files generated by 'pmb.save_database()'.
  • Currently, only 'csv' format is supported.
def load_pka_set(self, filename):
2400    def load_pka_set(self, filename):
2401        """
2402        Load a pKa set and attach chemical states and acid–base reactions
2403        to existing particle templates.
2404
2405        Args:
2406            filename ('str'): 
2407                Path to a JSON file containing the pKa set. Expected format:
2408                {"metadata": {...},
2409                  "data": {"A": {"acidity": "acidic", "pka_value": 4.5},
2410                           "B": {"acidity": "basic",  "pka_value": 9.8}}}
2411
2412        Returns:
2413            ('dict'): 
2414                Dictionary with bibliographic metadata about the original work were the pKa set was determined.
2415
2416        Notes:
2417            - This method is designed for monoprotic acids and bases only.
2418        """
2419        with open(filename, "r") as f:
2420            pka_data = json.load(f)
2421        pka_set = pka_data["data"]
2422        metadata = pka_data.get("metadata", {})
2423        self._check_pka_set(pka_set)
2424        for particle_name, entry in pka_set.items():
2425            acidity = entry["acidity"]
2426            pka = entry["pka_value"]
2427            self.define_monoprototic_acidbase_reaction(particle_name=particle_name,
2428                                                       pka=pka,
2429                                                       acidity=acidity,
2430                                                       metadata=metadata)
2431        return metadata

Load a pKa set and attach chemical states and acid–base reactions to existing particle templates.

Arguments:
  • filename ('str'): Path to a JSON file containing the pKa set. Expected format: {"metadata": {...}, "data": {"A": {"acidity": "acidic", "pka_value": 4.5}, "B": {"acidity": "basic", "pka_value": 9.8}}}
Returns:

('dict'): Dictionary with bibliographic metadata about the original work were the pKa set was determined.

Notes:
  • This method is designed for monoprotic acids and bases only.
def propose_unused_type(self):
2433    def propose_unused_type(self):
2434        """
2435        Propose an unused ESPResSo particle type.
2436
2437        Returns:
2438            ('int'): 
2439                The next available integer ESPResSo type. Returns ''0'' if no integer types are currently defined.
2440        """    
2441        return self.db.propose_unused_type()

Propose an unused ESPResSo particle type.

Returns:

('int'): The next available integer ESPResSo type. Returns ''0'' if no integer types are currently defined.

def read_protein_vtf(self, filename, unit_length=None):
2443    def read_protein_vtf(self, filename, unit_length=None):
2444        """
2445        Loads a coarse-grained protein model from a VTF file.
2446
2447        Args:
2448            filename ('str'): 
2449                Path to the VTF file.
2450                
2451            unit_length ('Pint.Quantity'): 
2452                Unit of length for coordinates (pyMBE UnitRegistry). Defaults to Angstrom.
2453
2454        Returns:
2455            ('tuple'):
2456                ('dict'): Particle topology.    
2457                ('str'):  One-letter amino-acid sequence (including n/c ends).
2458        """
2459        logging.info(f"Loading protein coarse-grain model file: {filename}")
2460        if unit_length is None:
2461            unit_length = 1 * self.units.angstrom
2462        atoms = {}        # atom_id -> atom info
2463        coords = []       # ordered coordinates
2464        residues = {}     # resid -> resname (first occurrence)
2465        has_n_term = False
2466        has_c_term = False
2467        aa_3to1 = {"ALA": "A", "ARG": "R", "ASN": "N", "ASP": "D",
2468                   "CYS": "C", "GLU": "E", "GLN": "Q", "GLY": "G",
2469                   "HIS": "H", "ILE": "I", "LEU": "L", "LYS": "K",
2470                   "MET": "M", "PHE": "F", "PRO": "P", "SER": "S",
2471                   "THR": "T", "TRP": "W", "TYR": "Y", "VAL": "V",
2472                   "n": "n", "c": "c"}
2473        # --- parse VTF ---
2474        with open(filename, "r") as f:
2475            for line in f:
2476                fields = line.split()
2477                if not fields:
2478                    continue
2479                if fields[0] == "atom":
2480                    atom_id = int(fields[1])
2481                    atom_name = fields[3]
2482                    resname = fields[5]
2483                    resid = int(fields[7])
2484                    chain_id = fields[9]
2485                    radius = float(fields[11]) * unit_length
2486                    atoms[atom_id] = {"name": atom_name,
2487                                     "resname": resname,
2488                                     "resid": resid,
2489                                     "chain_id": chain_id,
2490                                     "radius": radius}
2491                    if resname == "n":
2492                        has_n_term = True
2493                    elif resname == "c":
2494                        has_c_term = True
2495                    # register residue 
2496                    if resid not in residues:
2497                        residues[resid] = resname
2498                elif fields[0].isnumeric():
2499                    xyz = [(float(x) * unit_length).to("reduced_length").magnitude
2500                        for x in fields[1:4]]
2501                    coords.append(xyz)
2502        sequence = ""
2503        # N-terminus
2504        if has_n_term:
2505            sequence += "n"
2506        # protein residues only
2507        protein_resids = sorted(resid for resid, resname in residues.items()  if resname not in ("n", "c", "Ca"))
2508        for resid in protein_resids:
2509            resname = residues[resid]
2510            try:
2511                sequence += aa_3to1[resname]
2512            except KeyError:
2513                raise ValueError(f"Unknown residue name '{resname}' in VTF file")
2514        # C-terminus
2515        if has_c_term:
2516            sequence += "c"
2517        last_resid = max(protein_resids)
2518        # --- build topology ---
2519        topology_dict = {}
2520        for atom_id in sorted(atoms.keys()):
2521            atom = atoms[atom_id]
2522            resname = atom["resname"]
2523            resid = atom["resid"]
2524            # apply labeling rules
2525            if resname == "n":
2526                label_resid = 0
2527            elif resname == "c":
2528                label_resid = last_resid + 1
2529            elif resname == "Ca":
2530                label_resid = last_resid + 2
2531            else:
2532                label_resid = resid  # preserve original resid 
2533            label = f"{atom['name']}{label_resid}"
2534            if label in topology_dict:
2535                raise ValueError(f"Duplicate particle label '{label}'. Check VTF residue definitions.")
2536            topology_dict[label] = {"initial_pos": coords[atom_id - 1], "chain_id": atom["chain_id"], "radius": atom["radius"],}
2537        return topology_dict, sequence

Loads a coarse-grained protein model from a VTF file.

Arguments:
  • filename ('str'): Path to the VTF file.
  • unit_length ('Pint.Quantity'): Unit of length for coordinates (pyMBE UnitRegistry). Defaults to Angstrom.
Returns:

('tuple'): ('dict'): Particle topology.
('str'): One-letter amino-acid sequence (including n/c ends).

def save_database(self, folder, format='csv'):
2540    def save_database(self, folder, format='csv'):
2541        """
2542        Saves the current pyMBE database into a file 'filename'.
2543
2544        Args:
2545            folder ('str' or 'Path'): 
2546                Path to the folder where the database files will be saved.
2547
2548        """
2549        supported_formats = ['csv']
2550        if format not in supported_formats:
2551            raise ValueError(f"Format {format} not supported. Supported formats are: {supported_formats}")
2552        if format == 'csv':
2553            io._save_database_csv(self.db, 
2554                                folder=folder)

Saves the current pyMBE database into a file 'filename'.

Arguments:
  • folder ('str' or 'Path'): Path to the folder where the database files will be saved.
def set_particle_initial_state(self, particle_name, state_name):
2556    def set_particle_initial_state(self, particle_name, state_name):
2557        """
2558        Sets the default initial state of a particle template defined in the pyMBE database.
2559
2560        Args:
2561            particle_name ('str'): 
2562                Unique label that identifies the particle template. 
2563
2564            state_name ('str'): 
2565                Name of the state to be set as default initial state.
2566        """
2567        part_tpl = self.db.get_template(name=particle_name,
2568        
2569                                        pmb_type="particle")
2570        part_tpl.initial_state = state_name
2571        logging.info(f"Default initial state of particle {particle_name} set to {state_name}.")

Sets the default initial state of a particle template defined in the pyMBE database.

Arguments:
  • particle_name ('str'): Unique label that identifies the particle template.
  • state_name ('str'): Name of the state to be set as default initial state.
def set_reduced_units(self, unit_length=None, unit_charge=None, temperature=None, Kw=None):
2573    def set_reduced_units(self, unit_length=None, unit_charge=None, temperature=None, Kw=None):
2574        """
2575        Sets the set of reduced units used by pyMBE.units and it prints it.
2576
2577        Args:
2578            unit_length ('pint.Quantity', optional): 
2579                Reduced unit of length defined using the 'pmb.units' UnitRegistry. Defaults to None. 
2580
2581            unit_charge ('pint.Quantity', optional): 
2582                Reduced unit of charge defined using the 'pmb.units' UnitRegistry. Defaults to None. 
2583
2584            temperature ('pint.Quantity', optional): 
2585                Temperature of the system, defined using the 'pmb.units' UnitRegistry. Defaults to None. 
2586
2587            Kw ('pint.Quantity', optional): 
2588                Ionic product of water in mol^2/l^2. Defaults to None. 
2589
2590        Notes:
2591            - If no 'temperature' is given, a value of 298.15 K is assumed by default.
2592            - If no 'unit_length' is given, a value of 0.355 nm is assumed by default.
2593            - If no 'unit_charge' is given, a value of 1 elementary charge is assumed by default. 
2594            - If no 'Kw' is given, a value of 10^(-14) * mol^2 / l^2 is assumed by default. 
2595        """
2596        if unit_length is None:
2597            unit_length= 0.355*self.units.nm
2598        if temperature is None:
2599            temperature = 298.15 * self.units.K
2600        if unit_charge is None:
2601            unit_charge = scipy.constants.e * self.units.C
2602        if Kw is None:
2603            Kw = 1e-14
2604        # Sanity check
2605        variables=[unit_length,temperature,unit_charge]
2606        dimensionalities=["[length]","[temperature]","[charge]"]
2607        for variable,dimensionality in zip(variables,dimensionalities):
2608            self._check_dimensionality(variable,dimensionality)
2609        self.Kw=Kw*self.units.mol**2 / (self.units.l**2)
2610        self.kT=temperature*self.kB
2611        self.units._build_cache()
2612        self.units.define(f'reduced_energy = {self.kT} ')
2613        self.units.define(f'reduced_length = {unit_length}')
2614        self.units.define(f'reduced_charge = {unit_charge}')
2615        logging.info(self.get_reduced_units())

Sets the set of reduced units used by pyMBE.units and it prints it.

Arguments:
  • unit_length ('pint.Quantity', optional): Reduced unit of length defined using the 'pmb.units' UnitRegistry. Defaults to None.
  • unit_charge ('pint.Quantity', optional): Reduced unit of charge defined using the 'pmb.units' UnitRegistry. Defaults to None.
  • temperature ('pint.Quantity', optional): Temperature of the system, defined using the 'pmb.units' UnitRegistry. Defaults to None.
  • Kw ('pint.Quantity', optional): Ionic product of water in mol^2/l^2. Defaults to None.
Notes:
  • If no 'temperature' is given, a value of 298.15 K is assumed by default.
  • If no 'unit_length' is given, a value of 0.355 nm is assumed by default.
  • If no 'unit_charge' is given, a value of 1 elementary charge is assumed by default.
  • If no 'Kw' is given, a value of 10^(-14) * mol^2 / l^2 is assumed by default.
def set_simulation_engine(self, simulation_engine, box_l=None):
2617    def set_simulation_engine(self,simulation_engine,box_l=None):
2618        """ 
2619        Sets the instance attribute simulation_engine to an instance of a class of type SimulationEngine.
2620            
2621        Args:
2622            simulation_engine (Any): object which contains the methods to setup molecular dynamics and montecarlo simulations
2623            box_l('list[float,float,float]'): list of floats with the dimensions of the box
2624        """
2625
2626        if isinstance(simulation_engine, espressomd.System):
2627            self.simulation_engine=EspressoSimulation(box_l=simulation_engine.box_l,
2628                                                      db=self.db,
2629                                                      espresso_system=simulation_engine,
2630                                                      units=self.units,
2631                                                      kT=self.kT,
2632                                                      Kw=self.Kw,
2633                                                      seed=self.seed)
2634        elif isinstance(simulation_engine,LammpsProtocol): 
2635            self.simulation_engine=LammpsSimulation(box_l=box_l,
2636                                                      db=self.db,
2637                                                      lammps=simulation_engine,
2638                                                      units=self.units,
2639                                                      kT=self.kT,
2640                                                      Kw=self.Kw,
2641                                                      seed=self.seed)
2642        else:
2643            raise ValueError('The specified simulation engine is not implemented yet')

Sets the instance attribute simulation_engine to an instance of a class of type SimulationEngine.

Arguments:
  • simulation_engine (Any): object which contains the methods to setup molecular dynamics and montecarlo simulations
  • box_l('list[float,float,float]'): list of floats with the dimensions of the box
def setup_cpH( self, counter_ion, constant_pH, exclusion_range=None, use_exclusion_radius_per_type=False):
2645    def setup_cpH (self, counter_ion, constant_pH, exclusion_range=None, use_exclusion_radius_per_type = False):
2646        """
2647        Sets up the Acid/Base reactions for acidic/basic particles defined in the pyMBE database
2648        to be sampled in the constant pH ensemble. 
2649
2650        Args:
2651            counter_ion ('str'): 
2652                'name' of the counter_ion 'particle'.
2653
2654            constant_pH ('float'): 
2655                pH-value.
2656
2657            exclusion_range ('pint.Quantity', optional): 
2658                Below this value, no particles will be inserted.
2659
2660            use_exclusion_radius_per_type ('bool', optional): 
2661                Controls if one exclusion_radius for each espresso_type is used. Defaults to 'False'.
2662
2663        Returns:
2664            ('reaction_methods.ConstantpHEnsemble'): 
2665                Instance of a reaction_methods.ConstantpHEnsemble object from the espressomd library.
2666        """
2667       
2668        RE = self.simulation_engine.setup_cpH(counter_ion=counter_ion, 
2669                                        constant_pH=constant_pH, 
2670                                        exclusion_range=exclusion_range, 
2671                                        use_exclusion_radius_per_type = use_exclusion_radius_per_type)
2672        return RE

Sets up the Acid/Base reactions for acidic/basic particles defined in the pyMBE database to be sampled in the constant pH ensemble.

Arguments:
  • counter_ion ('str'): 'name' of the counter_ion 'particle'.
  • constant_pH ('float'): pH-value.
  • exclusion_range ('pint.Quantity', optional): Below this value, no particles will be inserted.
  • use_exclusion_radius_per_type ('bool', optional): Controls if one exclusion_radius for each espresso_type is used. Defaults to 'False'.
Returns:

('reaction_methods.ConstantpHEnsemble'): Instance of a reaction_methods.ConstantpHEnsemble object from the espressomd library.

def setup_gcmc( self, c_salt_res, salt_cation_name, salt_anion_name, activity_coefficient, exclusion_range=None, use_exclusion_radius_per_type=False):
2674    def setup_gcmc(self, c_salt_res, salt_cation_name, salt_anion_name, activity_coefficient, exclusion_range=None, use_exclusion_radius_per_type = False):
2675        """
2676        Sets up grand-canonical coupling to a reservoir of salt.
2677        For reactive systems coupled to a reservoir, the grand-reaction method has to be used instead.
2678
2679        Args:
2680            c_salt_res ('pint.Quantity'): 
2681                Concentration of monovalent salt (e.g. NaCl) in the reservoir.
2682
2683            salt_cation_name ('str'): 
2684                Name of the salt cation (e.g. Na+) particle.
2685
2686            salt_anion_name ('str'): 
2687                Name of the salt anion (e.g. Cl-) particle.
2688
2689            activity_coefficient ('callable'): 
2690                A function that calculates the activity coefficient of an ion pair as a function of the ionic strength.
2691
2692            exclusion_range('pint.Quantity', optional): 
2693                For distances shorter than this value, no particles will be inserted.
2694
2695            use_exclusion_radius_per_type('bool',optional): 
2696                Controls if one exclusion_radius for each espresso_type is used. Defaults to 'False'.
2697
2698        Returns:
2699            ('reaction_methods.ReactionEnsemble'): 
2700                Instance of a reaction_methods.ReactionEnsemble object from the espressomd library.
2701        """
2702        RE = self.simulation_engine.setup_gcmc(c_salt_res=c_salt_res, 
2703                                                salt_anion_name=salt_anion_name, 
2704                                                salt_cation_name=salt_cation_name, 
2705                                                activity_coefficient=activity_coefficient, 
2706                                                exclusion_range=exclusion_range, 
2707                                                use_exclusion_radius_per_type = use_exclusion_radius_per_type)
2708        return RE

Sets up grand-canonical coupling to a reservoir of salt. For reactive systems coupled to a reservoir, the grand-reaction method has to be used instead.

Arguments:
  • c_salt_res ('pint.Quantity'): Concentration of monovalent salt (e.g. NaCl) in the reservoir.
  • salt_cation_name ('str'): Name of the salt cation (e.g. Na+) particle.
  • salt_anion_name ('str'): Name of the salt anion (e.g. Cl-) particle.
  • activity_coefficient ('callable'): A function that calculates the activity coefficient of an ion pair as a function of the ionic strength.
  • exclusion_range('pint.Quantity', optional): For distances shorter than this value, no particles will be inserted.
  • use_exclusion_radius_per_type('bool',optional): Controls if one exclusion_radius for each espresso_type is used. Defaults to 'False'.
Returns:

('reaction_methods.ReactionEnsemble'): Instance of a reaction_methods.ReactionEnsemble object from the espressomd library.

def setup_grxmc_reactions( self, pH_res, c_salt_res, proton_name, hydroxide_name, salt_cation_name, salt_anion_name, activity_coefficient, exclusion_range=None, use_exclusion_radius_per_type=False):
2710    def setup_grxmc_reactions(self, pH_res, c_salt_res, proton_name, hydroxide_name, salt_cation_name, salt_anion_name, activity_coefficient, exclusion_range=None, use_exclusion_radius_per_type = False):
2711        """
2712        Sets up acid/base reactions for acidic/basic monoprotic particles defined in the pyMBE database, 
2713        as well as a grand-canonical coupling to a reservoir of small ions. 
2714        
2715
2716        Args:
2717            pH_res ('float'): 
2718                pH-value in the reservoir.
2719
2720            c_salt_res ('pint.Quantity'): 
2721                Concentration of monovalent salt (e.g. NaCl) in the reservoir.
2722
2723            proton_name ('str'): 
2724                Name of the proton (H+) particle.
2725
2726            hydroxide_name ('str'): 
2727                Name of the hydroxide (OH-) particle.
2728
2729            salt_cation_name ('str'): 
2730                Name of the salt cation (e.g. Na+) particle.
2731
2732            salt_anion_name ('str'): 
2733                Name of the salt anion (e.g. Cl-) particle.
2734
2735            activity_coefficient ('callable'): 
2736                A function that calculates the activity coefficient of an ion pair as a function of the ionic strength.
2737
2738            exclusion_range('pint.Quantity', optional): 
2739                For distances shorter than this value, no particles will be inserted.
2740
2741            use_exclusion_radius_per_type('bool', optional): 
2742                Controls if one exclusion_radius for each espresso_type is used. Defaults to 'False'.
2743
2744        Returns:
2745            For the Espresso system class:
2746            Output ('tuple(reaction_methods.ReactionEnsemble,pint.Quantity)'):
2747
2748                'reaction_methods.ReactionEnsemble':  
2749                    espressomd reaction_methods object with all reactions necesary to run the GRxMC ensamble.
2750                
2751                'pint.Quantity': 
2752                    Ionic strength of the reservoir (useful for calculating partition coefficients).
2753
2754        Notess:
2755            - This implementation uses the original formulation of the grand-reaction method by Landsgesell et al. [1].
2756
2757        [1] Landsgesell, J., Hebbeker, P., Rud, O., Lunkad, R., Košovan, P., & Holm, C. (2020). Grand-reaction method for simulations of ionization equilibria coupled to ion partitioning. Macromolecules, 53(8), 3007-3020.
2758        """     
2759        output=self.simulation_engine.setup_grxmc_reactions(pH_res=pH_res, 
2760                                                            c_salt_res=c_salt_res, 
2761                                                            proton_name=proton_name, 
2762                                                            hydroxide_name=hydroxide_name, 
2763                                                            salt_cation_name=salt_cation_name, 
2764                                                            salt_anion_name=salt_anion_name, 
2765                                                            activity_coefficient=activity_coefficient, 
2766                                                            exclusion_range=exclusion_range, 
2767                                                            use_exclusion_radius_per_type=use_exclusion_radius_per_type)
2768        
2769        return output

Sets up acid/base reactions for acidic/basic monoprotic particles defined in the pyMBE database, as well as a grand-canonical coupling to a reservoir of small ions.

Arguments:
  • pH_res ('float'): pH-value in the reservoir.
  • c_salt_res ('pint.Quantity'): Concentration of monovalent salt (e.g. NaCl) in the reservoir.
  • proton_name ('str'): Name of the proton (H+) particle.
  • hydroxide_name ('str'): Name of the hydroxide (OH-) particle.
  • salt_cation_name ('str'): Name of the salt cation (e.g. Na+) particle.
  • salt_anion_name ('str'): Name of the salt anion (e.g. Cl-) particle.
  • activity_coefficient ('callable'): A function that calculates the activity coefficient of an ion pair as a function of the ionic strength.
  • exclusion_range('pint.Quantity', optional): For distances shorter than this value, no particles will be inserted.
  • use_exclusion_radius_per_type('bool', optional): Controls if one exclusion_radius for each espresso_type is used. Defaults to 'False'.
Returns:

For the Espresso system class: Output ('tuple(reaction_methods.ReactionEnsemble,pint.Quantity)'):

'reaction_methods.ReactionEnsemble':  
    espressomd reaction_methods object with all reactions necesary to run the GRxMC ensamble.

'pint.Quantity': 
    Ionic strength of the reservoir (useful for calculating partition coefficients).
Notess:
  • This implementation uses the original formulation of the grand-reaction method by Landsgesell et al. [1].

[1] Landsgesell, J., Hebbeker, P., Rud, O., Lunkad, R., Košovan, P., & Holm, C. (2020). Grand-reaction method for simulations of ionization equilibria coupled to ion partitioning. Macromolecules, 53(8), 3007-3020.

def setup_grxmc_unified( self, pH_res, c_salt_res, cation_name, anion_name, activity_coefficient, exclusion_range=None, use_exclusion_radius_per_type=False):
2771    def setup_grxmc_unified(self, pH_res, c_salt_res, cation_name, anion_name, activity_coefficient, exclusion_range=None, use_exclusion_radius_per_type = False):
2772        """
2773        Sets up acid/base reactions for acidic/basic 'particles' defined in the pyMBE database, as well as a grand-canonical coupling to a 
2774        reservoir of small ions using a unified formulation for small ions.
2775
2776        Args:
2777            pH_res ('float'): 
2778                pH-value in the reservoir.
2779
2780            c_salt_res ('pint.Quantity'): 
2781                Concentration of monovalent salt (e.g. NaCl) in the reservoir.
2782
2783            cation_name ('str'): 
2784                Name of the cationic particle.
2785
2786            anion_name ('str'): 
2787                Name of the anionic particle.
2788
2789            activity_coefficient ('callable'): 
2790                A function that calculates the activity coefficient of an ion pair as a function of the ionic strength.
2791
2792            exclusion_range('pint.Quantity', optional): 
2793                Below this value, no particles will be inserted.
2794            
2795            use_exclusion_radius_per_type('bool', optional): 
2796                Controls if one exclusion_radius per each espresso_type. Defaults to 'False'.
2797
2798        Returns:
2799            For the Espresso system class:
2800            Output ('tuple(reaction_methods.ReactionEnsemble,pint.Quantity)'):
2801
2802                'reaction_methods.ReactionEnsemble':  
2803                    espressomd reaction_methods object with all reactions necesary to run the GRxMC ensamble.
2804                
2805                'pint.Quantity': 
2806                    Ionic strength of the reservoir (useful for calculating partition coefficients).
2807
2808        Notes:
2809            - This implementation uses the formulation of the grand-reaction method by Curk et al. [1], which relies on "unified" ion types X+ = {H+, Na+} and X- = {OH-, Cl-}. 
2810            - A function that implements the original version of the grand-reaction method by Landsgesell et al. [2] is also available under the name 'setup_grxmc_reactions'.
2811
2812        [1] Curk, T., Yuan, J., & Luijten, E. (2022). Accelerated simulation method for charge regulation effects. The Journal of Chemical Physics, 156(4).
2813        [2] Landsgesell, J., Hebbeker, P., Rud, O., Lunkad, R., Košovan, P., & Holm, C. (2020). Grand-reaction method for simulations of ionization equilibria coupled to ion partitioning. Macromolecules, 53(8), 3007-3020.
2814        """       
2815        output=self.simulation_engine.setup_grxmc_unified(pH_res=pH_res, 
2816                                                         c_salt_res=c_salt_res, 
2817                                                         cation_name=cation_name, 
2818                                                         anion_name=anion_name, 
2819                                                         activity_coefficient=activity_coefficient, 
2820                                                         exclusion_range=exclusion_range, 
2821                                                         use_exclusion_radius_per_type = use_exclusion_radius_per_type)
2822        return output

Sets up acid/base reactions for acidic/basic 'particles' defined in the pyMBE database, as well as a grand-canonical coupling to a reservoir of small ions using a unified formulation for small ions.

Arguments:
  • pH_res ('float'): pH-value in the reservoir.
  • c_salt_res ('pint.Quantity'): Concentration of monovalent salt (e.g. NaCl) in the reservoir.
  • cation_name ('str'): Name of the cationic particle.
  • anion_name ('str'): Name of the anionic particle.
  • activity_coefficient ('callable'): A function that calculates the activity coefficient of an ion pair as a function of the ionic strength.
  • exclusion_range('pint.Quantity', optional): Below this value, no particles will be inserted.
  • use_exclusion_radius_per_type('bool', optional): Controls if one exclusion_radius per each espresso_type. Defaults to 'False'.
Returns:

For the Espresso system class: Output ('tuple(reaction_methods.ReactionEnsemble,pint.Quantity)'):

'reaction_methods.ReactionEnsemble':  
    espressomd reaction_methods object with all reactions necesary to run the GRxMC ensamble.

'pint.Quantity': 
    Ionic strength of the reservoir (useful for calculating partition coefficients).
Notes:
  • This implementation uses the formulation of the grand-reaction method by Curk et al. [1], which relies on "unified" ion types X+ = {H+, Na+} and X- = {OH-, Cl-}.
  • A function that implements the original version of the grand-reaction method by Landsgesell et al. [2] is also available under the name 'setup_grxmc_reactions'.

[1] Curk, T., Yuan, J., & Luijten, E. (2022). Accelerated simulation method for charge regulation effects. The Journal of Chemical Physics, 156(4). [2] Landsgesell, J., Hebbeker, P., Rud, O., Lunkad, R., Košovan, P., & Holm, C. (2020). Grand-reaction method for simulations of ionization equilibria coupled to ion partitioning. Macromolecules, 53(8), 3007-3020.

def setup_lj_interactions(self, shift_potential=True, combining_rule='Lorentz-Berthelot'):
2824    def setup_lj_interactions(self, shift_potential=True, combining_rule='Lorentz-Berthelot'):
2825        """
2826        Sets up the Lennard-Jones (LJ) potential between all pairs of particle states defined in the pyMBE database.
2827
2828        Args:
2829
2830            shift_potential('bool', optional): 
2831                If True, a shift will be automatically computed such that the potential is continuous at the cutoff radius. Otherwise, no shift will be applied. Defaults to True.
2832
2833            combining_rule('string', optional): 
2834                combining rule used to calculate 'sigma' and 'epsilon' for the potential between a pair of particles. Defaults to 'Lorentz-Berthelot'.
2835
2836            warning('bool', optional): 
2837                switch to activate/deactivate warning messages. Defaults to True.
2838
2839        Notes:
2840            - Currently, the only 'combining_rule' supported is Lorentz-Berthelot.
2841            - Check the documentation of ESPResSo for more info about the potential https://espressomd.github.io/doc4.2.0/inter_non-bonded.html
2842
2843        """
2844        self.simulation_engine.setup_lj_interactions(shift_potential=shift_potential, 
2845                                                     combining_rule=combining_rule)

Sets up the Lennard-Jones (LJ) potential between all pairs of particle states defined in the pyMBE database.

Arguments:
  • shift_potential('bool', optional): If True, a shift will be automatically computed such that the potential is continuous at the cutoff radius. Otherwise, no shift will be applied. Defaults to True.
  • combining_rule('string', optional): combining rule used to calculate 'sigma' and 'epsilon' for the potential between a pair of particles. Defaults to 'Lorentz-Berthelot'.
  • warning('bool', optional): switch to activate/deactivate warning messages. Defaults to True.
Notes: