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

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)
 92    def __init__(self, seed, temperature=None, unit_length=None, unit_charge=None, Kw=None):
 93        """
 94        Initializes the pyMBE library.
 95
 96        Args:
 97            seed ('int'):
 98                Seed for the random number generator.
 99
100            temperature ('pint.Quantity', optional):
101                Simulation temperature. If ''None'', defaults to 298.15 K.
102
103            unit_length ('pint.Quantity', optional):
104                Reference length for reduced units. If ''None'', defaults to
105                0.355 nm.
106
107            unit_charge ('pint.Quantity', optional):
108                Reference charge for reduced units. If ''None'', defaults to
109                one elementary charge.
110
111            Kw ('pint.Quantity', optional):
112                Ionic product of water (typically in mol²/L²). If ''None'',
113                defaults to 1e-14 mol²/L².
114        """
115        # Seed and RNG
116        self.seed=seed
117        self.rng = np.random.default_rng(seed)
118        self.units=pint.UnitRegistry()
119        self.N_A=scipy.constants.N_A / self.units.mol
120        self.kB=scipy.constants.k * self.units.J / self.units.K
121        self.e=scipy.constants.e * self.units.C
122        self.set_reduced_units(unit_length=unit_length, 
123                               unit_charge=unit_charge,
124                               temperature=temperature, 
125                               Kw=Kw)
126        
127        self.db = Manager(units=self.units)
128        self.lattice_builder = None
129        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
lattice_builder
root
def calculate_center_of_mass(self, instance_id, pmb_type, espresso_system):
463    def calculate_center_of_mass(self, instance_id, pmb_type, espresso_system):
464        """
465        Calculates the center of mass of a pyMBE object instance in an ESPResSo system.
466
467        Args:
468            instance_id ('int'):
469                pyMBE instance ID of the object whose center of mass is calculated.
470
471            pmb_type ('str'):
472                Type of the pyMBE object. Must correspond to a particle-aggregating
473                template type (e.g. '"molecule"', '"residue"', '"peptide"', '"protein"').
474
475            espresso_system ('espressomd.system.System'):
476                ESPResSo system containing the particle instances.
477
478        Returns:
479            ('numpy.ndarray'):
480                Array of shape '(3,)' containing the Cartesian coordinates of the
481                center of mass.
482
483        Notes:
484            - This method assumes equal mass for all particles.
485            - Periodic boundary conditions are *not* unfolded; positions are taken
486            directly from ESPResSo particle coordinates.
487        """
488        center_of_mass = np.zeros(3)
489        axis_list = [0,1,2]
490        inst = self.db.get_instance(pmb_type=pmb_type,
491                                    instance_id=instance_id)
492        particle_id_list = self.get_particle_id_map(object_name=inst.name)["all"]
493        for pid in particle_id_list:
494            for axis in axis_list:
495                center_of_mass [axis] += espresso_system.part.by_id(pid).pos[axis]
496        center_of_mass = center_of_mass / len(particle_id_list)
497        return center_of_mass

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"').
  • espresso_system ('espressomd.system.System'): ESPResSo system containing the particle instances.
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):
499    def calculate_HH(self, template_name, pH_list=None, pka_set=None):
500        """
501        Calculates the charge in the template object according to the ideal  Henderson–Hasselbalch titration curve.
502
503        Args:
504            template_name ('str'):
505                Name of the template.
506
507            pH_list ('list[float]', optional):
508                pH values at which the charge is evaluated.
509                Defaults to 50 values between 2 and 12.
510
511            pka_set ('dict', optional):
512                Mapping: {particle_name: {"pka_value": 'float', "acidity": "acidic"|"basic"}}
513
514        Returns:
515            'list[float]':
516                Net molecular charge at each pH value.
517        """
518        if pH_list is None:
519            pH_list = np.linspace(2, 12, 50)
520        if pka_set is None:
521            pka_set = self.get_pka_set()
522        self._check_pka_set(pka_set=pka_set)
523        particle_counts = self.db.get_particle_templates_under(template_name=template_name,
524                                                               return_counts=True)
525        if not particle_counts:
526            return [None] * len(pH_list)
527        charge_number_map = self.get_charge_number_map()
528        def formal_charge(particle_name):
529            tpl = self.db.get_template(name=particle_name, 
530                                       pmb_type="particle")
531            state = self.db.get_template(name=tpl.initial_state,
532                                         pmb_type="particle_state")
533            return charge_number_map[state.es_type]
534        Z_HH = []
535        for pH in pH_list:
536            Z = 0.0
537            for particle, multiplicity in particle_counts.items():
538                if particle in pka_set:
539                    pka = pka_set[particle]["pka_value"]
540                    acidity = pka_set[particle]["acidity"]
541                    if acidity == "acidic":
542                        psi = -1
543                    elif acidity == "basic":
544                        psi = +1
545                    else:
546                        raise ValueError(f"Unknown acidity '{acidity}' for particle '{particle}'")
547                    charge = psi / (1.0 + 10.0 ** (psi * (pH - pka)))
548                    Z += multiplicity * charge
549                else:
550                    Z += multiplicity * formal_charge(particle)
551            Z_HH.append(Z)
552        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):
554    def calculate_HH_Donnan(self, c_macro, c_salt, pH_list=None, pka_set=None):
555        """
556        Computes macromolecular charges using the Henderson–Hasselbalch equation
557        coupled to ideal Donnan partitioning.
558
559        Args:
560            c_macro ('dict'):
561                Mapping of macromolecular species names to their concentrations
562                in the system:
563                '{molecule_name: concentration}'.
564                
565            c_salt ('float' or 'pint.Quantity'):
566                Salt concentration in the reservoir.
567
568            pH_list ('list[float]', optional):
569                List of pH values in the reservoir at which the calculation is
570                performed. If 'None', 50 equally spaced values between 2 and 12
571                are used.
572
573            pka_set ('dict', optional):
574                Dictionary defining the acid–base properties of titratable particle
575                types:
576                '{particle_name: {"pka_value": float, "acidity": "acidic" | "basic"}}'.
577                If 'None', the pKa set is taken from the pyMBE database.
578
579        Returns:
580            'dict':
581                Dictionary containing:
582                - '"charges_dict"' ('dict'):
583                    Mapping '{molecule_name: list}' of Henderson–Hasselbalch–Donnan
584                    charges evaluated at each pH value.
585                - '"pH_system_list"' ('list[float]'):
586                    Effective pH values inside the system phase after Donnan
587                    partitioning.
588                - '"partition_coefficients"' ('list[float]'):
589                    Partition coefficients of monovalent cations at each pH value.
590
591        Notes:
592            - This method assumes **ideal Donnan equilibrium** and **monovalent salt**.
593            - The ionic strength of the reservoir includes both salt and
594            pH-dependent H⁺/OH⁻ contributions.
595            - All charged macromolecular species present in the system must be
596            included in 'c_macro'; missing species will lead to incorrect results.
597            - The nonlinear Donnan equilibrium equation is solved using a scalar
598            root finder ('brentq') in logarithmic form for numerical stability.
599            - This method is intended for **two-phase systems**; for single-phase
600            systems use 'calculate_HH' instead.
601        """
602        if pH_list is None:
603            pH_list=np.linspace(2,12,50)
604        if pka_set is None:
605            pka_set=self.get_pka_set() 
606        self._check_pka_set(pka_set=pka_set)
607        partition_coefficients_list = []
608        pH_system_list = []
609        Z_HH_Donnan={}
610        for key in c_macro:
611            Z_HH_Donnan[key] = []
612        def calc_charges(c_macro, pH):
613            """
614            Calculates the charges of the different kinds of molecules according to the Henderson-Hasselbalch equation.
615
616            Args:
617                c_macro ('dict'): 
618                    {"name": concentration} - A dict containing the concentrations of all charged macromolecular species in the system. 
619
620                pH ('float'): 
621                    pH-value that is used in the HH equation.
622
623            Returns:
624                ('dict'): 
625                    {"molecule_name": charge}
626            """
627            charge = {}
628            for name in c_macro:
629                charge[name] = self.calculate_HH(name, [pH], pka_set)[0]
630            return charge
631
632        def calc_partition_coefficient(charge, c_macro):
633            """
634            Calculates the partition coefficients of positive ions according to the ideal Donnan theory.
635
636            Args:
637                charge ('dict'): 
638                    {"molecule_name": charge}
639
640                c_macro ('dict'): 
641                    {"name": concentration} - A dict containing the concentrations of all charged macromolecular species in the system. 
642            """
643            nonlocal ionic_strength_res
644            charge_density = 0.0
645            for key in charge:
646                charge_density += charge[key] * c_macro[key]
647            return (-charge_density / (2 * ionic_strength_res) + np.sqrt((charge_density / (2 * ionic_strength_res))**2 + 1)).magnitude
648        for pH_value in pH_list:    
649            # calculate the ionic strength of the reservoir
650            if pH_value <= 7.0:
651                ionic_strength_res = 10 ** (-pH_value) * self.units.mol/self.units.l + c_salt 
652            elif pH_value > 7.0:
653                ionic_strength_res = 10 ** (-(14-pH_value)) * self.units.mol/self.units.l + c_salt
654            #Determine the partition coefficient of positive ions by solving the system of nonlinear, coupled equations
655            #consisting of the partition coefficient given by the ideal Donnan theory and the Henderson-Hasselbalch equation.
656            #The nonlinear equation is formulated for log(xi) since log-operations are not supported for RootResult objects.
657            equation = lambda logxi: logxi - np.log10(calc_partition_coefficient(calc_charges(c_macro, pH_value - logxi), c_macro))
658            logxi = scipy.optimize.root_scalar(equation, bracket=[-1e2, 1e2], method="brentq")
659            partition_coefficient = 10**logxi.root
660            charges_temp = calc_charges(c_macro, pH_value-np.log10(partition_coefficient))
661            for key in c_macro:
662                Z_HH_Donnan[key].append(charges_temp[key])
663            pH_system_list.append(pH_value - np.log10(partition_coefficient))
664            partition_coefficients_list.append(partition_coefficient)
665        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, espresso_system, object_name, pmb_type, dimensionless=False):
667    def calculate_net_charge(self,espresso_system,object_name,pmb_type,dimensionless=False):
668        """
669        Calculates the net charge per instance of a given pmb object type.
670
671        Args:
672            espresso_system (espressomd.system.System):
673                ESPResSo system containing the particles.
674            object_name (str):
675                Name of the object (e.g. molecule, residue, peptide, protein).
676            pmb_type (str):
677                Type of object to analyze. Must be molecule-like.
678            dimensionless (bool, optional):
679                If True, return charge as a pure number.
680                If False, return a quantity with reduced_charge units.
681
682        Returns:
683            dict:
684                {"mean": mean_net_charge, "instances": {instance_id: net_charge}}
685        """
686        id_map = self.get_particle_id_map(object_name=object_name)
687        label = self._get_label_id_map(pmb_type=pmb_type)
688        instance_map = id_map[label]
689        charges = {}
690        for instance_id, particle_ids in instance_map.items():
691            if dimensionless:
692                net_charge = 0.0
693            else:
694                net_charge = 0 * self.units.Quantity(1, "reduced_charge")
695            for pid in particle_ids:
696                q = espresso_system.part.by_id(pid).q
697                if not dimensionless:
698                    q *= self.units.Quantity(1, "reduced_charge")
699                net_charge += q
700            charges[instance_id] = net_charge
701        # Mean charge
702        if dimensionless:
703            mean_charge = float(np.mean(list(charges.values())))
704        else:
705            mean_charge = (np.mean([q.magnitude for q in charges.values()])* self.units.Quantity(1, "reduced_charge"))
706        return {"mean": mean_charge, "instances": charges}

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

Arguments:
  • espresso_system (espressomd.system.System): ESPResSo system containing the particles.
  • 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, espresso_system, pmb_type):
708    def center_object_in_simulation_box(self, instance_id, espresso_system, pmb_type):
709        """
710        Centers a pyMBE object instance in the simulation box of an ESPResSo system.
711        The object is translated such that its center of mass coincides with the
712        geometric center of the ESPResSo simulation box.
713
714        Args:
715            instance_id ('int'):
716                ID of the pyMBE object instance to be centered.
717
718            pmb_type ('str'):
719                Type of the pyMBE object.
720
721            espresso_system ('espressomd.system.System'):
722                ESPResSo system object in which the particles are defined.
723
724        Notes:
725            - Works for both cubic and non-cubic simulation boxes.
726        """
727        inst = self.db.get_instance(instance_id=instance_id,
728                                    pmb_type=pmb_type)
729        center_of_mass = self.calculate_center_of_mass(instance_id=instance_id,
730                                                       espresso_system=espresso_system,
731                                                       pmb_type=pmb_type)
732        box_center = [espresso_system.box_l[0]/2.0,
733                      espresso_system.box_l[1]/2.0,
734                      espresso_system.box_l[2]/2.0]
735        particle_id_list = self.get_particle_id_map(object_name=inst.name)["all"]
736        for pid in particle_id_list:
737            es_pos = espresso_system.part.by_id(pid).pos
738            espresso_system.part.by_id(pid).pos = es_pos - center_of_mass + box_center

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.
  • pmb_type ('str'): Type of the pyMBE object.
  • espresso_system ('espressomd.system.System'): ESPResSo system object in which the particles are defined.
Notes:
  • Works for both cubic and non-cubic simulation boxes.
def create_added_salt(self, espresso_system, cation_name, anion_name, c_salt):
740    def create_added_salt(self, espresso_system, cation_name, anion_name, c_salt):    
741        """
742        Creates a 'c_salt' concentration of 'cation_name' and 'anion_name' ions into the 'espresso_system'.
743
744        Args:
745            espresso_system('espressomd.system.System'): instance of an espresso system object.
746            cation_name('str'): 'name' of a particle with a positive charge.
747            anion_name('str'): 'name' of a particle with a negative charge.
748            c_salt('float'): Salt concentration.
749            
750        Returns:
751            c_salt_calculated('float'): Calculated salt concentration added to 'espresso_system'.
752        """ 
753        cation_tpl = self.db.get_template(pmb_type="particle",
754                                          name=cation_name)
755        cation_state = self.db.get_template(pmb_type="particle_state",
756                                            name=cation_tpl.initial_state)
757        cation_charge = cation_state.z
758        anion_tpl = self.db.get_template(pmb_type="particle",
759                                          name=anion_name)
760        anion_state = self.db.get_template(pmb_type="particle_state",
761                                            name=anion_tpl.initial_state)
762        anion_charge = anion_state.z
763        if cation_charge <= 0:
764            raise ValueError(f'ERROR cation charge must be positive, charge {cation_charge}')
765        if anion_charge >= 0:
766            raise ValueError(f'ERROR anion charge must be negative, charge {anion_charge}')
767        # Calculate the number of ions in the simulation box
768        volume=self.units.Quantity(espresso_system.volume(), 'reduced_length**3')
769        if c_salt.check('[substance] [length]**-3'):
770            N_ions= int((volume*c_salt.to('mol/reduced_length**3')*self.N_A).magnitude)
771            c_salt_calculated=N_ions/(volume*self.N_A)
772        elif c_salt.check('[length]**-3'):
773            N_ions= int((volume*c_salt.to('reduced_length**-3')).magnitude)
774            c_salt_calculated=N_ions/volume
775        else:
776            raise ValueError('Unknown units for c_salt, please provided it in [mol / volume] or [particle / volume]', c_salt)
777        N_cation = N_ions*abs(anion_charge)
778        N_anion = N_ions*abs(cation_charge)
779        self.create_particle(espresso_system=espresso_system, 
780                             name=cation_name, 
781                             number_of_particles=N_cation)
782        self.create_particle(espresso_system=espresso_system, 
783                             name=anion_name, 
784                             number_of_particles=N_anion)
785        if c_salt_calculated.check('[substance] [length]**-3'):
786            logging.info(f"added salt concentration of {c_salt_calculated.to('mol/L')} given by {N_cation} cations and {N_anion} anions")
787        elif c_salt_calculated.check('[length]**-3'):
788            logging.info(f"added salt concentration of {c_salt_calculated.to('reduced_length**-3')} given by {N_cation} cations and {N_anion} anions")
789        return c_salt_calculated

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

