Skip to content
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

## Added
- Support to setup angular potentials with pyMBE. All flexible pyMBE templates now support angula potentials: hydrogels, molecules, peptides and residues (including residues with nested residues). (#150)
- Sample scripts and tests for the new functionality. (#150)
- Template and instance `Angle` to store information about angular potentials in the pyMBE database. (#150)
- Functions `define_angle` and ``define_default_angle` to define a templates of angular potentials in pyMBE. (#150)
- Function `create_angle` to create instances of angular potential in pyMBE. (#150)
- Function `get_angle_template` to retrieve a template of an angle potential in the pyMBE database. (#150)
- Introduced a canonical pyMBE database backend replacing the previous monolithic Pandas DataFrame storage approach. This lays the foundation for more robust, extensible, and normalized data handling across pyMBE. (#147)
- Added support to define reaction templates in the pyMBE database. (#147)
- Utility functions to cast information about templates and instances in the pyMBE database into pandas dataframe `pmb.get_templates_df`, `pmb.get_instances_df` and `pmb.get_reactions_df`. (#147)
Expand Down
411 changes: 386 additions & 25 deletions pyMBE/pyMBE.py

Large diffs are not rendered by default.

58 changes: 58 additions & 0 deletions pyMBE/storage/instances/angle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
#
# Copyright (C) 2026 pyMBE-dev team
#
# This file is part of pyMBE.
#
# pyMBE is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# pyMBE is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#

from typing import Literal
from pyMBE.storage.base_type import PMBBaseModel
from pydantic import validator

class AngleInstance(PMBBaseModel):
"""
Instance representation of an angle between three particles.

Attributes:
pmb_type ('Literal["angle"]'):
Fixed identifier set to ``"angle"`` for all angle instances.

angle_id ('int'):
Unique non-negative integer identifying this angle instance.

name ('str'):
Name of the angle template from which this instance was created.

particle_id1 ('int'):
ID of the first side particle.

particle_id2 ('int'):
ID of the central particle.

particle_id3 ('int'):
ID of the second side particle.
"""
pmb_type: Literal["angle"] = "angle"
angle_id: int
name: str
particle_id1: int
particle_id2: int
particle_id3: int

@validator("angle_id", "particle_id1", "particle_id2", "particle_id3")
def validate_non_negative_int(cls, value, field):
if value < 0:
raise ValueError(f"{field.name} must be a non-negative integer.")
return value
48 changes: 46 additions & 2 deletions pyMBE/storage/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,12 @@
from pyMBE.storage.templates.residue import ResidueTemplate
from pyMBE.storage.templates.molecule import MoleculeTemplate
from pyMBE.storage.templates.bond import BondTemplate
from pyMBE.storage.templates.angle import AngleTemplate
from pyMBE.storage.instances.particle import ParticleInstance
from pyMBE.storage.instances.residue import ResidueInstance
from pyMBE.storage.instances.molecule import MoleculeInstance
from pyMBE.storage.instances.bond import BondInstance
from pyMBE.storage.instances.angle import AngleInstance
from pyMBE.storage.reactions.reaction import Reaction, ReactionParticipant
from pyMBE.storage.templates.peptide import PeptideTemplate
from pyMBE.storage.instances.peptide import PeptideInstance
Expand Down Expand Up @@ -123,7 +125,7 @@ def _load_database_csv(db, folder):

Notes:
- PintQuantity objects are reconstructed from their dictionary representation.
- Supports particle, residue, molecule, peptide, protein, bond, and hydrogel types.
- Supports particle, residue, molecule, peptide, protein, bond, angle, and hydrogel types.
"""
folder = Path(folder)
if not folder.exists():
Expand All @@ -134,6 +136,7 @@ def _load_database_csv(db, folder):
"residue",
"molecule",
"bond",
"angle",
"peptide",
"protein",
"hydrogel",
Expand Down Expand Up @@ -218,6 +221,22 @@ def _load_database_csv(db, folder):
particle_name2=None if particle_name2 == "" else particle_name2,
parameters=parameters)
templates[tpl.name] = tpl
elif pmb_type == "angle":
params_raw = _decode(row.get("parameters", "")) or {}
parameters: Dict[str, Any] = {}
side_particle1 = row.get("side_particle1", "") or ""
central_particle = row.get("central_particle", "") or ""
side_particle2 = row.get("side_particle2", "") or ""
for k, v in params_raw.items():
if isinstance(v, dict) and {"magnitude", "units", "dimension"}.issubset(v.keys()):
parameters[k] = PintQuantity.from_dict(v)
tpl = AngleTemplate(name=row["name"],
angle_type=row.get("angle_type", ""),
side_particle1=None if side_particle1 == "" else side_particle1,
central_particle=None if central_particle == "" else central_particle,
side_particle2=None if side_particle2 == "" else side_particle2,
parameters=parameters)
templates[tpl.name] = tpl
elif pmb_type == "hydrogel":
node_map_raw = _decode(row.get("node_map", "")) or []
chain_map_raw = _decode(row.get("chain_map", "")) or []
Expand Down Expand Up @@ -308,6 +327,13 @@ def _load_database_csv(db, folder):
particle_id1=int(row["particle_id1"]),
particle_id2=int(row["particle_id2"]))
instances[inst.bond_id] = inst
elif pmb_type == "angle":
inst = AngleInstance(name=row["name"],
angle_id=int(row["angle_id"]),
particle_id1=int(row["particle_id1"]),
particle_id2=int(row["particle_id2"]),
particle_id3=int(row["particle_id3"]))
instances[inst.angle_id] = inst
elif pmb_type == "hydrogel":
inst = HydrogelInstance(name=row["name"],
assembly_id=int(row["assembly_id"]))
Expand Down Expand Up @@ -401,6 +427,17 @@ def _save_database_csv(db, folder):
"particle_name2": tpl.particle_name2,
"bond_type": tpl.bond_type,
"parameters": _encode(params_serial)})
elif pmb_type == "angle" and isinstance(tpl, AngleTemplate):
params_serial = {}
for k, v in tpl.parameters.items():
if isinstance(v, PintQuantity):
params_serial[k] = v.to_dict()
rows.append({"name": tpl.name,
"side_particle1": tpl.side_particle1,
"central_particle": tpl.central_particle,
"side_particle2": tpl.side_particle2,
"angle_type": tpl.angle_type,
"parameters": _encode(params_serial)})
# HYDROGEL TEMPLATE
elif pmb_type == "hydrogel" and isinstance(tpl, HydrogelTemplate):
rows.append({"name": tpl.name,
Expand Down Expand Up @@ -465,6 +502,13 @@ def _save_database_csv(db, folder):
"bond_id": int(inst.bond_id),
"particle_id1": int(inst.particle_id1),
"particle_id2": int(inst.particle_id2)})
elif pmb_type == "angle" and isinstance(inst, AngleInstance):
rows.append({"pmb_type": pmb_type,
"name": inst.name,
"angle_id": int(inst.angle_id),
"particle_id1": int(inst.particle_id1),
"particle_id2": int(inst.particle_id2),
"particle_id3": int(inst.particle_id3)})
elif pmb_type == "hydrogel" and isinstance(inst, HydrogelInstance):
rows.append({"pmb_type": pmb_type,
"name": inst.name,
Expand All @@ -488,4 +532,4 @@ def _save_database_csv(db, folder):
"reaction_type": rx.reaction_type,
"metadata": _encode(rx.metadata) if getattr(rx, "metadata", None) is not None else ""})
if rows:
pd.DataFrame(rows).to_csv(os.path.join(folder, "reactions.csv"), index=False)
pd.DataFrame(rows).to_csv(os.path.join(folder, "reactions.csv"), index=False)
46 changes: 45 additions & 1 deletion pyMBE/storage/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,12 @@
from pyMBE.storage.templates.residue import ResidueTemplate
from pyMBE.storage.templates.molecule import MoleculeTemplate
from pyMBE.storage.templates.bond import BondTemplate
from pyMBE.storage.templates.angle import AngleTemplate
from pyMBE.storage.instances.particle import ParticleInstance
from pyMBE.storage.instances.residue import ResidueInstance
from pyMBE.storage.instances.molecule import MoleculeInstance
from pyMBE.storage.instances.bond import BondInstance
from pyMBE.storage.instances.angle import AngleInstance
from pyMBE.storage.reactions.reaction import Reaction
from pyMBE.storage.templates.peptide import PeptideTemplate
from pyMBE.storage.instances.peptide import PeptideInstance
Expand Down Expand Up @@ -88,8 +90,9 @@ def __init__(self,units):
"peptide",
"protein"]
self._assembly_like_types = ["hydrogel"]
self._pmb_types = ["particle", "residue"] + self._molecule_like_types + self._assembly_like_types
self._pmb_types = ["particle", "residue", "angle"] + self._molecule_like_types + self._assembly_like_types
self.espresso_bond_instances= {}
self.espresso_angle_instances= {}

def _collect_particle_templates(self, name, pmb_type):
"""
Expand Down Expand Up @@ -176,6 +179,28 @@ def _delete_bonds_of_particle(self, pid):
if "bond" in self._instances and not self._instances["bond"]:
del self._instances["bond"]

def _delete_angles_of_particle(self, pid):
"""
Delete all angle instances involving a given particle instance.

Args:
pid ('int'):
The particle ID whose associated angles should be deleted.

Notes:
- If no `"angle"` instances are present in the database, the method
exits immediately.
- This method does not raise errors if no angles involve the particle.
- It is intended for internal use by cascade-deletion routines.
"""
if "angle" not in self._instances:
return
angles_to_delete = [a_id for a_id, a in list(self._instances["angle"].items()) if a.particle_id1 == pid or a.particle_id2 == pid or a.particle_id3 == pid]
for a_id in angles_to_delete:
del self._instances["angle"][a_id]
if "angle" in self._instances and not self._instances["angle"]:
del self._instances["angle"]

def _find_instance_ids_by_attribute(self, pmb_type, attribute, value):
"""
Return a list of instance IDs for a given pmb_type where a given attribute
Expand Down Expand Up @@ -355,6 +380,17 @@ def _get_templates_df(self, pmb_type):
"particle_name1": tpl.particle_name1,
"particle_name2": tpl.particle_name2,
"parameters": parameters})
elif pmb_type == "angle":
parameters = {}
for key in tpl.parameters.keys():
parameters[key] = tpl.parameters[key].to_quantity(self._units)
rows.append({"pmb_type": tpl.pmb_type,
"name": tpl.name,
"angle_type": tpl.angle_type,
"side_particle1": tpl.side_particle1,
"central_particle": tpl.central_particle,
"side_particle2": tpl.side_particle2,
"parameters": parameters})
else:
# Generic representation for other types
rows.append(tpl.dict())
Expand Down Expand Up @@ -428,6 +464,9 @@ def _register_instance(self, instance):
elif isinstance(instance, BondInstance):
pmb_type = "bond"
iid = instance.bond_id
elif isinstance(instance, AngleInstance):
pmb_type = "angle"
iid = instance.angle_id
elif isinstance(instance, HydrogelInstance):
pmb_type = "hydrogel"
iid = instance.assembly_id
Expand Down Expand Up @@ -679,6 +718,7 @@ def delete_instance(self, pmb_type, instance_id):
# --- Delete children of PARTICLE (only bonds) ---
if pmb_type == "particle":
self._delete_bonds_of_particle(instance_id)
self._delete_angles_of_particle(instance_id)
# =============== FINAL DELETION STEP ======================
del self._instances[pmb_type][instance_id]
if not self._instances[pmb_type]:
Expand Down Expand Up @@ -754,6 +794,10 @@ def delete_template(self, pmb_type, name):
if pmb_type == "bond":
if name in self.espresso_bond_instances.keys():
del self.espresso_bond_instances[name]
# if it is an angle template delete also stored espresso angle instances
if pmb_type == "angle":
if name in self.espresso_angle_instances.keys():
del self.espresso_angle_instances[name]
# Delete empty groups
if not self._templates[pmb_type]:
del self._templates[pmb_type]
Expand Down
109 changes: 109 additions & 0 deletions pyMBE/storage/templates/angle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
#
# Copyright (C) 2026 pyMBE-dev team
#
# This file is part of pyMBE.
#
# pyMBE is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# pyMBE is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#

from typing import Dict, Literal, Optional
from ..base_type import PMBBaseModel
from ..pint_quantity import PintQuantity
from pydantic import Field

class AngleTemplate(PMBBaseModel):
"""
Template defining an angular potential in the pyMBE database.

Attributes:
pmb_type ('Literal["angle"]'):
Fixed type identifier for this template. Always "angle".

name ('str'):
Unique name of the angle template, e.g., "A-B-C" where B is the central particle.

angle_type ('str'):
Type of angle potential. Examples: "harmonic", "cosine", "harmonic_cosine".

side_particle1 ('Optional[str]'):
Name of the first side particle in the angle triplet.

central_particle ('Optional[str]'):
Name of the central particle in the angle triplet.

side_particle2 ('Optional[str]'):
Name of the second side particle in the angle triplet.

parameters ('Dict[str, PintQuantity]'):
Dictionary of angle parameters (e.g., k, phi_0).

Notes:
- Values of the parameters are stored as PintQuantity objects for unit-aware calculations.
- The canonical name sorts the two side particles alphabetically while keeping
the central particle in the middle: ``"side_min-central-side_max"``.
"""
pmb_type: Literal["angle"] = "angle"
name: str = Field(default="default")
angle_type: str
side_particle1: Optional[str] = None
central_particle: Optional[str] = None
side_particle2: Optional[str] = None
parameters: Dict[str, PintQuantity]

@classmethod
def make_angle_key(cls, side1, central, side2):
"""Return a canonical name for an angle between three particle names.

The two side particles are sorted alphabetically, with the central
particle kept in the middle position.

Args:
side1 ('str'):
Name of the first side particle.

central ('str'):
Name of the central particle.

side2 ('str'):
Name of the second side particle.

Returns:
('str'):
Canonical angle name, e.g. "A-B-C".
"""
sides = sorted([side1, side2])
return f"{sides[0]}-{central}-{sides[1]}"

def _make_name(self):
"""Create canonical name using particle names."""
if not self.side_particle1 or not self.central_particle or not self.side_particle2:
raise RuntimeError("Cannot generate angle name: side_particle1, central_particle, or side_particle2 missing.")
self.name = self.make_angle_key(self.side_particle1, self.central_particle, self.side_particle2)

def get_parameters(self, ureg):
"""
Retrieve the angle parameters as Pint `Quantity` objects.

Args:
ureg ('pint.UnitRegistry'):
Pint unit registry used to reconstruct physical quantities from storage.

Returns:
'Dict[str, pint.Quantity]':
A dictionary mapping parameter names to their corresponding unit-aware Pint quantities.
"""
pint_parameters = {}
for parameter in self.parameters.keys():
pint_parameters[parameter] = self.parameters[parameter].to_quantity(ureg)
return pint_parameters
Loading
Loading