Arguments:
  • espresso_system('espressomd.system.System'): instance of an espresso system object.
  • 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, espresso_system, use_default_bond=False):
791    def create_bond(self, particle_id1, particle_id2, espresso_system, use_default_bond=False):
792        """
793        Creates a bond between two particle instances in an ESPResSo system and registers it in the pyMBE database.
794
795        This method performs the following steps:
796            1. Retrieves the particle instances corresponding to 'particle_id1' and 'particle_id2' from the database.
797            2. Retrieves or creates the corresponding ESPResSo bond instance using the bond template.
798            3. Adds the ESPResSo bond instance to the ESPResSo system if it was newly created.
799            4. Adds the bond to the first particle's bond list in ESPResSo.
800            5. Creates a 'BondInstance' in the database and registers it.
801
802        Args:
803            particle_id1 ('int'): 
804                pyMBE and ESPResSo ID of the first particle.
805
806            particle_id2 ('int'): 
807                pyMBE and ESPResSo ID of the second particle.
808
809            espresso_system ('espressomd.system.System'): 
810                ESPResSo system object where the bond will be created.
811
812            use_default_bond ('bool', optional): 
813                If True, use a default bond template if no specific template exists. Defaults to False.
814
815        Returns:
816            ('int'): 
817                bond_id of the bond instance created in the pyMBE database.
818        """
819        particle_inst_1 = self.db.get_instance(pmb_type="particle",
820                                               instance_id=particle_id1)
821        particle_inst_2 = self.db.get_instance(pmb_type="particle",
822                                               instance_id=particle_id2)
823        bond_tpl = self.get_bond_template(particle_name1=particle_inst_1.name,
824                                          particle_name2=particle_inst_2.name,
825                                          use_default_bond=use_default_bond)
826        bond_inst = self._get_espresso_bond_instance(bond_template=bond_tpl,
827                                                    espresso_system=espresso_system)
828        espresso_system.part.by_id(particle_id1).add_bond((bond_inst, particle_id2))
829        bond_id = self.db._propose_instance_id(pmb_type="bond")
830        pmb_bond_instance = BondInstance(bond_id=bond_id,
831                                         name=bond_tpl.name,
832                                         particle_id1=particle_id1,
833                                         particle_id2=particle_id2)
834        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.
  • espresso_system ('espressomd.system.System'): ESPResSo system object where the bond will be created.
  • 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, espresso_system):
836    def create_counterions(self, object_name, cation_name, anion_name, espresso_system):
837        """
838        Creates particles of 'cation_name' and 'anion_name' in 'espresso_system' to counter the net charge of 'object_name'.
839        
840        Args:
841            object_name ('str'): 
842                'name' of a pyMBE object.
843
844            espresso_system ('espressomd.system.System'): 
845                Instance of a system object from the espressomd library.
846
847            cation_name ('str'): 
848                'name' of a particle with a positive charge.
849
850            anion_name ('str'): 
851                'name' of a particle with a negative charge.
852
853        Returns: 
854            ('dict'): 
855                {"name": number}
856
857        Notes:
858            This function currently does not support the creation of counterions for hydrogels.
859        """ 
860        cation_tpl = self.db.get_template(pmb_type="particle",
861                                          name=cation_name)
862        cation_state = self.db.get_template(pmb_type="particle_state",
863                                            name=cation_tpl.initial_state)
864        cation_charge = cation_state.z
865        anion_tpl = self.db.get_template(pmb_type="particle",
866                                          name=anion_name)
867        anion_state = self.db.get_template(pmb_type="particle_state",
868                                            name=anion_tpl.initial_state)
869        anion_charge = anion_state.z
870        object_ids = self.get_particle_id_map(object_name=object_name)["all"]
871        counterion_number={}
872        object_charge={}
873        for name in ['positive', 'negative']:
874            object_charge[name]=0
875        for id in object_ids:
876            if espresso_system.part.by_id(id).q > 0:
877                object_charge['positive']+=1*(np.abs(espresso_system.part.by_id(id).q ))
878            elif espresso_system.part.by_id(id).q < 0:
879                object_charge['negative']+=1*(np.abs(espresso_system.part.by_id(id).q ))
880        if object_charge['positive'] % abs(anion_charge) == 0:
881            counterion_number[anion_name]=int(object_charge['positive']/abs(anion_charge))
882        else:
883            raise ValueError('The number of positive charges in the pmb_object must be divisible by the  charge of the anion')
884        if object_charge['negative'] % abs(cation_charge) == 0:
885            counterion_number[cation_name]=int(object_charge['negative']/cation_charge)
886        else:
887            raise ValueError('The number of negative charges in the pmb_object must be divisible by the  charge of the cation')
888        if counterion_number[cation_name] > 0: 
889            self.create_particle(espresso_system=espresso_system, 
890                                 name=cation_name, 
891                                 number_of_particles=counterion_number[cation_name])
892        else:
893            counterion_number[cation_name]=0
894        if counterion_number[anion_name] > 0:
895            self.create_particle(espresso_system=espresso_system, 
896                                 name=anion_name, 
897                                 number_of_particles=counterion_number[anion_name])
898        else:
899            counterion_number[anion_name] = 0
900        logging.info('the following counter-ions have been created: ')
901        for name in counterion_number.keys():
902            logging.info(f'Ion type: {name} created number: {counterion_number[name]}')
903        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.
  • espresso_system ('espressomd.system.System'): Instance of a system object from the espressomd library.
  • cation_name ('str'): 'name' of a particle with a positive charge.
  • anion_name ('str'): 'name' of a particle with a negative charge.

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

Notes:

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

def create_hydrogel(self, name, espresso_system, use_default_bond=False):
905    def create_hydrogel(self, name, espresso_system, use_default_bond=False):
906        """ 
907        Creates a hydrogel in espresso_system using a pyMBE hydrogel template given by 'name'
908
909        Args:
910            name ('str'): 
911                name of the hydrogel template in the pyMBE database.
912
913            espresso_system ('espressomd.system.System'): 
914                ESPResSo system object where the hydrogel will be created.
915
916            use_default_bond ('bool', optional): 
917                If True, use a default bond template if no specific template exists. Defaults to False.
918
919        Returns:
920            ('int'): id of the hydrogel instance created.
921        """
922        if not self.db._has_template(name=name, pmb_type="hydrogel"):
923            raise ValueError(f"Hydrogel template with name '{name}' is not defined in the pyMBE database.")
924        hydrogel_tpl = self.db.get_template(pmb_type="hydrogel",
925                                            name=name)
926        assembly_id = self.db._propose_instance_id(pmb_type="hydrogel")
927        # Create the nodes
928        nodes = {}
929        node_topology = hydrogel_tpl.node_map
930        for node in node_topology:
931            node_index = node.lattice_index
932            node_name = node.particle_name
933            node_pos, node_id = self._create_hydrogel_node(node_index=node_index,
934                                                          node_name=node_name,
935                                                          espresso_system=espresso_system)
936            node_label = self.lattice_builder._create_node_label(node_index=node_index)
937            nodes[node_label] = {"name": node_name, "id": node_id, "pos": node_pos} 
938            self.db._update_instance(instance_id=node_id,
939                                     pmb_type="particle",
940                                     attribute="assembly_id",
941                                     value=assembly_id)
942        for hydrogel_chain in hydrogel_tpl.chain_map:
943            molecule_id = self._create_hydrogel_chain(hydrogel_chain=hydrogel_chain,
944                                                      nodes=nodes, 
945                                                      espresso_system=espresso_system,
946                                                      use_default_bond=use_default_bond)
947            self.db._update_instance(instance_id=molecule_id,
948                                     pmb_type="molecule",
949                                     attribute="assembly_id",
950                                     value=assembly_id)
951        self.db._propagate_id(root_type="hydrogel", 
952                                root_id=assembly_id, 
953                                attribute="assembly_id", 
954                                value=assembly_id)
955        # Register an hydrogel instance in the pyMBE databasegit 
956        self.db._register_instance(HydrogelInstance(name=name,
957                                                    assembly_id=assembly_id))
958        return assembly_id

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

Arguments:
  • name ('str'): name of the hydrogel template in the pyMBE database.
  • espresso_system ('espressomd.system.System'): ESPResSo system object where the hydrogel will be created.
  • use_default_bond ('bool', optional): If True, use a default bond template if no specific template exists. Defaults to False.
Returns:

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

def create_molecule( self, name, number_of_molecules, espresso_system, list_of_first_residue_positions=None, backbone_vector=None, use_default_bond=False, reverse_residue_order=False):
 960    def create_molecule(self, name, number_of_molecules, espresso_system, list_of_first_residue_positions=None, backbone_vector=None, use_default_bond=False, reverse_residue_order = False):
 961        """
 962        Creates instances of a given molecule template name into ESPResSo.
 963
 964        Args:
 965            name ('str'): 
 966                Label of the molecule type to be created. 'name'.
 967
 968            espresso_system ('espressomd.system.System'): 
 969                Instance of a system object from espressomd library.
 970
 971            number_of_molecules ('int'): 
 972                Number of molecules or peptides of type 'name' to be created.
 973
 974            list_of_first_residue_positions ('list', optional): 
 975                List of coordinates where the central bead of the first_residue_position will be created, random by default.
 976
 977            backbone_vector ('list' of 'float'): 
 978                Backbone vector of the molecule, random by default. Central beads of the residues in the 'residue_list' are placed along this vector. 
 979
 980            use_default_bond('bool', optional): 
 981                Controls if a bond of type 'default' is used to bond particles with undefined bonds in the pyMBE database.
 982
 983            reverse_residue_order('bool', optional): 
 984                Creates residues in reverse sequential order than the one defined in the molecule template. Defaults to False.
 985
 986        Returns:
 987            ('list' of 'int'): 
 988                List with the 'molecule_id' of the pyMBE molecule instances created into 'espresso_system'.
 989
 990        Notes:
 991            - This function can be used to create both molecules and peptides.    
 992        """
 993        pmb_type = self._get_template_type(name=name,
 994                                           allowed_types={"molecule", "peptide"})
 995        if number_of_molecules <= 0:
 996            return {}
 997        if list_of_first_residue_positions is not None:
 998            for item in list_of_first_residue_positions:
 999                if not isinstance(item, list):
1000                    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.")
1001                elif len(item) != 3:
1002                    raise ValueError("The provided input position is formatted wrong. The elements in the provided list does not have 3 coordinates, corresponding to xyz coord.")
1003
1004            if len(list_of_first_residue_positions) != number_of_molecules:
1005                raise ValueError(f"Number of positions provided in {list_of_first_residue_positions} does not match number of molecules desired, {number_of_molecules}")
1006        # Generate an arbitrary random unit vector
1007        if backbone_vector is None:
1008            backbone_vector = self.generate_random_points_in_a_sphere(center=[0,0,0],
1009                                                                      radius=1, 
1010                                                                      n_samples=1,
1011                                                                      on_surface=True)[0]
1012        else:
1013            backbone_vector = np.array(backbone_vector)
1014        first_residue = True
1015        molecule_tpl = self.db.get_template(pmb_type=pmb_type,
1016                                            name=name)
1017        if reverse_residue_order:
1018            residue_list = molecule_tpl.residue_list[::-1]
1019        else:
1020            residue_list = molecule_tpl.residue_list
1021        pos_index = 0 
1022        molecule_ids = []
1023        for _ in range(number_of_molecules):        
1024            molecule_id = self.db._propose_instance_id(pmb_type=pmb_type)
1025            for residue in residue_list:
1026                if first_residue:
1027                    if list_of_first_residue_positions is None:
1028                        central_bead_pos = None
1029                    else:
1030                        for item in list_of_first_residue_positions:
1031                            central_bead_pos = [np.array(list_of_first_residue_positions[pos_index])]
1032                            
1033                    residue_id = self.create_residue(name=residue,
1034                                                     espresso_system=espresso_system, 
1035                                                     central_bead_position=central_bead_pos,  
1036                                                     use_default_bond= use_default_bond, 
1037                                                     backbone_vector=backbone_vector)
1038                    
1039                    # Add molecule_id to the residue instance and all particles associated
1040                    self.db._propagate_id(root_type="residue", 
1041                                          root_id=residue_id,
1042                                          attribute="molecule_id", 
1043                                          value=molecule_id)
1044                    particle_ids_in_residue = self.db._find_instance_ids_by_attribute(pmb_type="particle",
1045                                                                                      attribute="residue_id",
1046                                                                                      value=residue_id)
1047                    prev_central_bead_id = particle_ids_in_residue[0]
1048                    prev_central_bead_name = self.db.get_instance(pmb_type="particle", 
1049                                                                  instance_id=prev_central_bead_id).name
1050                    prev_central_bead_pos = espresso_system.part.by_id(prev_central_bead_id).pos
1051                    first_residue = False          
1052                else:
1053                    
1054                    # Calculate the starting position of the new residue
1055                    residue_tpl = self.db.get_template(pmb_type="residue",
1056                                                       name=residue)
1057                    lj_parameters = self.get_lj_parameters(particle_name1=prev_central_bead_name,
1058                                                           particle_name2=residue_tpl.central_bead)
1059                    bond_tpl = self.get_bond_template(particle_name1=prev_central_bead_name,
1060                                                      particle_name2=residue_tpl.central_bead,
1061                                                      use_default_bond=use_default_bond)
1062                    l0 = hf.calculate_initial_bond_length(lj_parameters=lj_parameters,
1063                                                          bond_type=bond_tpl.bond_type,
1064                                                          bond_parameters=bond_tpl.get_parameters(ureg=self.units))
1065                    central_bead_pos = prev_central_bead_pos+backbone_vector*l0
1066                    # Create the residue
1067                    residue_id = self.create_residue(name=residue, 
1068                                                     espresso_system=espresso_system, 
1069                                                     central_bead_position=[central_bead_pos],
1070                                                     use_default_bond= use_default_bond, 
1071                                                     backbone_vector=backbone_vector)
1072                    # Add molecule_id to the residue instance and all particles associated
1073                    self.db._propagate_id(root_type="residue", 
1074                                          root_id=residue_id, 
1075                                          attribute="molecule_id", 
1076                                          value=molecule_id)
1077                    particle_ids_in_residue = self.db._find_instance_ids_by_attribute(pmb_type="particle",
1078                                                                                      attribute="residue_id",
1079                                                                                      value=residue_id)
1080                    central_bead_id = particle_ids_in_residue[0]
1081
1082                    # Bond the central beads of the new and previous residues
1083                    self.create_bond(particle_id1=prev_central_bead_id,
1084                                     particle_id2=central_bead_id,
1085                                     espresso_system=espresso_system,
1086                                     use_default_bond=use_default_bond)
1087                    
1088                    prev_central_bead_id = central_bead_id                    
1089                    prev_central_bead_name = self.db.get_instance(pmb_type="particle", instance_id=central_bead_id).name
1090                    prev_central_bead_pos =central_bead_pos
1091            # Create a Peptide or Molecule instance and register it on the pyMBE database
1092            if pmb_type == "molecule":
1093                inst = MoleculeInstance(molecule_id=molecule_id,
1094                                        name=name)
1095            elif pmb_type == "peptide":
1096                inst = PeptideInstance(name=name,
1097                                       molecule_id=molecule_id)
1098            self.db._register_instance(inst)
1099            first_residue = True
1100            pos_index+=1
1101            molecule_ids.append(molecule_id)
1102        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'.
  • espresso_system ('espressomd.system.System'): Instance of a system object from espressomd library.
  • 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, espresso_system, number_of_particles, position=None, fix=False):
1104    def create_particle(self, name, espresso_system, number_of_particles, position=None, fix=False):
1105        """
1106        Creates one or more particles in an ESPResSo system based on the particle template in the pyMBE database.
1107        
1108        Args:
1109            name ('str'): 
1110                Label of the particle template in the pyMBE database. 
1111
1112            espresso_system ('espressomd.system.System'): 
1113                Instance of a system object from the espressomd library.
1114
1115            number_of_particles ('int'): 
1116                Number of particles to be created.
1117
1118            position (list of ['float','float','float'], optional): 
1119                Initial positions of the particles. If not given, particles are created in random positions. Defaults to None.
1120
1121            fix ('bool', optional): 
1122                Controls if the particle motion is frozen in the integrator, it is used to create rigid objects. Defaults to False.
1123
1124        Returns:
1125            ('list' of 'int'): 
1126                List with the ids of the particles created into 'espresso_system'.
1127        """       
1128        if number_of_particles <=0:
1129            return []
1130        if not self.db._has_template(name=name, pmb_type="particle"):
1131            raise ValueError(f"Particle template with name '{name}' is not defined in the pyMBE database.")
1132        
1133        part_tpl = self.db.get_template(pmb_type="particle",
1134                                        name=name)
1135        part_state = self.db.get_template(pmb_type="particle_state",
1136                                         name=part_tpl.initial_state)
1137        z = part_state.z
1138        es_type = part_state.es_type
1139        # Create the new particles into  ESPResSo 
1140        created_pid_list=[]
1141        for index in range(number_of_particles):
1142            if position is None:
1143                particle_position = self.rng.random((1, 3))[0] *np.copy(espresso_system.box_l)
1144            else:
1145                particle_position = position[index]
1146            
1147            particle_id = self.db._propose_instance_id(pmb_type="particle")
1148            created_pid_list.append(particle_id)
1149            kwargs = dict(id=particle_id, pos=particle_position, type=es_type, q=z)
1150            if fix:
1151                kwargs["fix"] = 3 * [fix]
1152            espresso_system.part.add(**kwargs)
1153            part_inst = ParticleInstance(name=name,
1154                                         particle_id=particle_id,
1155                                         initial_state=part_state.name)
1156            self.db._register_instance(part_inst)
1157                              
1158        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.
  • espresso_system ('espressomd.system.System'): Instance of a system object from the espressomd library.
  • 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, espresso_system, topology_dict):
1160    def create_protein(self, name, number_of_proteins, espresso_system, topology_dict):
1161        """
1162        Creates one or more protein molecules in an ESPResSo system based on the 
1163        protein template in the pyMBE database and a provided topology.
1164
1165        Args:
1166            name (str):
1167                Name of the protein template stored in the pyMBE database.
1168            
1169            number_of_proteins (int):
1170                Number of protein molecules to generate.  
1171            
1172            espresso_system (espressomd.system.System):
1173                The ESPResSo simulation system where the protein molecules will be created.
1174            
1175            topology_dict (dict):
1176                Dictionary defining the internal structure of the protein. Expected format:
1177                    {"ResidueName1": {"initial_pos": np.ndarray,
1178                                      "chain_id": int,
1179                                      "radius": float},
1180                     "ResidueName2": { ... },
1181                        ...
1182                    }
1183                The '"initial_pos"' entry is required and represents the residue’s
1184                reference coordinates before shifting to the protein's center-of-mass.
1185
1186        Returns:
1187            ('list' of 'int'): 
1188                List of the molecule_id of the Protein instances created into ESPResSo.
1189
1190        Notes:
1191            - Particles are created using 'create_particle()' with 'fix=True',
1192            meaning they are initially immobilized.
1193            - The function assumes all residues in 'topology_dict' correspond to
1194            particle templates already defined in the pyMBE database.
1195            - Bonds between residues are not created here; it assumes a rigid body representation of the protein.
1196        """
1197        if number_of_proteins <= 0:
1198            return
1199        if not self.db._has_template(name=name, pmb_type="protein"):
1200            raise ValueError(f"Protein template with name '{name}' is not defined in the pyMBE database.")
1201        protein_tpl = self.db.get_template(pmb_type="protein", name=name)
1202        box_half = espresso_system.box_l[0] / 2.0
1203        # Create protein
1204        mol_ids = []
1205        for _ in range(number_of_proteins):
1206            # create a molecule identifier in pyMBE
1207            molecule_id = self.db._propose_instance_id(pmb_type="protein")
1208            # place protein COM randomly
1209            protein_center = self.generate_coordinates_outside_sphere(radius=1,
1210                                                                      max_dist=box_half,
1211                                                                      n_samples=1,
1212                                                                      center=[box_half]*3)[0]
1213            residues = hf.get_residues_from_topology_dict(topology_dict=topology_dict,
1214                                                         model=protein_tpl.model)
1215            # CREATE RESIDUES + PARTICLES
1216            for _, rdata in residues.items():
1217                base_resname = rdata["resname"]  
1218                residue_name = f"AA-{base_resname}"
1219                # residue instance ID
1220                residue_id = self.db._propose_instance_id("residue")
1221                # register ResidueInstance
1222                self.db._register_instance(ResidueInstance(name=residue_name,
1223                                                           residue_id=residue_id,
1224                                                           molecule_id=molecule_id))
1225                # PARTICLE CREATION
1226                for bead_id in rdata["beads"]:
1227                    bead_type = re.split(r'\d+', bead_id)[0]
1228                    relative_pos = topology_dict[bead_id]["initial_pos"]
1229                    absolute_pos = relative_pos + protein_center
1230                    particle_id = self.create_particle(name=bead_type,
1231                                                       espresso_system=espresso_system,
1232                                                       number_of_particles=1,
1233                                                       position=[absolute_pos],
1234                                                       fix=True)[0]
1235                    # update metadata
1236                    self.db._update_instance(instance_id=particle_id,
1237                                             pmb_type="particle",
1238                                             attribute="molecule_id",
1239                                             value=molecule_id)
1240                    self.db._update_instance(instance_id=particle_id,
1241                                             pmb_type="particle",
1242                                             attribute="residue_id",
1243                                             value=residue_id)
1244            protein_inst = ProteinInstance(name=name,
1245                                           molecule_id=molecule_id)
1246            self.db._register_instance(protein_inst)
1247            mol_ids.append(molecule_id)
1248        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.
  • espresso_system (espressomd.system.System): The ESPResSo simulation system where the protein molecules will be created.
  • 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, espresso_system, central_bead_position=None, use_default_bond=False, backbone_vector=None):
1250    def create_residue(self, name, espresso_system, central_bead_position=None,use_default_bond=False, backbone_vector=None):
1251        """
1252        Creates a residue  into ESPResSo.
1253
1254        Args:
1255            name ('str'): 
1256                Label of the residue type to be created. 
1257
1258            espresso_system ('espressomd.system.System'): 
1259                Instance of a system object from espressomd library.
1260
1261            central_bead_position ('list' of 'float'): 
1262                Position of the central bead.
1263
1264            use_default_bond ('bool'): 
1265                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.
1266
1267            backbone_vector ('list' of 'float'): 
1268                Backbone vector of the molecule. All side chains are created perpendicularly to 'backbone_vector'.
1269
1270        Returns:
1271            (int): 
1272                residue_id of the residue created.
1273        """
1274        if not self.db._has_template(name=name, pmb_type="residue"):
1275            raise ValueError(f"Residue template with name '{name}' is not defined in the pyMBE database.")
1276        res_tpl = self.db.get_template(pmb_type="residue",
1277                                       name=name)
1278        # Assign a residue_id
1279        residue_id = self.db._propose_instance_id(pmb_type="residue")
1280        res_inst = ResidueInstance(name=name,
1281                                   residue_id=residue_id)
1282        self.db._register_instance(res_inst)
1283        # create the principal bead   
1284        central_bead_name = res_tpl.central_bead 
1285        central_bead_id = self.create_particle(name=central_bead_name,
1286                                               espresso_system=espresso_system,
1287                                               position=central_bead_position,
1288                                               number_of_particles = 1)[0]
1289        
1290        central_bead_position=espresso_system.part.by_id(central_bead_id).pos
1291        # Assigns residue_id to the central_bead particle created.
1292        self.db._update_instance(pmb_type="particle",
1293                                 instance_id=central_bead_id,
1294                                 attribute="residue_id",
1295                                 value=residue_id)
1296        
1297        # create the lateral beads  
1298        side_chain_list = res_tpl.side_chains
1299        side_chain_beads_ids = []
1300        for side_chain_name in side_chain_list:
1301            pmb_type = self._get_template_type(name=side_chain_name,
1302                                               allowed_types={"particle", "residue"})
1303            if pmb_type == 'particle':
1304                lj_parameters = self.get_lj_parameters(particle_name1=central_bead_name,
1305                                                       particle_name2=side_chain_name)
1306                bond_tpl = self.get_bond_template(particle_name1=central_bead_name,
1307                                                  particle_name2=side_chain_name,
1308                                                  use_default_bond=use_default_bond)
1309                l0 = hf.calculate_initial_bond_length(lj_parameters=lj_parameters,
1310                                                      bond_type=bond_tpl.bond_type,
1311                                                      bond_parameters=bond_tpl.get_parameters(ureg=self.units))               
1312                if backbone_vector is None:
1313                    bead_position=self.generate_random_points_in_a_sphere(center=central_bead_position, 
1314                                                                radius=l0, 
1315                                                                n_samples=1,
1316                                                                on_surface=True)[0]
1317                else:
1318                    bead_position=central_bead_position+self.generate_trial_perpendicular_vector(vector=np.array(backbone_vector),
1319                                                                                                magnitude=l0)
1320                    
1321                side_bead_id = self.create_particle(name=side_chain_name, 
1322                                                    espresso_system=espresso_system,
1323                                                    position=[bead_position], 
1324                                                    number_of_particles=1)[0]
1325                side_chain_beads_ids.append(side_bead_id)
1326                self.db._update_instance(pmb_type="particle",
1327                                         instance_id=side_bead_id,
1328                                         attribute="residue_id",
1329                                         value=residue_id)
1330                self.create_bond(particle_id1=central_bead_id,
1331                                 particle_id2=side_bead_id,
1332                                 espresso_system=espresso_system,
1333                                 use_default_bond=use_default_bond)
1334            elif pmb_type == 'residue':
1335                side_residue_tpl = self.db.get_template(name=side_chain_name,
1336                                                        pmb_type=pmb_type)
1337                central_bead_side_chain = side_residue_tpl.central_bead
1338                lj_parameters = self.get_lj_parameters(particle_name1=central_bead_name,
1339                                                       particle_name2=central_bead_side_chain)
1340                bond_tpl = self.get_bond_template(particle_name1=central_bead_name,
1341                                                  particle_name2=central_bead_side_chain,
1342                                                  use_default_bond=use_default_bond)
1343                l0 = hf.calculate_initial_bond_length(lj_parameters=lj_parameters,
1344                                                      bond_type=bond_tpl.bond_type,
1345                                                      bond_parameters=bond_tpl.get_parameters(ureg=self.units))
1346                if backbone_vector is None:
1347                    residue_position=self.generate_random_points_in_a_sphere(center=central_bead_position, 
1348                                                                radius=l0, 
1349                                                                n_samples=1,
1350                                                                on_surface=True)[0]
1351                else:
1352                    residue_position=central_bead_position+self.generate_trial_perpendicular_vector(vector=backbone_vector,
1353                                                                                                    magnitude=l0)
1354                side_residue_id = self.create_residue(name=side_chain_name, 
1355                                                      espresso_system=espresso_system,
1356                                                      central_bead_position=[residue_position],
1357                                                      use_default_bond=use_default_bond)
1358                # Find particle ids of the inner residue
1359                side_chain_beads_ids = self.db._find_instance_ids_by_attribute(pmb_type="particle",
1360                                                                               attribute="residue_id",
1361                                                                               value=side_residue_id)
1362                # Change the residue_id of the residue in the side chain to the one of the outer residue
1363                for particle_id in side_chain_beads_ids:
1364                    self.db._update_instance(instance_id=particle_id,
1365                                             pmb_type="particle",
1366                                             attribute="residue_id",
1367                                             value=residue_id)
1368                # Remove the instance of the inner residue
1369                self.db.delete_instance(pmb_type="residue",
1370                                        instance_id=side_residue_id)
1371                self.create_bond(particle_id1=central_bead_id,
1372                                 particle_id2=side_chain_beads_ids[0],
1373                                 espresso_system=espresso_system,
1374                                 use_default_bond=use_default_bond)        
1375        return  residue_id  

Creates a residue into ESPResSo.

Arguments:
  • name ('str'): Label of the residue type to be created.
  • espresso_system ('espressomd.system.System'): Instance of a system object from espressomd library.
  • central_bead_position ('list' of 'float'): Position of the central bead.
  • 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):
1377    def define_bond(self, bond_type, bond_parameters, particle_pairs):
1378        """
1379        Defines bond templates for each particle pair in 'particle_pairs' in the pyMBE database.
1380
1381        Args:
1382            bond_type ('str'): 
1383                label to identify the potential to model the bond.
1384
1385            bond_parameters ('dict'): 
1386                parameters of the potential of the bond.
1387
1388            particle_pairs ('lst'): 
1389                list of the 'names' of the 'particles' to be bonded.
1390
1391        Notes:
1392            -Currently, only HARMONIC and FENE bonds are supported.
1393            - For a HARMONIC bond the dictionary must contain the following parameters:
1394                - k ('pint.Quantity')      : Magnitude of the bond. It should have units of energy/length**2 
1395                using the 'pmb.units' UnitRegistry.
1396                - r_0 ('pint.Quantity')    : Equilibrium bond length. It should have units of length using 
1397                the 'pmb.units' UnitRegistry.
1398           - For a FENE bond the dictionary must contain the same parameters as for a HARMONIC bond and:              
1399                - d_r_max ('pint.Quantity'): Maximal stretching length for FENE. It should have 
1400                units of length using the 'pmb.units' UnitRegistry. Default 'None'.
1401        """
1402        self._check_bond_inputs(bond_parameters=bond_parameters,
1403                                bond_type=bond_type)
1404        parameters_expected_dimensions={"r_0": "length",
1405                                        "k": "energy/length**2",
1406                                        "d_r_max": "length"}
1407
1408        parameters_tpl = {}
1409        for key in bond_parameters.keys():
1410            parameters_tpl[key]= PintQuantity.from_quantity(q=bond_parameters[key],
1411                                                            expected_dimension=parameters_expected_dimensions[key],
1412                                                            ureg=self.units)
1413
1414        bond_names=[]
1415        for particle_name1, particle_name2 in particle_pairs:
1416            
1417            tpl = BondTemplate(particle_name1=particle_name1,
1418                               particle_name2=particle_name2,
1419                               parameters=parameters_tpl,
1420                               bond_type=bond_type)
1421            tpl._make_name()
1422            if tpl.name in bond_names:
1423                raise RuntimeError(f"Bond {tpl.name} has already been defined, please check the list of particle pairs")
1424            bond_names.append(tpl.name)
1425            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):
1428    def define_default_bond(self, bond_type, bond_parameters):
1429        """
1430        Defines a bond template as a "default" template in the pyMBE database.
1431        
1432        Args:
1433            bond_type ('str'): 
1434                label to identify the potential to model the bond.
1435
1436            bond_parameters ('dict'): 
1437                parameters of the potential of the bond.
1438            
1439        Notes:
1440            - Currently, only harmonic and FENE bonds are supported. 
1441        """
1442        self._check_bond_inputs(bond_parameters=bond_parameters,
1443                                bond_type=bond_type)
1444        parameters_expected_dimensions={"r_0": "length",
1445                                        "k": "energy/length**2",
1446                                        "d_r_max": "length"}
1447        parameters_tpl = {}
1448        for key in bond_parameters.keys():
1449            parameters_tpl[key]= PintQuantity.from_quantity(q=bond_parameters[key],
1450                                                            expected_dimension=parameters_expected_dimensions[key],
1451                                                            ureg=self.units)
1452        tpl = BondTemplate(parameters=parameters_tpl,
1453                               bond_type=bond_type)
1454        tpl.name = "default"
1455        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_hydrogel(self, name, node_map, chain_map):
1457    def define_hydrogel(self, name, node_map, chain_map):
1458        """
1459        Defines a hydrogel template in the pyMBE database.
1460
1461        Args:
1462            name ('str'): 
1463                Unique label that identifies the 'hydrogel'.
1464
1465            node_map ('list of dict'): 
1466                [{"particle_name": , "lattice_index": }, ... ]
1467
1468            chain_map ('list of dict'): 
1469                [{"node_start": , "node_end": , "residue_list": , ... ]
1470        """
1471        # Sanity tests
1472        node_indices = {tuple(entry['lattice_index']) for entry in node_map}                
1473        chain_map_connectivity = set()
1474        for entry in chain_map:
1475            start = self.lattice_builder.node_labels[entry['node_start']]
1476            end = self.lattice_builder.node_labels[entry['node_end']]
1477            chain_map_connectivity.add((start,end))
1478        if self.lattice_builder.lattice.connectivity != chain_map_connectivity:
1479            raise ValueError("Incomplete hydrogel: A diamond lattice must contain correct 16 lattice index pairs")
1480        diamond_indices = {tuple(row) for row in self.lattice_builder.lattice.indices}
1481        if node_indices != diamond_indices:
1482            raise ValueError(f"Incomplete hydrogel: A diamond lattice must contain exactly 8 lattice indices, {diamond_indices} ")
1483        # Register information in the pyMBE database
1484        nodes=[]
1485        for entry in node_map:
1486            nodes.append(HydrogelNode(particle_name=entry["particle_name"],
1487                                      lattice_index=entry["lattice_index"]))
1488        chains=[]
1489        for chain in chain_map:
1490            chains.append(HydrogelChain(node_start=chain["node_start"],
1491                                        node_end=chain["node_end"],
1492                                        molecule_name=chain["molecule_name"]))
1493        tpl = HydrogelTemplate(name=name,
1494                               node_map=nodes,
1495                               chain_map=chains)
1496        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):
1498    def define_molecule(self, name, residue_list):
1499        """
1500        Defines a molecule template in the pyMBE database.
1501
1502        Args:
1503            name('str'): 
1504                Unique label that identifies the 'molecule'.
1505
1506            residue_list ('list' of 'str'): 
1507                List of the 'name's of the 'residue's  in the sequence of the 'molecule'.  
1508        """
1509        tpl = MoleculeTemplate(name=name,
1510                               residue_list=residue_list)
1511        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):
1513    def define_monoprototic_acidbase_reaction(self, particle_name, pka, acidity, metadata=None):
1514        """
1515        Defines an acid-base reaction for a monoprototic particle in the pyMBE database.
1516
1517        Args:
1518            particle_name ('str'): 
1519                Unique label that identifies the particle template. 
1520
1521            pka ('float'): 
1522                pka-value of the acid or base.
1523
1524            acidity ('str'): 
1525                Identifies whether if the particle is 'acidic' or 'basic'.
1526
1527            metadata ('dict', optional): 
1528                Additional information to be stored in the reaction. Defaults to None.
1529        """
1530        supported_acidities = ["acidic", "basic"]
1531        if acidity not in supported_acidities:
1532            raise ValueError(f"Unsupported acidity '{acidity}' for particle '{particle_name}'. Supported acidities are {supported_acidities}.")
1533        reaction_type = "monoprotic"
1534        if acidity == "basic":
1535            reaction_type += "_base"
1536        else:
1537            reaction_type += "_acid"
1538        reaction = Reaction(participants=[ReactionParticipant(particle_name=particle_name,
1539                                                              state_name=f"{particle_name}H", 
1540                                                              coefficient=-1),
1541                                          ReactionParticipant(particle_name=particle_name,
1542                                                              state_name=f"{particle_name}",
1543                                                              coefficient=1)],
1544                            reaction_type=reaction_type,
1545                            pK=pka,
1546                            metadata=metadata)
1547        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):
1549    def define_monoprototic_particle_states(self, particle_name, acidity):
1550        """
1551        Defines particle states for a monoprotonic particle template including the charges in each of its possible states. 
1552
1553        Args:
1554            particle_name ('str'): 
1555                Unique label that identifies the particle template. 
1556
1557            acidity ('str'): 
1558                Identifies whether the particle is 'acidic' or 'basic'.
1559        """
1560        acidity_valid_keys = ['acidic', 'basic']
1561        if not pd.isna(acidity):
1562            if acidity not in acidity_valid_keys:
1563                raise ValueError(f"Acidity {acidity} provided for particle name  {particle_name} is not supported. Valid keys are: {acidity_valid_keys}")
1564        if acidity == "acidic":
1565            states = [{"name": f"{particle_name}H", "z": 0}, 
1566                      {"name": f"{particle_name}",  "z": -1}]
1567            
1568        elif acidity == "basic":
1569            states = [{"name": f"{particle_name}H", "z": 1}, 
1570                      {"name": f"{particle_name}",  "z": 0}]
1571        self.define_particle_states(particle_name=particle_name, 
1572                                    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>):
1574    def define_particle(self, name,  sigma, epsilon, z=0, acidity=pd.NA, pka=pd.NA, cutoff=pd.NA, offset=pd.NA):
1575        """
1576        Defines a particle template in the pyMBE database.
1577
1578        Args:
1579            name('str'):
1580                 Unique label that identifies this particle type.  
1581
1582            sigma('pint.Quantity'): 
1583                Sigma parameter used to set up Lennard-Jones interactions for this particle type. 
1584
1585            epsilon('pint.Quantity'): 
1586                Epsilon parameter used to setup Lennard-Jones interactions for this particle tipe.
1587
1588            z('int', optional): 
1589                Permanent charge number of this particle type. Defaults to 0.
1590
1591            acidity('str', optional): 
1592                Identifies whether if the particle is 'acidic' or 'basic', used to setup constant pH simulations. Defaults to pd.NA.
1593
1594            pka('float', optional):
1595                If 'particle' is an acid or a base, it defines its  pka-value. Defaults to pd.NA.
1596
1597            cutoff('pint.Quantity', optional): 
1598                Cutoff parameter used to set up Lennard-Jones interactions for this particle type. Defaults to pd.NA.
1599
1600            offset('pint.Quantity', optional): 
1601                Offset parameter used to set up Lennard-Jones interactions for this particle type. Defaults to pd.NA.
1602            
1603        Notes:
1604            - 'sigma', 'cutoff' and 'offset' must have a dimensitonality of '[length]' and should be defined using pmb.units.
1605            - 'epsilon' must have a dimensitonality of '[energy]' and should be defined using pmb.units.
1606            - 'cutoff' defaults to '2**(1./6.) reduced_length'. 
1607            - 'offset' defaults to 0.
1608            - For more information on 'sigma', 'epsilon', 'cutoff' and 'offset' check 'pmb.setup_lj_interactions()'.
1609        """ 
1610        # If 'cutoff' and 'offset' are not defined, default them to the following values
1611        if pd.isna(cutoff):
1612            cutoff=self.units.Quantity(2**(1./6.), "reduced_length")
1613        if pd.isna(offset):
1614            offset=self.units.Quantity(0, "reduced_length")
1615        # Define particle states
1616        if acidity is pd.NA:
1617            states = [{"name": f"{name}",  "z": z}]
1618            self.define_particle_states(particle_name=name, 
1619                                        states=states)
1620            initial_state = name
1621        else:
1622            self.define_monoprototic_particle_states(particle_name=name,
1623                                                  acidity=acidity)
1624            initial_state = f"{name}H"
1625            if pka is not pd.NA:
1626                self.define_monoprototic_acidbase_reaction(particle_name=name,
1627                                                           acidity=acidity,
1628                                                           pka=pka)
1629        tpl = ParticleTemplate(name=name, 
1630                               sigma=PintQuantity.from_quantity(q=sigma, expected_dimension="length", ureg=self.units), 
1631                               epsilon=PintQuantity.from_quantity(q=epsilon, expected_dimension="energy", ureg=self.units),
1632                               cutoff=PintQuantity.from_quantity(q=cutoff, expected_dimension="length", ureg=self.units), 
1633                               offset=PintQuantity.from_quantity(q=offset, expected_dimension="length", ureg=self.units),
1634                               initial_state=initial_state)
1635        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):
1637    def define_particle_states(self, particle_name, states):
1638        """
1639        Define the chemical states of an existing particle template.
1640
1641        Args:
1642            particle_name ('str'):
1643                Name of a particle template. 
1644
1645            states ('list' of 'dict'):
1646                List of dictionaries defining the particle states. Each dictionary
1647                must contain:
1648                - 'name' ('str'): Name of the particle state (e.g. '"H"', '"-"',
1649                '"neutral"').
1650                - 'z' ('int'): Charge number of the particle in this state.
1651                Example:
1652                states = [{"name": "AH", "z": 0},     # protonated
1653                         {"name": "A-", "z": -1}]    # deprotonated
1654        Notes:
1655            - Each state is assigned a unique Espresso 'es_type' automatically.
1656            - Chemical reactions (e.g. acid–base equilibria) are **not** created by
1657            this method and must be defined separately (e.g. via
1658            'set_particle_acidity()' or custom reaction definitions).
1659            - Particles without explicitly defined states are assumed to have a
1660            single, implicit state with their default charge.
1661        """
1662        for s in states:
1663            state = ParticleStateTemplate(particle_name=particle_name,
1664                                          name=s["name"],
1665                                          z=s["z"],
1666                                          es_type=self.propose_unused_type())
1667            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):
1669    def define_peptide(self, name, sequence, model):
1670        """
1671        Defines a peptide template in the pyMBE database.
1672
1673        Args:
1674            name ('str'): 
1675                Unique label that identifies the peptide.
1676
1677            sequence ('str'): 
1678                Sequence of the peptide.
1679
1680            model ('str'): 
1681                Model name. Currently only models with 1 bead '1beadAA' or with 2 beads '2beadAA' per amino acid are supported.
1682        """
1683        valid_keys = ['1beadAA','2beadAA']
1684        if model not in valid_keys:
1685            raise ValueError('Invalid label for the peptide model, please choose between 1beadAA or 2beadAA')
1686        clean_sequence = hf.protein_sequence_parser(sequence=sequence)    
1687        residue_list = self._get_residue_list_from_sequence(sequence=clean_sequence)
1688        tpl = PeptideTemplate(name=name,
1689                            residue_list=residue_list,
1690                            model=model,
1691                            sequence=sequence)
1692        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):
1694    def define_protein(self, name, sequence, model):
1695        """
1696        Defines a protein template in the pyMBE database.
1697
1698        Args:
1699            name ('str'): 
1700                Unique label that identifies the protein.
1701
1702            sequence ('str'): 
1703                Sequence of the protein.
1704
1705            model ('string'): 
1706                Model name. Currently only models with 1 bead '1beadAA' or with 2 beads '2beadAA' per amino acid are supported.
1707
1708        Notes:
1709            - Currently, only 'lj_setup_mode="wca"' is supported. This corresponds to setting up the WCA potential.
1710        """
1711        valid_model_keys = ['1beadAA','2beadAA']
1712        if model not in valid_model_keys:
1713            raise ValueError('Invalid key for the protein model, supported models are {valid_model_keys}')
1714        
1715        residue_list = self._get_residue_list_from_sequence(sequence=sequence)
1716        tpl = ProteinTemplate(name=name,
1717                              model=model,
1718                              residue_list=residue_list,
1719                              sequence=sequence)
1720        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):
1722    def define_residue(self, name, central_bead, side_chains):
1723        """
1724        Defines a residue template in the pyMBE database.
1725
1726        Args:
1727            name ('str'): 
1728                Unique label that identifies the residue.
1729
1730            central_bead ('str'): 
1731                'name' of the 'particle' to be placed as central_bead of the residue.
1732
1733            side_chains('list' of 'str'): 
1734                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.
1735        """
1736        tpl = ResidueTemplate(name=name,
1737                              central_bead=central_bead,
1738                              side_chains=side_chains)
1739        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, espresso_system):
1741    def delete_instances_in_system(self, instance_id, pmb_type, espresso_system):
1742        """
1743        Deletes the instance with instance_id from the ESPResSo system. 
1744        Related assembly, molecule, residue, particles and bond instances will also be deleted from the pyMBE dataframe.
1745
1746        Args:
1747            instance_id ('int'): 
1748                id of the assembly to be deleted. 
1749
1750            pmb_type ('str'): 
1751                the instance type to be deleted. 
1752
1753            espresso_system ('espressomd.system.System'): 
1754                Instance of a system class from espressomd library.
1755        """
1756        if pmb_type == "particle":
1757            instance_identifier = "particle_id"
1758        elif pmb_type == "residue":
1759            instance_identifier = "residue_id"
1760        elif pmb_type in self.db._molecule_like_types:
1761            instance_identifier = "molecule_id"
1762        elif pmb_type in self.db._assembly_like_types:
1763            instance_identifier = "assembly_id"
1764        particle_ids = self.db._find_instance_ids_by_attribute(pmb_type="particle",
1765                                                               attribute=instance_identifier,
1766                                                               value=instance_id)
1767        self._delete_particles_from_espresso(particle_ids=particle_ids,
1768                                             espresso_system=espresso_system)
1769        self.db.delete_instance(pmb_type=pmb_type,
1770                                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):
1772    def determine_reservoir_concentrations(self, pH_res, c_salt_res, activity_coefficient_monovalent_pair, max_number_sc_runs=200):
1773        """
1774        Determines ionic concentrations in the reservoir at fixed pH and salt concentration.
1775
1776        Args:
1777            pH_res ('float'):
1778                Target pH value in the reservoir.
1779
1780            c_salt_res ('pint.Quantity'):
1781                Concentration of monovalent salt (e.g., NaCl) in the reservoir.
1782
1783            activity_coefficient_monovalent_pair ('callable'):
1784                Function returning the activity coefficient of a monovalent ion pair
1785                as a function of ionic strength:
1786                'gamma = activity_coefficient_monovalent_pair(I)'.
1787
1788            max_number_sc_runs ('int', optional):
1789                Maximum number of self-consistent iterations allowed before
1790                convergence is enforced. Defaults to 200.
1791
1792        Returns:
1793            tuple:
1794                (cH_res, cOH_res, cNa_res, cCl_res)
1795                - cH_res ('pint.Quantity'): Concentration of H⁺ ions.
1796                - cOH_res ('pint.Quantity'): Concentration of OH⁻ ions.
1797                - cNa_res ('pint.Quantity'): Concentration of Na⁺ ions.
1798                - cCl_res ('pint.Quantity'): Concentration of Cl⁻ ions.
1799
1800        Notess:
1801            - The algorithm enforces electroneutrality in the reservoir.
1802            - Water autodissociation is included via the equilibrium constant 'Kw'.
1803            - Non-ideal effects enter through activity coefficients depending on
1804            ionic strength.
1805            - The implementation follows the self-consistent scheme described in
1806            Landsgesell (PhD thesis, Sec. 5.3, doi:10.18419/opus-10831), adapted
1807            from the original code (doi:10.18419/darus-2237).
1808        """
1809        def determine_reservoir_concentrations_selfconsistently(cH_res, c_salt_res):
1810            """
1811            Iteratively determines reservoir ion concentrations self-consistently.
1812
1813            Args:
1814                cH_res ('pint.Quantity'):
1815                    Current estimate of the H⁺ concentration.
1816                c_salt_res ('pint.Quantity'):
1817                    Concentration of monovalent salt in the reservoir.
1818
1819            Returns:
1820                'tuple':
1821                    (cH_res, cOH_res, cNa_res, cCl_res)
1822            """
1823            # Initial ideal estimate
1824            cOH_res = self.Kw / cH_res
1825            if cOH_res >= cH_res:
1826                cNa_res = c_salt_res + (cOH_res - cH_res)
1827                cCl_res = c_salt_res
1828            else:
1829                cCl_res = c_salt_res + (cH_res - cOH_res)
1830                cNa_res = c_salt_res
1831            # Self-consistent iteration
1832            for _ in range(max_number_sc_runs):
1833                ionic_strength_res = 0.5 * (cNa_res + cCl_res + cOH_res + cH_res)
1834                cOH_new = self.Kw / (cH_res * activity_coefficient_monovalent_pair(ionic_strength_res))
1835                if cOH_new >= cH_res:
1836                    cNa_new = c_salt_res + (cOH_new - cH_res)
1837                    cCl_new = c_salt_res
1838                else:
1839                    cCl_new = c_salt_res + (cH_res - cOH_new)
1840                    cNa_new = c_salt_res
1841                # Update values
1842                cOH_res = cOH_new
1843                cNa_res = cNa_new
1844                cCl_res = cCl_new
1845            return cH_res, cOH_res, cNa_res, cCl_res
1846        # Initial guess for H+ concentration from target pH
1847        cH_res = 10 ** (-pH_res) * self.units.mol / self.units.l
1848        # First self-consistent solve
1849        cH_res, cOH_res, cNa_res, cCl_res = (determine_reservoir_concentrations_selfconsistently(cH_res, 
1850                                                                                                 c_salt_res))
1851        ionic_strength_res = 0.5 * (cNa_res + cCl_res + cOH_res + cH_res)
1852        determined_pH = -np.log10(cH_res.to("mol/L").magnitude* np.sqrt(activity_coefficient_monovalent_pair(ionic_strength_res)))
1853        # Outer loop to enforce target pH
1854        while abs(determined_pH - pH_res) > 1e-6:
1855            if determined_pH > pH_res:
1856                cH_res *= 1.005
1857            else:
1858                cH_res /= 1.003
1859            cH_res, cOH_res, cNa_res, cCl_res = (determine_reservoir_concentrations_selfconsistently(cH_res, 
1860                                                                                                     c_salt_res))
1861            ionic_strength_res = 0.5 * (cNa_res + cCl_res + cOH_res + cH_res)
1862            determined_pH = -np.log10(cH_res.to("mol/L").magnitude * np.sqrt(activity_coefficient_monovalent_pair(ionic_strength_res)))
1863        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, espresso_system):
1865    def enable_motion_of_rigid_object(self, instance_id, pmb_type, espresso_system):
1866        """
1867        Enables translational and rotational motion of a rigid pyMBE object instance
1868        in an ESPResSo system.This method creates a rigid-body center particle at the center of mass of
1869        the specified pyMBE object and attaches all constituent particles to it
1870        using ESPResSo virtual sites. The resulting rigid object can translate and
1871        rotate as a single body.
1872
1873        Args:
1874            instance_id ('int'):
1875                Instance ID of the pyMBE object whose rigid-body motion is enabled.
1876
1877            pmb_type ('str'):
1878                pyMBE object type of the instance (e.g. '"molecule"', '"peptide"',
1879                '"protein"', or any assembly-like type).
1880
1881            espresso_system ('espressomd.system.System'):
1882                ESPResSo system in which the rigid object is defined.
1883
1884        Notess:
1885            - This method requires ESPResSo to be compiled with the following
1886            features enabled:
1887                - '"VIRTUAL_SITES_RELATIVE"'
1888                - '"MASS"'
1889            - A new ESPResSo particle is created to represent the rigid-body center.
1890            - The mass of the rigid-body center is set to the number of particles
1891            belonging to the object.
1892            - The rotational inertia tensor is approximated from the squared
1893            distances of the particles to the center of mass.
1894        """
1895        logging.info('enable_motion_of_rigid_object requires that espressomd has the following features activated: ["VIRTUAL_SITES_RELATIVE", "MASS"]')
1896        inst = self.db.get_instance(pmb_type=pmb_type,
1897                                    instance_id=instance_id)
1898        label = self._get_label_id_map(pmb_type=pmb_type)
1899        particle_ids_list = self.get_particle_id_map(object_name=inst.name)[label][instance_id]
1900        center_of_mass = self.calculate_center_of_mass (instance_id=instance_id,
1901                                                        espresso_system=espresso_system,
1902                                                        pmb_type=pmb_type)
1903        rigid_object_center = espresso_system.part.add(pos=center_of_mass,
1904                                                        rotation=[True,True,True], 
1905                                                        type=self.propose_unused_type())
1906        rigid_object_center.mass = len(particle_ids_list)
1907        momI = 0
1908        for pid in particle_ids_list:
1909            momI += np.power(np.linalg.norm(center_of_mass - espresso_system.part.by_id(pid).pos), 2)
1910        rigid_object_center.rinertia = np.ones(3) * momI        
1911        for particle_id in particle_ids_list:
1912            pid = espresso_system.part.by_id(particle_id)
1913            pid.vs_auto_relate_to(rigid_object_center.id)

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).
  • espresso_system ('espressomd.system.System'): ESPResSo system in which the rigid object is defined.
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):
1915    def generate_coordinates_outside_sphere(self, center, radius, max_dist, n_samples):
1916        """
1917        Generates random coordinates outside a sphere and inside a larger bounding sphere.
1918
1919        Args:
1920            center ('array-like'):
1921                Coordinates of the center of the spheres.
1922
1923            radius ('float'):
1924                Radius of the inner exclusion sphere. Must be positive.
1925
1926            max_dist ('float'):
1927                Radius of the outer sampling sphere. Must be larger than 'radius'.
1928
1929            n_samples ('int'):
1930                Number of coordinates to generate.
1931
1932        Returns:
1933            'list' of 'numpy.ndarray':
1934                List of coordinates lying outside the inner sphere and inside the
1935                outer sphere.
1936
1937        Notess:
1938            - Points are uniformly sampled inside a sphere of radius 'max_dist' centered at 'center' 
1939            and only those with a distance greater than or equal to 'radius' from the center are retained.
1940        """
1941        if not radius > 0: 
1942            raise ValueError (f'The value of {radius} must be a positive value')
1943        if not radius < max_dist:
1944            raise ValueError(f'The min_dist ({radius} must be lower than the max_dist ({max_dist}))')
1945        coord_list = []
1946        counter = 0
1947        while counter<n_samples:
1948            coord = self.generate_random_points_in_a_sphere(center=center, 
1949                                            radius=max_dist,
1950                                            n_samples=1)[0]
1951            if np.linalg.norm(coord-np.asarray(center))>=radius:
1952                coord_list.append (coord)
1953                counter += 1
1954        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):
1956    def generate_random_points_in_a_sphere(self, center, radius, n_samples, on_surface=False):
1957        """
1958        Generates uniformly distributed random points inside or on the surface of a sphere.
1959
1960        Args:
1961            center ('array-like'):
1962                Coordinates of the center of the sphere.
1963
1964            radius ('float'):
1965                Radius of the sphere.
1966
1967            n_samples ('int'):
1968                Number of sample points to generate.
1969
1970            on_surface ('bool', optional):
1971                If True, points are uniformly sampled on the surface of the sphere.
1972                If False, points are uniformly sampled within the sphere volume.
1973                Defaults to False.
1974
1975        Returns:
1976            'numpy.ndarray':
1977                Array of shape '(n_samples, d)' containing the generated coordinates,
1978                where 'd' is the dimensionality of 'center'.
1979        Notes:
1980            - Points are sampled in a space whose dimensionality is inferred 
1981            from the length of 'center'.
1982        """
1983        # initial values
1984        center=np.array(center)
1985        d = center.shape[0]
1986        # sample n_samples points in d dimensions from a standard normal distribution
1987        samples = self.rng.normal(size=(n_samples, d))
1988        # make the samples lie on the surface of the unit hypersphere
1989        normalize_radii = np.linalg.norm(samples, axis=1)[:, np.newaxis]
1990        samples /= normalize_radii
1991        if not on_surface:
1992            # make the samples lie inside the hypersphere with the correct density
1993            uniform_points = self.rng.uniform(size=n_samples)[:, np.newaxis]
1994            new_radii = np.power(uniform_points, 1/d)
1995            samples *= new_radii
1996        # scale the points to have the correct radius and center
1997        samples = samples * radius + center
1998        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):
2000    def generate_trial_perpendicular_vector(self,vector,magnitude):
2001        """
2002        Generates a random vector perpendicular to a given vector.
2003
2004        Args:
2005            vector ('array-like'):
2006                Reference vector to which the generated vector will be perpendicular.
2007
2008            magnitude ('float'):
2009                Desired magnitude of the perpendicular vector.
2010
2011        Returns:
2012            'numpy.ndarray':
2013                Vector orthogonal to 'vector' with norm equal to 'magnitude'.
2014        """ 
2015        np_vec = np.array(vector) 
2016        if np.all(np_vec == 0):
2017            raise ValueError('Zero vector')
2018        np_vec /= np.linalg.norm(np_vec) 
2019        # Generate a random vector 
2020        random_vector = self.generate_random_points_in_a_sphere(radius=1, 
2021                                                                center=[0,0,0],
2022                                                                n_samples=1, 
2023                                                                on_surface=True)[0]
2024        # Project the random vector onto the input vector and subtract the projection
2025        projection = np.dot(random_vector, np_vec) * np_vec
2026        perpendicular_vector = random_vector - projection
2027        # Normalize the perpendicular vector to have the same magnitude as the input vector
2028        perpendicular_vector /= np.linalg.norm(perpendicular_vector) 
2029        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):
2031    def get_bond_template(self, particle_name1, particle_name2, use_default_bond=False) :
2032        """
2033        Retrieves a bond template connecting two particle templates.
2034
2035        Args:
2036            particle_name1 ('str'):
2037                Name of the first particle template.
2038
2039            particle_name2 ('str'):
2040                Name of the second particle template.
2041
2042            use_default_bond ('bool', optional):
2043                If True, returns the default bond template when no specific bond
2044                template is found. Defaults to False.
2045
2046        Returns:
2047            'BondTemplate':
2048                Bond template object retrieved from the pyMBE database.
2049            
2050        Notes:
2051            - This method searches the pyMBE database for a bond template defined between particle templates with names 'particle_name1' and 'particle_name2'. 
2052            - If no specific bond template is found and 'use_default_bond' is enabled, a default bond template is returned instead.
2053        """
2054        # Try to find a specific bond template
2055        bond_key = BondTemplate.make_bond_key(pn1=particle_name1,
2056                                              pn2=particle_name2)
2057        try:
2058            return self.db.get_template(name=bond_key, 
2059                                        pmb_type="bond")
2060        except ValueError:
2061            pass
2062
2063        #  Fallback to default bond if allowed
2064        if use_default_bond:
2065            return self.db.get_template(name="default", 
2066                                        pmb_type="bond")
2067
2068        # No bond template found
2069        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):
2071    def get_charge_number_map(self):
2072        """
2073        Construct a mapping from ESPResSo particle types to their charge numbers.
2074
2075        Returns:
2076            'dict[int, float]':
2077                Dictionary mapping ESPResSo particle types to charge numbers,
2078                ''{es_type: z}''.
2079
2080        Notess:
2081            - The mapping is built from particle *states*, not instances.
2082            - If multiple templates define states with the same ''es_type'',
2083            the last encountered definition will overwrite previous ones.
2084            This behavior is intentional and assumes database consistency.
2085            - Neutral particles (''z = 0'') are included in the map.
2086        """
2087        charge_number_map = {}
2088        particle_templates = self.db.get_templates("particle")
2089        for tpl in particle_templates.values():
2090            for state in self.db.get_particle_states_templates(particle_name=tpl.name).values():
2091                charge_number_map[state.es_type] = state.z
2092        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):
2094    def get_instances_df(self, pmb_type):
2095        """
2096        Returns a dataframe with all instances of type 'pmb_type' in the pyMBE database.
2097
2098        Args:
2099            pmb_type ('str'): 
2100                pmb type to search instances in the pyMBE database.
2101        
2102        Returns:
2103            ('Pandas.Dataframe'): 
2104                Dataframe with all instances of type 'pmb_type'.
2105        """
2106        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'):
2108    def get_lj_parameters(self, particle_name1, particle_name2, combining_rule='Lorentz-Berthelot'):
2109        """
2110        Returns the Lennard-Jones parameters for the interaction between the particle types given by 
2111        'particle_name1' and 'particle_name2' in the pyMBE database, calculated according to the provided combining rule.
2112
2113        Args:
2114            particle_name1 ('str'): 
2115                label of the type of the first particle type
2116
2117            particle_name2 ('str'): 
2118                label of the type of the second particle type
2119
2120            combining_rule ('string', optional): 
2121                combining rule used to calculate 'sigma' and 'epsilon' for the potential betwen a pair of particles. Defaults to 'Lorentz-Berthelot'.
2122
2123        Returns:
2124            ('dict'):
2125                {"epsilon": epsilon_value, "sigma": sigma_value, "offset": offset_value, "cutoff": cutoff_value}
2126
2127        Notes:
2128            - Currently, the only 'combining_rule' supported is Lorentz-Berthelot.
2129            - 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.
2130        """
2131        supported_combining_rules=["Lorentz-Berthelot"]
2132        if combining_rule not in supported_combining_rules:
2133            raise ValueError(f"Combining_rule {combining_rule} currently not implemented in pyMBE, valid keys are {supported_combining_rules}")
2134        part_tpl1 = self.db.get_template(name=particle_name1,
2135                                         pmb_type="particle")
2136        part_tpl2 = self.db.get_template(name=particle_name2,
2137                                         pmb_type="particle")
2138        lj_parameters1 = part_tpl1.get_lj_parameters(ureg=self.units)
2139        lj_parameters2 = part_tpl2.get_lj_parameters(ureg=self.units)
2140
2141        # If one of the particle has sigma=0, no LJ interations are set up between that particle type and the others    
2142        if part_tpl1.sigma.magnitude == 0 or part_tpl2.sigma.magnitude == 0:
2143            return {}
2144        # Apply combining rule
2145        if combining_rule == 'Lorentz-Berthelot':
2146            sigma=(lj_parameters1["sigma"]+lj_parameters2["sigma"])/2
2147            cutoff=(lj_parameters1["cutoff"]+lj_parameters2["cutoff"])/2
2148            offset=(lj_parameters1["offset"]+lj_parameters2["offset"])/2
2149            epsilon=np.sqrt(lj_parameters1["epsilon"]*lj_parameters2["epsilon"])
2150        return {"sigma": sigma, "cutoff": cutoff, "offset": offset, "epsilon": epsilon}    

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):
2152    def get_particle_id_map(self, object_name):
2153        """
2154        Collect all particle IDs associated with an object of given name in the
2155        pyMBE database. 
2156
2157        Args:
2158            object_name ('str'): 
2159                Name of the object.
2160
2161        Returns:
2162            ('dict'): 
2163                {"all": [particle_ids],
2164                 "residue_map": {residue_id: [particle_ids]},
2165                 "molecule_map": {molecule_id: [particle_ids]},
2166                 "assembly_map": {assembly_id: [particle_ids]},}
2167
2168        Notess:
2169            - Works for all supported pyMBE templates.
2170            - Relies in the internal method Manager.get_particle_id_map, see method for the detailed code.
2171        """
2172        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):
2174    def get_pka_set(self):
2175        """
2176        Retrieve the pKa set for all titratable particles in the pyMBE database.
2177
2178        Returns:
2179            ('dict'): 
2180                Dictionary of the form:
2181                {"particle_name": {"pka_value": float,
2182                                   "acidity": "acidic" | "basic"}}
2183        Notes:
2184            - If a particle participates in multiple acid/base reactions, an error is raised.
2185        """
2186        pka_set = {}
2187        supported_reactions = ["monoprotic_acid",
2188                               "monoprotic_base"]
2189        for reaction in self.db._reactions.values():
2190            if reaction.reaction_type not in supported_reactions:
2191                continue
2192            # Identify involved particle(s)
2193            particle_names = {participant.particle_name for participant in reaction.participants}
2194            particle_name = particle_names.pop()
2195            if particle_name in pka_set:
2196                raise ValueError(f"Multiple acid/base reactions found for particle '{particle_name}'.")
2197            pka_set[particle_name] = {"pka_value": reaction.pK}
2198            if reaction.reaction_type == "monoprotic_acid":
2199                acidity = "acidic"
2200            elif reaction.reaction_type == "monoprotic_base":
2201                acidity = "basic"
2202            pka_set[particle_name]["acidity"] = acidity
2203        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):
2205    def get_radius_map(self, dimensionless=True):
2206        """
2207        Gets the effective radius of each particle defined in the pyMBE database. 
2208
2209        Args:
2210            dimensionless ('bool'):
2211                If ``True``, return magnitudes expressed in ``reduced_length``.
2212                If ``False``, return Pint quantities with units.
2213        
2214        Returns:
2215            ('dict'): 
2216                {espresso_type: radius}.
2217
2218        Notes:
2219            - The radius corresponds to (sigma+offset)/2
2220        """
2221        if "particle" not in self.db._templates:
2222            return {}          
2223        result = {}
2224        for _, tpl in self.db._templates["particle"].items():
2225            radius = (tpl.sigma.to_quantity(self.units) + tpl.offset.to_quantity(self.units))/2.0
2226            if dimensionless:
2227                magnitude_reduced_length = radius.m_as("reduced_length")
2228                radius = magnitude_reduced_length
2229            for state in self.db.get_particle_states_templates(particle_name=tpl.name).values():
2230                result[state.es_type] = radius
2231        return result

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):
2233    def get_reactions_df(self):
2234        """
2235        Returns a dataframe with all reaction templates in the pyMBE database.
2236
2237        Returns:
2238            (Pandas.Dataframe): 
2239                Dataframe with all  reaction templates.
2240        """
2241        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):
2243    def get_reduced_units(self):
2244        """
2245        Returns the  current set of reduced units defined in pyMBE.
2246
2247        Returns:
2248            reduced_units_text ('str'): 
2249                text with information about the current set of reduced units.
2250
2251        """
2252        unit_length=self.units.Quantity(1,'reduced_length')
2253        unit_energy=self.units.Quantity(1,'reduced_energy')
2254        unit_charge=self.units.Quantity(1,'reduced_charge')
2255        reduced_units_text = "\n".join(["Current set of reduced units:",
2256                                       f"{unit_length.to('nm'):.5g} = {unit_length}",
2257                                       f"{unit_energy.to('J'):.5g} = {unit_energy}",
2258                                       f"{unit_charge.to('C'):.5g} = {unit_charge}",
2259                                       f"Temperature: {(self.kT/self.kB).to('K'):.5g}"
2260                                        ])   
2261        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):
2263    def get_templates_df(self, pmb_type):
2264        """
2265        Returns a dataframe with all templates of type 'pmb_type' in the pyMBE database.
2266
2267        Args:
2268            pmb_type ('str'): 
2269                pmb type to search templates in the pyMBE database.
2270        
2271        Returns:
2272            ('Pandas.Dataframe'): 
2273                Dataframe with all templates of type given by 'pmb_type'.
2274        """
2275        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):
2277    def get_type_map(self):
2278        """
2279        Return the mapping of ESPResSo types for all particle states defined in the pyMBE database.
2280        
2281        Returns:
2282            'dict[str, int]':
2283                A dictionary mapping each particle state to its corresponding ESPResSo type:
2284                {state_name: es_type, ...}
2285        """
2286        
2287        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):
2289    def initialize_lattice_builder(self, diamond_lattice):
2290        """
2291        Initialize the lattice builder with the DiamondLattice object.
2292
2293        Args:
2294            diamond_lattice ('DiamondLattice'): 
2295                DiamondLattice object from the 'lib/lattice' module to be used in the LatticeBuilder.
2296        """
2297        from .lib.lattice import LatticeBuilder, DiamondLattice
2298        if not isinstance(diamond_lattice, DiamondLattice):
2299            raise TypeError("Currently only DiamondLattice objects are supported.")
2300        self.lattice_builder = LatticeBuilder(lattice=diamond_lattice)
2301        logging.info(f"LatticeBuilder initialized with mpc={diamond_lattice.mpc} and box_l={diamond_lattice.box_l}")
2302        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'):
2304    def load_database(self, folder, format='csv'):
2305        """
2306        Loads a pyMBE database stored in 'folder'.
2307
2308        Args:
2309            folder ('str' or 'Path'): 
2310                Path to the folder where the pyMBE database was stored.
2311
2312            format ('str', optional): 
2313                Format of the database to be loaded. Defaults to 'csv'.
2314
2315        Return:
2316            ('dict'): 
2317                metadata with additional information about the source of the information in the database.
2318
2319        Notes:
2320            - The folder must contain the files generated by 'pmb.save_database()'.
2321            - Currently, only 'csv' format is supported.
2322        """
2323        supported_formats = ['csv']
2324        if format not in supported_formats:
2325            raise ValueError(f"Format {format} not supported. Supported formats are {supported_formats}")
2326        if format == 'csv':
2327            metadata =io._load_database_csv(self.db, 
2328                                            folder=folder)
2329        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):
2332    def load_pka_set(self, filename):
2333        """
2334        Load a pKa set and attach chemical states and acid–base reactions
2335        to existing particle templates.
2336
2337        Args:
2338            filename ('str'): 
2339                Path to a JSON file containing the pKa set. Expected format:
2340                {"metadata": {...},
2341                  "data": {"A": {"acidity": "acidic", "pka_value": 4.5},
2342                           "B": {"acidity": "basic",  "pka_value": 9.8}}}
2343
2344        Returns:
2345            ('dict'): 
2346                Dictionary with bibliographic metadata about the original work were the pKa set was determined.
2347
2348        Notes:
2349            - This method is designed for monoprotic acids and bases only.
2350        """
2351        with open(filename, "r") as f:
2352            pka_data = json.load(f)
2353        pka_set = pka_data["data"]
2354        metadata = pka_data.get("metadata", {})
2355        self._check_pka_set(pka_set)
2356        for particle_name, entry in pka_set.items():
2357            acidity = entry["acidity"]
2358            pka = entry["pka_value"]
2359            self.define_monoprototic_acidbase_reaction(particle_name=particle_name,
2360                                                       pka=pka,
2361                                                       acidity=acidity,
2362                                                       metadata=metadata)
2363        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):
2365    def propose_unused_type(self):
2366        """
2367        Propose an unused ESPResSo particle type.
2368
2369        Returns:
2370            ('int'): 
2371                The next available integer ESPResSo type. Returns ''0'' if no integer types are currently defined.
2372        """
2373        type_map = self.get_type_map()
2374        # Flatten all es_type values across all particles and states
2375        all_types = []
2376        for es_type in type_map.values():
2377            all_types.append(es_type)
2378        # If no es_types exist, start at 0
2379        if not all_types:
2380            return 0
2381        return max(all_types) + 1

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):
2383    def read_protein_vtf(self, filename, unit_length=None):
2384        """
2385        Loads a coarse-grained protein model from a VTF file.
2386
2387        Args:
2388            filename ('str'): 
2389                Path to the VTF file.
2390                
2391            unit_length ('Pint.Quantity'): 
2392                Unit of length for coordinates (pyMBE UnitRegistry). Defaults to Angstrom.
2393
2394        Returns:
2395            ('tuple'):
2396                ('dict'): Particle topology.    
2397                ('str'):  One-letter amino-acid sequence (including n/c ends).
2398        """
2399        logging.info(f"Loading protein coarse-grain model file: {filename}")
2400        if unit_length is None:
2401            unit_length = 1 * self.units.angstrom
2402        atoms = {}        # atom_id -> atom info
2403        coords = []       # ordered coordinates
2404        residues = {}     # resid -> resname (first occurrence)
2405        has_n_term = False
2406        has_c_term = False
2407        aa_3to1 = {"ALA": "A", "ARG": "R", "ASN": "N", "ASP": "D",
2408                   "CYS": "C", "GLU": "E", "GLN": "Q", "GLY": "G",
2409                   "HIS": "H", "ILE": "I", "LEU": "L", "LYS": "K",
2410                   "MET": "M", "PHE": "F", "PRO": "P", "SER": "S",
2411                   "THR": "T", "TRP": "W", "TYR": "Y", "VAL": "V",
2412                   "n": "n", "c": "c"}
2413        # --- parse VTF ---
2414        with open(filename, "r") as f:
2415            for line in f:
2416                fields = line.split()
2417                if not fields:
2418                    continue
2419                if fields[0] == "atom":
2420                    atom_id = int(fields[1])
2421                    atom_name = fields[3]
2422                    resname = fields[5]
2423                    resid = int(fields[7])
2424                    chain_id = fields[9]
2425                    radius = float(fields[11]) * unit_length
2426                    atoms[atom_id] = {"name": atom_name,
2427                                     "resname": resname,
2428                                     "resid": resid,
2429                                     "chain_id": chain_id,
2430                                     "radius": radius}
2431                    if resname == "n":
2432                        has_n_term = True
2433                    elif resname == "c":
2434                        has_c_term = True
2435                    # register residue 
2436                    if resid not in residues:
2437                        residues[resid] = resname
2438                elif fields[0].isnumeric():
2439                    xyz = [(float(x) * unit_length).to("reduced_length").magnitude
2440                        for x in fields[1:4]]
2441                    coords.append(xyz)
2442        sequence = ""
2443        # N-terminus
2444        if has_n_term:
2445            sequence += "n"
2446        # protein residues only
2447        protein_resids = sorted(resid for resid, resname in residues.items()  if resname not in ("n", "c", "Ca"))
2448        for resid in protein_resids:
2449            resname = residues[resid]
2450            try:
2451                sequence += aa_3to1[resname]
2452            except KeyError:
2453                raise ValueError(f"Unknown residue name '{resname}' in VTF file")
2454        # C-terminus
2455        if has_c_term:
2456            sequence += "c"
2457        last_resid = max(protein_resids)
2458        # --- build topology ---
2459        topology_dict = {}
2460        for atom_id in sorted(atoms.keys()):
2461            atom = atoms[atom_id]
2462            resname = atom["resname"]
2463            resid = atom["resid"]
2464            # apply labeling rules
2465            if resname == "n":
2466                label_resid = 0
2467            elif resname == "c":
2468                label_resid = last_resid + 1
2469            elif resname == "Ca":
2470                label_resid = last_resid + 2
2471            else:
2472                label_resid = resid  # preserve original resid 
2473            label = f"{atom['name']}{label_resid}"
2474            if label in topology_dict:
2475                raise ValueError(f"Duplicate particle label '{label}'. Check VTF residue definitions.")
2476            topology_dict[label] = {"initial_pos": coords[atom_id - 1], "chain_id": atom["chain_id"], "radius": atom["radius"],}
2477        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'):
2480    def save_database(self, folder, format='csv'):
2481        """
2482        Saves the current pyMBE database into a file 'filename'.
2483
2484        Args:
2485            folder ('str' or 'Path'): 
2486                Path to the folder where the database files will be saved.
2487
2488        """
2489        supported_formats = ['csv']
2490        if format not in supported_formats:
2491            raise ValueError(f"Format {format} not supported. Supported formats are: {supported_formats}")
2492        if format == 'csv':
2493            io._save_database_csv(self.db, 
2494                                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):
2496    def set_particle_initial_state(self, particle_name, state_name):
2497        """
2498        Sets the default initial state of a particle template defined in the pyMBE database.
2499
2500        Args:
2501            particle_name ('str'): 
2502                Unique label that identifies the particle template. 
2503
2504            state_name ('str'): 
2505                Name of the state to be set as default initial state.
2506        """
2507        part_tpl = self.db.get_template(name=particle_name,
2508        
2509                                        pmb_type="particle")
2510        part_tpl.initial_state = state_name
2511        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):
2513    def set_reduced_units(self, unit_length=None, unit_charge=None, temperature=None, Kw=None):
2514        """
2515        Sets the set of reduced units used by pyMBE.units and it prints it.
2516
2517        Args:
2518            unit_length ('pint.Quantity', optional): 
2519                Reduced unit of length defined using the 'pmb.units' UnitRegistry. Defaults to None. 
2520
2521            unit_charge ('pint.Quantity', optional): 
2522                Reduced unit of charge defined using the 'pmb.units' UnitRegistry. Defaults to None. 
2523
2524            temperature ('pint.Quantity', optional): 
2525                Temperature of the system, defined using the 'pmb.units' UnitRegistry. Defaults to None. 
2526
2527            Kw ('pint.Quantity', optional): 
2528                Ionic product of water in mol^2/l^2. Defaults to None. 
2529
2530        Notes:
2531            - If no 'temperature' is given, a value of 298.15 K is assumed by default.
2532            - If no 'unit_length' is given, a value of 0.355 nm is assumed by default.
2533            - If no 'unit_charge' is given, a value of 1 elementary charge is assumed by default. 
2534            - If no 'Kw' is given, a value of 10^(-14) * mol^2 / l^2 is assumed by default. 
2535        """
2536        if unit_length is None:
2537            unit_length= 0.355*self.units.nm
2538        if temperature is None:
2539            temperature = 298.15 * self.units.K
2540        if unit_charge is None:
2541            unit_charge = scipy.constants.e * self.units.C
2542        if Kw is None:
2543            Kw = 1e-14
2544        # Sanity check
2545        variables=[unit_length,temperature,unit_charge]
2546        dimensionalities=["[length]","[temperature]","[charge]"]
2547        for variable,dimensionality in zip(variables,dimensionalities):
2548            self._check_dimensionality(variable,dimensionality)
2549        self.Kw=Kw*self.units.mol**2 / (self.units.l**2)
2550        self.kT=temperature*self.kB
2551        self.units._build_cache()
2552        self.units.define(f'reduced_energy = {self.kT} ')
2553        self.units.define(f'reduced_length = {unit_length}')
2554        self.units.define(f'reduced_charge = {unit_charge}')
2555        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 setup_cpH( self, counter_ion, constant_pH, exclusion_range=None, use_exclusion_radius_per_type=False):
2557    def setup_cpH (self, counter_ion, constant_pH, exclusion_range=None, use_exclusion_radius_per_type = False):
2558        """
2559        Sets up the Acid/Base reactions for acidic/basic particles defined in the pyMBE database
2560        to be sampled in the constant pH ensemble. 
2561
2562        Args:
2563            counter_ion ('str'): 
2564                'name' of the counter_ion 'particle'.
2565
2566            constant_pH ('float'): 
2567                pH-value.
2568
2569            exclusion_range ('pint.Quantity', optional): 
2570                Below this value, no particles will be inserted.
2571
2572            use_exclusion_radius_per_type ('bool', optional): 
2573                Controls if one exclusion_radius for each espresso_type is used. Defaults to 'False'.
2574
2575        Returns:
2576            ('reaction_methods.ConstantpHEnsemble'): 
2577                Instance of a reaction_methods.ConstantpHEnsemble object from the espressomd library.
2578        """
2579        from espressomd import reaction_methods
2580        if exclusion_range is None:
2581            exclusion_range = max(self.get_radius_map().values())*2.0
2582        if use_exclusion_radius_per_type:
2583            exclusion_radius_per_type = self.get_radius_map()
2584        else:
2585            exclusion_radius_per_type = {}
2586        RE = reaction_methods.ConstantpHEnsemble(kT=self.kT.to('reduced_energy').magnitude,
2587                                                exclusion_range=exclusion_range, 
2588                                                seed=self.seed, 
2589                                                constant_pH=constant_pH,
2590                                                exclusion_radius_per_type = exclusion_radius_per_type)
2591        conterion_tpl = self.db.get_template(name=counter_ion,
2592                                             pmb_type="particle")
2593        conterion_state = self.db.get_template(name=conterion_tpl.initial_state,
2594                                               pmb_type="particle_state")
2595        for reaction in self.db.get_reactions():
2596            if reaction.reaction_type not in ["monoprotic_acid", "monoprotic_base"]:
2597                continue
2598            default_charges = {}
2599            reactant_types  = []
2600            product_types   = []
2601            for participant in reaction.participants:
2602                state_tpl = self.db.get_template(name=participant.state_name,
2603                                                 pmb_type="particle_state")
2604                default_charges[state_tpl.es_type] = state_tpl.z
2605                if participant.coefficient < 0:
2606                    reactant_types.append(state_tpl.es_type)
2607                elif participant.coefficient > 0:
2608                    product_types.append(state_tpl.es_type)
2609            # Add counterion to the products
2610            if conterion_state.es_type not in product_types:
2611                product_types.append(conterion_state.es_type)
2612                default_charges[conterion_state.es_type] = conterion_state.z
2613                reaction.add_participant(particle_name=counter_ion,
2614                                         state_name=conterion_tpl.initial_state,
2615                                         coefficient=1)
2616            gamma=10**-reaction.pK
2617            RE.add_reaction(gamma=gamma,
2618                            reactant_types=reactant_types,
2619                            product_types=product_types,
2620                            default_charges=default_charges)
2621            reaction.add_simulation_method(simulation_method="cpH")
2622        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):
2624    def setup_gcmc(self, c_salt_res, salt_cation_name, salt_anion_name, activity_coefficient, exclusion_range=None, use_exclusion_radius_per_type = False):
2625        """
2626        Sets up grand-canonical coupling to a reservoir of salt.
2627        For reactive systems coupled to a reservoir, the grand-reaction method has to be used instead.
2628
2629        Args:
2630            c_salt_res ('pint.Quantity'): 
2631                Concentration of monovalent salt (e.g. NaCl) in the reservoir.
2632
2633            salt_cation_name ('str'): 
2634                Name of the salt cation (e.g. Na+) particle.
2635
2636            salt_anion_name ('str'): 
2637                Name of the salt anion (e.g. Cl-) particle.
2638
2639            activity_coefficient ('callable'): 
2640                A function that calculates the activity coefficient of an ion pair as a function of the ionic strength.
2641
2642            exclusion_range('pint.Quantity', optional): 
2643                For distances shorter than this value, no particles will be inserted.
2644
2645            use_exclusion_radius_per_type('bool',optional): 
2646                Controls if one exclusion_radius for each espresso_type is used. Defaults to 'False'.
2647
2648        Returns:
2649            ('reaction_methods.ReactionEnsemble'): 
2650                Instance of a reaction_methods.ReactionEnsemble object from the espressomd library.
2651        """
2652        from espressomd import reaction_methods
2653        if exclusion_range is None:
2654            exclusion_range = max(self.get_radius_map().values())*2.0
2655        if use_exclusion_radius_per_type:
2656            exclusion_radius_per_type = self.get_radius_map()
2657        else:
2658            exclusion_radius_per_type = {}
2659        RE = reaction_methods.ReactionEnsemble(kT=self.kT.to('reduced_energy').magnitude,
2660                                               exclusion_range=exclusion_range, 
2661                                               seed=self.seed, 
2662                                               exclusion_radius_per_type = exclusion_radius_per_type)
2663        # Determine the concentrations of the various species in the reservoir and the equilibrium constants
2664        determined_activity_coefficient = activity_coefficient(c_salt_res)
2665        K_salt = (c_salt_res.to('1/(N_A * reduced_length**3)')**2) * determined_activity_coefficient
2666        cation_tpl = self.db.get_template(pmb_type="particle",
2667                                          name=salt_cation_name)
2668        cation_state = self.db.get_template(pmb_type="particle_state",
2669                                            name=cation_tpl.initial_state)
2670        anion_tpl = self.db.get_template(pmb_type="particle",
2671                                          name=salt_anion_name)
2672        anion_state = self.db.get_template(pmb_type="particle_state",
2673                                            name=anion_tpl.initial_state)
2674        salt_cation_es_type = cation_state.es_type
2675        salt_anion_es_type = anion_state.es_type     
2676        salt_cation_charge = cation_state.z
2677        salt_anion_charge = anion_state.z
2678        if salt_cation_charge <= 0:
2679            raise ValueError('ERROR salt cation charge must be positive, charge ', salt_cation_charge)
2680        if salt_anion_charge >= 0:
2681            raise ValueError('ERROR salt anion charge must be negative, charge ', salt_anion_charge)
2682        # Grand-canonical coupling to the reservoir
2683        RE.add_reaction(gamma = K_salt.magnitude,
2684                        reactant_types = [],
2685                        reactant_coefficients = [],
2686                        product_types = [ salt_cation_es_type, salt_anion_es_type ],
2687                        product_coefficients = [ 1, 1 ],
2688                        default_charges = {salt_cation_es_type: salt_cation_charge, 
2689                                           salt_anion_es_type: salt_anion_charge})
2690        rx_tpl = Reaction(participants=[ReactionParticipant(particle_name=salt_cation_name,
2691                                                            state_name=cation_state.name,
2692                                                            coefficient=1),
2693                                        ReactionParticipant(particle_name=salt_anion_name,
2694                                                            state_name=anion_state.name,
2695                                                            coefficient=1)],
2696                           pK=-np.log10(K_salt.magnitude),
2697                           reaction_type="ion_insertion",
2698                           simulation_method="GCMC")
2699        self.db._register_reaction(rx_tpl)
2700        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):
2702    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):
2703        """
2704        Sets up acid/base reactions for acidic/basic monoprotic particles defined in the pyMBE database, 
2705        as well as a grand-canonical coupling to a reservoir of small ions. 
2706        
2707        Args:
2708            pH_res ('float'): 
2709                pH-value in the reservoir.
2710
2711            c_salt_res ('pint.Quantity'): 
2712                Concentration of monovalent salt (e.g. NaCl) in the reservoir.
2713
2714            proton_name ('str'): 
2715                Name of the proton (H+) particle.
2716
2717            hydroxide_name ('str'): 
2718                Name of the hydroxide (OH-) particle.
2719
2720            salt_cation_name ('str'): 
2721                Name of the salt cation (e.g. Na+) particle.
2722
2723            salt_anion_name ('str'): 
2724                Name of the salt anion (e.g. Cl-) particle.
2725
2726            activity_coefficient ('callable'): 
2727                A function that calculates the activity coefficient of an ion pair as a function of the ionic strength.
2728
2729            exclusion_range('pint.Quantity', optional): 
2730                For distances shorter than this value, no particles will be inserted.
2731
2732            use_exclusion_radius_per_type('bool', optional): 
2733                Controls if one exclusion_radius for each espresso_type is used. Defaults to 'False'.
2734
2735        Returns:
2736            'tuple(reaction_methods.ReactionEnsemble,pint.Quantity)':
2737
2738                'reaction_methods.ReactionEnsemble':  
2739                    espressomd reaction_methods object with all reactions necesary to run the GRxMC ensamble.
2740                
2741                'pint.Quantity': 
2742                    Ionic strength of the reservoir (useful for calculating partition coefficients).
2743
2744        Notess:
2745            - This implementation uses the original formulation of the grand-reaction method by Landsgesell et al. [1].
2746
2747        [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.
2748        """
2749        from espressomd import reaction_methods
2750        if exclusion_range is None:
2751            exclusion_range = max(self.get_radius_map().values())*2.0
2752        if use_exclusion_radius_per_type:
2753            exclusion_radius_per_type = self.get_radius_map()
2754        else:
2755            exclusion_radius_per_type = {}
2756        RE = reaction_methods.ReactionEnsemble(kT=self.kT.to('reduced_energy').magnitude,
2757                                               exclusion_range=exclusion_range, 
2758                                               seed=self.seed, 
2759                                               exclusion_radius_per_type = exclusion_radius_per_type)
2760        # Determine the concentrations of the various species in the reservoir and the equilibrium constants
2761        cH_res, cOH_res, cNa_res, cCl_res = self.determine_reservoir_concentrations(pH_res, c_salt_res, activity_coefficient)
2762        ionic_strength_res = 0.5*(cNa_res+cCl_res+cOH_res+cH_res)
2763        determined_activity_coefficient = activity_coefficient(ionic_strength_res)
2764        K_W = cH_res.to('1/(N_A * reduced_length**3)') * cOH_res.to('1/(N_A * reduced_length**3)') * determined_activity_coefficient
2765        K_NACL = cNa_res.to('1/(N_A * reduced_length**3)') * cCl_res.to('1/(N_A * reduced_length**3)') * determined_activity_coefficient
2766        K_HCL = cH_res.to('1/(N_A * reduced_length**3)') * cCl_res.to('1/(N_A * reduced_length**3)') * determined_activity_coefficient
2767        cation_tpl = self.db.get_template(pmb_type="particle",
2768                                          name=salt_cation_name)
2769        cation_state = self.db.get_template(pmb_type="particle_state",
2770                                            name=cation_tpl.initial_state)
2771        anion_tpl = self.db.get_template(pmb_type="particle",
2772                                          name=salt_anion_name)
2773        anion_state = self.db.get_template(pmb_type="particle_state",
2774                                            name=anion_tpl.initial_state)
2775        proton_tpl = self.db.get_template(pmb_type="particle",
2776                                          name=proton_name)
2777        proton_state = self.db.get_template(pmb_type="particle_state",
2778                                            name=proton_tpl.initial_state)
2779        hydroxide_tpl = self.db.get_template(pmb_type="particle",
2780                                             name=hydroxide_name)
2781        hydroxide_state = self.db.get_template(pmb_type="particle_state",
2782                                               name=hydroxide_tpl.initial_state)
2783        proton_es_type = proton_state.es_type
2784        hydroxide_es_type = hydroxide_state.es_type
2785        salt_cation_es_type = cation_state.es_type
2786        salt_anion_es_type = anion_state.es_type
2787        proton_charge = proton_state.z
2788        hydroxide_charge = hydroxide_state.z          
2789        salt_cation_charge = cation_state.z
2790        salt_anion_charge = anion_state.z      
2791        if proton_charge <= 0:
2792            raise ValueError('ERROR proton charge must be positive, charge ', proton_charge)
2793        if salt_cation_charge <= 0:
2794            raise ValueError('ERROR salt cation charge must be positive, charge ', salt_cation_charge)
2795        if hydroxide_charge >= 0:
2796            raise ValueError('ERROR hydroxide charge must be negative, charge ', hydroxide_charge)
2797        if salt_anion_charge >= 0:
2798            raise ValueError('ERROR salt anion charge must be negative, charge ', salt_anion_charge)
2799        # Grand-canonical coupling to the reservoir
2800        # 0 = H+ + OH-
2801        RE.add_reaction(gamma = K_W.magnitude,
2802                        reactant_types = [],
2803                        reactant_coefficients = [],
2804                        product_types = [ proton_es_type, hydroxide_es_type ],
2805                        product_coefficients = [ 1, 1 ],
2806                        default_charges = {proton_es_type: proton_charge, 
2807                                           hydroxide_es_type: hydroxide_charge})
2808        rx_tpl = Reaction(participants=[ReactionParticipant(particle_name=proton_name,
2809                                                            state_name=proton_state.name,
2810                                                            coefficient=1),
2811                                        ReactionParticipant(particle_name=hydroxide_name,
2812                                                            state_name=hydroxide_state.name,
2813                                                            coefficient=1)],
2814                           pK=-np.log10(K_W.magnitude),
2815                           reaction_type="ion_insertion",
2816                           simulation_method="GRxMC")
2817        self.db._register_reaction(rx_tpl)
2818        # 0 = Na+ + Cl-
2819        RE.add_reaction(gamma = K_NACL.magnitude,
2820                        reactant_types = [],
2821                        reactant_coefficients = [],
2822                        product_types = [ salt_cation_es_type, salt_anion_es_type ],
2823                        product_coefficients = [ 1, 1 ],
2824                        default_charges = {salt_cation_es_type: salt_cation_charge, 
2825                                        salt_anion_es_type: salt_anion_charge})
2826        rx_tpl = Reaction(participants=[ReactionParticipant(particle_name=salt_cation_name,
2827                                                            state_name=cation_state.name,
2828                                                            coefficient=1),
2829                                        ReactionParticipant(particle_name=salt_anion_name,
2830                                                            state_name=anion_state.name,
2831                                                            coefficient=1)],
2832                           pK=-np.log10(K_NACL.magnitude),
2833                           reaction_type="ion_insertion",
2834                           simulation_method="GRxMC")
2835        self.db._register_reaction(rx_tpl)
2836        # 0 = Na+ + OH-
2837        RE.add_reaction(gamma = (K_NACL * K_W / K_HCL).magnitude,
2838                        reactant_types = [],
2839                        reactant_coefficients = [],
2840                        product_types = [ salt_cation_es_type, hydroxide_es_type ],
2841                        product_coefficients = [ 1, 1 ],
2842                        default_charges = {salt_cation_es_type: salt_cation_charge, 
2843                                           hydroxide_es_type: hydroxide_charge})
2844        rx_tpl = Reaction(participants=[ReactionParticipant(particle_name=salt_cation_name,
2845                                                            state_name=cation_state.name,
2846                                                            coefficient=1),
2847                                        ReactionParticipant(particle_name=hydroxide_name,
2848                                                            state_name=hydroxide_state.name,
2849                                                            coefficient=1)],
2850                           pK=-np.log10((K_NACL * K_W / K_HCL).magnitude),
2851                           reaction_type="ion_insertion",
2852                           simulation_method="GRxMC")
2853        self.db._register_reaction(rx_tpl)
2854        # 0 = H+ + Cl-
2855        RE.add_reaction(gamma = K_HCL.magnitude,
2856                        reactant_types = [],
2857                        reactant_coefficients = [],
2858                        product_types = [ proton_es_type, salt_anion_es_type ],
2859                        product_coefficients = [ 1, 1 ],
2860                        default_charges = {proton_es_type: proton_charge, 
2861                                           salt_anion_es_type: salt_anion_charge})
2862        rx_tpl = Reaction(participants=[ReactionParticipant(particle_name=proton_name,
2863                                                            state_name=proton_state.name,
2864                                                            coefficient=1),
2865                                        ReactionParticipant(particle_name=salt_anion_name,
2866                                                            state_name=anion_state.name,
2867                                                            coefficient=1)],
2868                           pK=-np.log10(K_HCL.magnitude),
2869                           reaction_type="ion_insertion",
2870                           simulation_method="GRxMC")
2871        self.db._register_reaction(rx_tpl)
2872        # Annealing moves to ensure sufficient sampling
2873        # Cation annealing H+ = Na+
2874        RE.add_reaction(gamma = (K_NACL / K_HCL).magnitude,
2875                        reactant_types = [proton_es_type],
2876                        reactant_coefficients = [ 1 ],
2877                        product_types = [ salt_cation_es_type ],
2878                        product_coefficients = [ 1 ],
2879                        default_charges = {proton_es_type: proton_charge, 
2880                                           salt_cation_es_type: salt_cation_charge})
2881        rx_tpl = Reaction(participants=[ReactionParticipant(particle_name=proton_name,
2882                                                            state_name=proton_state.name,
2883                                                            coefficient=-1),
2884                                        ReactionParticipant(particle_name=salt_cation_name,
2885                                                            state_name=cation_state.name,
2886                                                            coefficient=1)],
2887                           pK=-np.log10((K_NACL / K_HCL).magnitude),
2888                           reaction_type="particle replacement",
2889                           simulation_method="GRxMC")
2890        self.db._register_reaction(rx_tpl)
2891        # Anion annealing OH- = Cl- 
2892        RE.add_reaction(gamma = (K_HCL / K_W).magnitude,
2893                        reactant_types = [hydroxide_es_type],
2894                        reactant_coefficients = [ 1 ],
2895                        product_types = [ salt_anion_es_type ],
2896                        product_coefficients = [ 1 ],
2897            default_charges = {hydroxide_es_type: hydroxide_charge, 
2898                               salt_anion_es_type: salt_anion_charge})
2899        rx_tpl = Reaction(participants=[ReactionParticipant(particle_name=hydroxide_name,
2900                                                            state_name=hydroxide_state.name,
2901                                                            coefficient=-1),
2902                                        ReactionParticipant(particle_name=salt_anion_name,
2903                                                            state_name=anion_state.name,
2904                                                            coefficient=1)],
2905                           pK=-np.log10((K_HCL / K_W).magnitude),
2906                           reaction_type="particle replacement",
2907                           simulation_method="GRxMC")
2908        self.db._register_reaction(rx_tpl)
2909        for reaction in self.db.get_reactions():
2910            if reaction.reaction_type not in ["monoprotic_acid", "monoprotic_base"]:
2911                continue
2912            default_charges = {}
2913            reactant_types  = []
2914            product_types   = []
2915            for participant in reaction.participants:
2916                state_tpl = self.db.get_template(name=participant.state_name,
2917                                                 pmb_type="particle_state")
2918                default_charges[state_tpl.es_type] = state_tpl.z
2919                if participant.coefficient < 0:
2920                    reactant_types.append(state_tpl.es_type)
2921                    reactant_name=state_tpl.particle_name
2922                    reactant_state_name=state_tpl.name
2923                elif participant.coefficient > 0:
2924                    product_types.append(state_tpl.es_type)
2925                    product_name=state_tpl.particle_name
2926                    product_state_name=state_tpl.name
2927
2928            Ka = (10**-reaction.pK * self.units.mol/self.units.l).to('1/(N_A * reduced_length**3)')
2929            # Reaction in terms of proton: HA = A + H+
2930            RE.add_reaction(gamma=Ka.magnitude,
2931                            reactant_types=reactant_types,
2932                            reactant_coefficients=[1],
2933                            product_types=product_types+[proton_es_type],
2934                            product_coefficients=[1, 1],
2935                            default_charges= default_charges | {proton_es_type: proton_charge})
2936            reaction.add_participant(particle_name=proton_name,
2937                                     state_name=proton_state.name,
2938                                     coefficient=1)
2939            reaction.add_simulation_method("GRxMC")
2940            # Reaction in terms of salt cation: HA = A + Na+
2941            RE.add_reaction(gamma=(Ka * K_NACL / K_HCL).magnitude,
2942                            reactant_types=reactant_types,
2943                            reactant_coefficients=[1],
2944                            product_types=product_types+[salt_cation_es_type],
2945                            product_coefficients=[1, 1],
2946                            default_charges=default_charges | {salt_cation_es_type: salt_cation_charge})
2947            rx_tpl = Reaction(participants=[ReactionParticipant(particle_name=reactant_name,
2948                                                                state_name=reactant_state_name,
2949                                                                coefficient=-1),
2950                                            ReactionParticipant(particle_name=product_name,
2951                                                                state_name=product_state_name,
2952                                                                coefficient=1),
2953                                            ReactionParticipant(particle_name=salt_cation_name,
2954                                                                state_name=cation_state.name,
2955                                                                coefficient=1),],
2956                              pK=-np.log10((Ka * K_NACL / K_HCL).magnitude),
2957                              reaction_type=reaction.reaction_type+"_salt",
2958                              simulation_method="GRxMC")
2959            self.db._register_reaction(rx_tpl)
2960            # Reaction in terms of hydroxide: OH- + HA = A
2961            RE.add_reaction(gamma=(Ka / K_W).magnitude,
2962                            reactant_types=reactant_types+[hydroxide_es_type],
2963                            reactant_coefficients=[1, 1],
2964                            product_types=product_types,
2965                            product_coefficients=[1],
2966                            default_charges=default_charges | {hydroxide_es_type: hydroxide_charge})
2967            rx_tpl = Reaction(participants=[ReactionParticipant(particle_name=reactant_name,
2968                                                                state_name=reactant_state_name,
2969                                                                coefficient=-1),
2970                                            ReactionParticipant(particle_name=product_name,
2971                                                                state_name=product_state_name,
2972                                                                coefficient=1),
2973                                            ReactionParticipant(particle_name=hydroxide_name,
2974                                                                state_name=hydroxide_state.name,
2975                                                                coefficient=-1),],
2976                              pK=-np.log10((Ka / K_W).magnitude),
2977                              reaction_type=reaction.reaction_type+"_conjugate",
2978                              simulation_method="GRxMC")
2979            self.db._register_reaction(rx_tpl)
2980            # Reaction in terms of salt anion: Cl- + HA = A
2981            RE.add_reaction(gamma=(Ka / K_HCL).magnitude,
2982                            reactant_types=reactant_types+[salt_anion_es_type],
2983                            reactant_coefficients=[1, 1],
2984                            product_types=product_types,
2985                            product_coefficients=[1],
2986                            default_charges=default_charges | {salt_anion_es_type: salt_anion_charge})
2987            rx_tpl = Reaction(participants=[ReactionParticipant(particle_name=reactant_name,
2988                                                                state_name=reactant_state_name,
2989                                                                coefficient=-1),
2990                                            ReactionParticipant(particle_name=product_name,
2991                                                                state_name=product_state_name,
2992                                                                coefficient=1),
2993                                            ReactionParticipant(particle_name=salt_anion_name,
2994                                                                state_name=anion_state.name,
2995                                                                coefficient=-1),],
2996                              pK=-np.log10((Ka / K_HCL).magnitude),
2997                              reaction_type=reaction.reaction_type+"_salt",
2998                              simulation_method="GRxMC")
2999            self.db._register_reaction(rx_tpl)
3000        return RE, ionic_strength_res

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:

'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):
3002    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):
3003        """
3004        Sets up acid/base reactions for acidic/basic 'particles' defined in the pyMBE database, as well as a grand-canonical coupling to a 
3005        reservoir of small ions using a unified formulation for small ions.
3006
3007        Args:
3008            pH_res ('float'): 
3009                pH-value in the reservoir.
3010
3011            c_salt_res ('pint.Quantity'): 
3012                Concentration of monovalent salt (e.g. NaCl) in the reservoir.
3013
3014            cation_name ('str'): 
3015                Name of the cationic particle.
3016
3017            anion_name ('str'): 
3018                Name of the anionic particle.
3019
3020            activity_coefficient ('callable'): 
3021                A function that calculates the activity coefficient of an ion pair as a function of the ionic strength.
3022
3023            exclusion_range('pint.Quantity', optional): 
3024                Below this value, no particles will be inserted.
3025            
3026            use_exclusion_radius_per_type('bool', optional): 
3027                Controls if one exclusion_radius per each espresso_type. Defaults to 'False'.
3028
3029        Returns:
3030            'tuple(reaction_methods.ReactionEnsemble,pint.Quantity)':
3031
3032                'reaction_methods.ReactionEnsemble':  
3033                    espressomd reaction_methods object with all reactions necesary to run the GRxMC ensamble.
3034                
3035                'pint.Quantity': 
3036                    Ionic strength of the reservoir (useful for calculating partition coefficients).
3037
3038        Notes:
3039            - 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-}. 
3040            - 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'.
3041
3042        [1] Curk, T., Yuan, J., & Luijten, E. (2022). Accelerated simulation method for charge regulation effects. The Journal of Chemical Physics, 156(4).
3043        [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.
3044        """
3045        from espressomd import reaction_methods
3046        if exclusion_range is None:
3047            exclusion_range = max(self.get_radius_map().values())*2.0
3048        if use_exclusion_radius_per_type:
3049            exclusion_radius_per_type = self.get_radius_map()
3050        else:
3051            exclusion_radius_per_type = {}
3052        RE = reaction_methods.ReactionEnsemble(kT=self.kT.to('reduced_energy').magnitude,
3053                                               exclusion_range=exclusion_range, 
3054                                               seed=self.seed, 
3055                                               exclusion_radius_per_type = exclusion_radius_per_type)
3056        # Determine the concentrations of the various species in the reservoir and the equilibrium constants
3057        cH_res, cOH_res, cNa_res, cCl_res = self.determine_reservoir_concentrations(pH_res, c_salt_res, activity_coefficient)
3058        ionic_strength_res = 0.5*(cNa_res+cCl_res+cOH_res+cH_res)
3059        determined_activity_coefficient = activity_coefficient(ionic_strength_res)
3060        a_hydrogen = (10 ** (-pH_res) * self.units.mol/self.units.l).to('1/(N_A * reduced_length**3)')
3061        a_cation = (cH_res+cNa_res).to('1/(N_A * reduced_length**3)') * np.sqrt(determined_activity_coefficient)
3062        a_anion = (cH_res+cNa_res).to('1/(N_A * reduced_length**3)') * np.sqrt(determined_activity_coefficient)
3063        K_XX = a_cation * a_anion
3064        cation_tpl = self.db.get_template(pmb_type="particle",
3065                                          name=cation_name)
3066        cation_state = self.db.get_template(pmb_type="particle_state",
3067                                            name=cation_tpl.initial_state)
3068        anion_tpl = self.db.get_template(pmb_type="particle",
3069                                          name=anion_name)
3070        anion_state = self.db.get_template(pmb_type="particle_state",
3071                                            name=anion_tpl.initial_state)
3072        cation_es_type = cation_state.es_type
3073        anion_es_type = anion_state.es_type     
3074        cation_charge = cation_state.z
3075        anion_charge = anion_state.z
3076        if cation_charge <= 0:
3077            raise ValueError('ERROR cation charge must be positive, charge ', cation_charge)
3078        if anion_charge >= 0:
3079            raise ValueError('ERROR anion charge must be negative, charge ', anion_charge)
3080        # Coupling to the reservoir: 0 = X+ + X-
3081        RE.add_reaction(gamma = K_XX.magnitude,
3082                        reactant_types = [],
3083                        reactant_coefficients = [],
3084                        product_types = [ cation_es_type, anion_es_type ],
3085                        product_coefficients = [ 1, 1 ],
3086                        default_charges = {cation_es_type: cation_charge, 
3087                                           anion_es_type: anion_charge})
3088        rx_tpl = Reaction(participants=[ReactionParticipant(particle_name=cation_name,
3089                                                            state_name=cation_state.name,
3090                                                            coefficient=1),
3091                                        ReactionParticipant(particle_name=anion_name,
3092                                                            state_name=anion_state.name,
3093                                                            coefficient=1)],
3094                           pK=-np.log10(K_XX.magnitude),
3095                           reaction_type="ion_insertion",
3096                           simulation_method="GCMC")
3097        self.db._register_reaction(rx_tpl)
3098        for reaction in self.db.get_reactions():
3099            if reaction.reaction_type not in ["monoprotic_acid", "monoprotic_base"]:
3100                continue
3101            default_charges = {}
3102            reactant_types  = []
3103            product_types   = []
3104            for participant in reaction.participants:
3105                state_tpl = self.db.get_template(name=participant.state_name,
3106                                                 pmb_type="particle_state")
3107                default_charges[state_tpl.es_type] = state_tpl.z
3108                if participant.coefficient < 0:
3109                    reactant_types.append(state_tpl.es_type)
3110                    reactant_name=state_tpl.particle_name
3111                    reactant_state_name=state_tpl.name
3112                elif participant.coefficient > 0:
3113                    product_types.append(state_tpl.es_type)
3114                    product_name=state_tpl.particle_name
3115                    product_state_name=state_tpl.name
3116
3117            Ka = (10**-reaction.pK * self.units.mol/self.units.l).to('1/(N_A * reduced_length**3)')
3118            gamma_K_AX = Ka.to('1/(N_A * reduced_length**3)').magnitude * a_cation / a_hydrogen
3119            # Reaction in terms of small cation: HA = A + X+
3120            RE.add_reaction(gamma=gamma_K_AX.magnitude,
3121                            reactant_types=reactant_types,
3122                            reactant_coefficients=[1],
3123                            product_types=product_types+[cation_es_type],
3124                            product_coefficients=[1, 1],
3125                            default_charges=default_charges|{cation_es_type: cation_charge})
3126            reaction.add_participant(particle_name=cation_name,
3127                                     state_name=cation_state.name,
3128                                     coefficient=1)
3129            reaction.add_simulation_method("GRxMC")
3130            # Reaction in terms of small anion: X- + HA = A
3131            RE.add_reaction(gamma=gamma_K_AX.magnitude / K_XX.magnitude,
3132                            reactant_types=reactant_types+[anion_es_type],
3133                            reactant_coefficients=[1, 1],
3134                            product_types=product_types,
3135                            product_coefficients=[1],
3136                            default_charges=default_charges|{anion_es_type: anion_charge})
3137            rx_tpl = Reaction(participants=[ReactionParticipant(particle_name=reactant_name,
3138                                                                state_name=reactant_state_name,
3139                                                                coefficient=-1),
3140                                            ReactionParticipant(particle_name=product_name,
3141                                                                state_name=product_state_name,
3142                                                                coefficient=1),
3143                                            ReactionParticipant(particle_name=anion_name,
3144                                                                state_name=anion_state.name,
3145                                                                coefficient=-1),],
3146                              pK=-np.log10(gamma_K_AX.magnitude / K_XX.magnitude),
3147                              reaction_type=reaction.reaction_type+"_conjugate",
3148                              simulation_method="GRxMC")
3149            self.db._register_reaction(rx_tpl)
3150        return RE, ionic_strength_res

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:

'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, espresso_system, shift_potential=True, combining_rule='Lorentz-Berthelot'):
3152    def setup_lj_interactions(self, espresso_system, shift_potential=True, combining_rule='Lorentz-Berthelot'):
3153        """
3154        Sets up the Lennard-Jones (LJ) potential between all pairs of particle states defined in the pyMBE database.
3155
3156        Args:
3157            espresso_system('espressomd.system.System'): 
3158                Instance of a system object from the espressomd library.
3159
3160            shift_potential('bool', optional): 
3161                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.
3162
3163            combining_rule('string', optional): 
3164                combining rule used to calculate 'sigma' and 'epsilon' for the potential between a pair of particles. Defaults to 'Lorentz-Berthelot'.
3165
3166            warning('bool', optional): 
3167                switch to activate/deactivate warning messages. Defaults to True.
3168
3169        Notes:
3170            - Currently, the only 'combining_rule' supported is Lorentz-Berthelot.
3171            - Check the documentation of ESPResSo for more info about the potential https://espressomd.github.io/doc4.2.0/inter_non-bonded.html
3172
3173        """
3174        from itertools import combinations_with_replacement
3175        particle_templates = self.db.get_templates("particle")
3176        shift = "auto" if shift_potential else 0
3177        if shift == "auto":
3178            shift_tpl = shift
3179        else:
3180            shift_tpl = PintQuantity.from_quantity(q=shift*self.units.reduced_length,
3181                                                   expected_dimension="length",
3182                                                   ureg=self.units)
3183        # Get all particle states registered in pyMBE
3184        state_entries = []
3185        for tpl in particle_templates.values():
3186            for state in self.db.get_particle_states_templates(particle_name=tpl.name).values():
3187                state_entries.append((tpl, state))
3188
3189        # Iterate over all unique state pairs
3190        for (tpl1, state1), (tpl2, state2) in combinations_with_replacement(state_entries, 2):
3191
3192            lj_parameters = self.get_lj_parameters(particle_name1=tpl1.name,
3193                                                   particle_name2=tpl2.name,
3194                                                   combining_rule=combining_rule)
3195            if not lj_parameters:
3196                continue
3197
3198            espresso_system.non_bonded_inter[state1.es_type, state2.es_type].lennard_jones.set_params(
3199                epsilon=lj_parameters["epsilon"].to("reduced_energy").magnitude,
3200                sigma=lj_parameters["sigma"].to("reduced_length").magnitude,
3201                cutoff=lj_parameters["cutoff"].to("reduced_length").magnitude,
3202                offset=lj_parameters["offset"].to("reduced_length").magnitude,
3203                shift=shift)
3204                
3205            lj_template = LJInteractionTemplate(state1=state1.name,
3206                                                state2=state2.name,
3207                                                sigma=PintQuantity.from_quantity(q=lj_parameters["sigma"],
3208                                                                                 expected_dimension="length",
3209                                                                                 ureg=self.units),
3210                                                epsilon=PintQuantity.from_quantity(q=lj_parameters["epsilon"],
3211                                                                                   expected_dimension="energy",
3212                                                                                   ureg=self.units),
3213                                                cutoff=PintQuantity.from_quantity(q=lj_parameters["cutoff"],
3214                                                                                  expected_dimension="length",
3215                                                                                  ureg=self.units),
3216                                                offset=PintQuantity.from_quantity(q=lj_parameters["offset"],
3217                                                                                  expected_dimension="length",
3218                                                                                  ureg=self.units),
3219                                                shift=shift_tpl)
3220            self.db._register_template(lj_template)

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

Arguments:
  • espresso_system('espressomd.system.System'): Instance of a system object from the espressomd library.
  • 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: