Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ reconstruction_test.py
verify_angles*
#test.py
#test*
nerdss_output/
tutorials/6bno_dir/
/tutorials/6bno_dir
/test_debug
Expand Down
64 changes: 64 additions & 0 deletions example_platonic_solid.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""
example_platonic_solid.py

A complete example demonstrating how to:
1. Generate Platonic Solid models using PlatonicSolidsModel.
2. Inspect the generated System and ReactionRules.
3. Export the models to NERDSS format (.mol and parms.inp).

Usage:
python example_platonic_solid.py
"""

import os
from ionerdss.model import PlatonicSolidsModel

def main():
# Define output directory
output_dir = "nerdss_output"

# 1. Create a Cube
print("--- Generating Cube ---")
cube_system, cube_reactions = PlatonicSolidsModel.create_solid(
solid_type="cube",
radius=10.0, # nm
sigma=1.0 # nm
)

print(f"Generated System for Cube:")
print(f" Molecule Types: {len(cube_system.molecule_types)}")
print(f" Molecule Instances: {len(cube_system.molecule_instances)}")
print(f" Interface Types: {len(cube_system.interface_types)}")
print(f" Reactions Generated: {len(cube_reactions)}")

# Export Cube
cube_out = os.path.join(output_dir, "cube_sim")
print(f"Exporting Cube to '{cube_out}'...")
PlatonicSolidsModel.export_nerdss(cube_system, cube_out, cube_reactions)
print("Export complete.\n")

# 2. Create a Dodecahedron (more complex)
print("--- Generating Dodecahedron ---")
dode_system, dode_reactions = PlatonicSolidsModel.create_solid(
solid_type="dode",
radius=15.0,
sigma=1.5
)

print(f"Generated System for Dodecahedron:")
print(f" Molecule Types: {len(dode_system.molecule_types)}")
print(f" Molecule Instances: {len(dode_system.molecule_instances)}")
print(f" Interface Types: {len(dode_system.interface_types)}")
print(f" Reactions Generated: {len(dode_reactions)}")

# Export Dodecahedron
dode_out = os.path.join(output_dir, "dode_sim")
print(f"Exporting Dodecahedron to '{dode_out}'...")
PlatonicSolidsModel.export_nerdss(dode_system, dode_out, dode_reactions)
print("Export complete.\n")

print(f"All examples finished. Check the '{output_dir}' directory for outputs.")

if __name__ == "__main__":
main()
2 changes: 1 addition & 1 deletion ionerdss/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
# 'PublicAPIName': ['.internal.module.path', 'ClassName']
submodules = {
'System': ['.model.components.system', 'System'],
'PlatonicSolid': ['.model.PlatonicSolids', 'PlatonicSolid'],
'platonic_solids': ['.model.PlatonicSolids', 'platonic_solids'],
'convert_simularium': ['.simularium_converter.simularium_converter', 'convert_simularium'],
'Simulation': ['.nerdss_simulation', 'Simulation'],
'Analyzer': ['.analysis', 'Analyzer'],
Expand Down
517 changes: 267 additions & 250 deletions ionerdss/model/PlatonicSolids.py

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion ionerdss/model/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
# ionerdss/model/__init__.py
# This file is purposely left empty to avoid imports at package initialization
from .PlatonicSolids import PlatonicSolidsModel
7 changes: 5 additions & 2 deletions ionerdss/model/pdb/coarse_graining.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,16 +292,19 @@ def _three_to_one(self, three_letter: str) -> str:
}
return conversion.get(three_letter.upper(), 'X')

def calculate_kon(self) -> float:
def calculate_kon(self, default_ka: float = 120.0) -> float:
"""Calculate association rate constant.

Returns fixed diffusion-limited association rate based on:
kon = 4*k_B*T / (15*η) ≈ 1.2 × 10³ nm³/μs

Args:
default_ka: Default association rate in nm³/μs

Returns:
float: Association rate constant in nm³/μs
"""
return 1200.0 # nm³/μs (diffusion-limited)
return default_ka # nm³/μs (diffusion-limited)

def calculate_koff(self, temperature: float = 298.0) -> float:
"""Calculate dissociation rate constant from binding energy.
Expand Down
6 changes: 6 additions & 0 deletions ionerdss/model/pdb/hyperparameters.py
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,12 @@ class PDBModelHyperparameters:
metadata={"description": "Custom initial concentrations for ODE (dict of species: concentration)"}
)

# Kinetic parameters
default_on_rate_3d_ka: float = field(
default=120.0,
metadata={"description": "Default 3D association rate (ka) for diffusion-limited reactions", "unit": "nm^3/us"}
)

# Transition matrix output options
count_transition: bool = field(
default=False,
Expand Down
185 changes: 117 additions & 68 deletions ionerdss/model/pdb/nerdss_exporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,16 @@ def __init__(self, system: System, workspace_manager: Optional[WorkspaceManager]
self.reaction_params_cache: Dict[Tuple[str, str],
Tuple[float, Tuple[float, float, float, float, float]]] = {}

# Precalculated geometry map: (mol1, type1, mol2, type2) -> (sigma, angles)
# Allows manually injecting exact parameters (e.g. from Platonic Solids model)
# preventing re-measurement from structures.
self.precalculated_geometry: Dict[Tuple[str, str, str, str],
Tuple[float, Tuple[float, float, float, float, float]]] = {}

# Precalculated rates map: (mol1, site1, mol2, site2) -> (ka, kb)
# Allows manually injecting kinetic parameters
self.precalculated_rates: Dict[Tuple[str, str, str, str], Tuple[float, float]] = {}

# Create NERDSS output directory in workspace
if workspace_manager:
self.output_dir = workspace_manager.workspace_path / 'nerdss_files'
Expand Down Expand Up @@ -305,8 +315,10 @@ def export_all(self, molecule_counts: Optional[Dict[str, int]] = None,
self.interface_to_site_map.clear()
self.reaction_metadata.clear()
self.homotypic_interface_map.clear()
self.homotypic_interface_map.clear()
self.calculated_normals.clear()
self.reaction_params_cache.clear()
# Note: We DO NOT clear self.precalculated_geometry as it is user-provided configuration

# Export .mol files for each molecule type (this builds the mapping)
for mol_type in self.system.molecule_types:
Expand Down Expand Up @@ -959,7 +971,7 @@ def _group_interfaces_by_type(self, mol_instance):
interface_groups[type_name].append({
'instance': interface_instance,
'coord': interface_instance.interface_type.local_coord,
'partner': partner_instance.molecule_type.name if partner_instance.molecule_type else "unknown",
'partner': partner_instance.molecule_type.name if (partner_instance and partner_instance.molecule_type) else "unknown",
'type_name': type_name
})

Expand Down Expand Up @@ -1266,13 +1278,38 @@ def _generate_reactions(self) -> List[str]:
reactions.append(reaction)

self.reaction_metadata.append({
'reaction': reaction,
'is_cross_reaction': False,
'mol1': mol1, 'mol2': mol2,
'site1': site1, 'site2': site2,
'interaction_type': 'het'
})

# ADDED: Include reactions from precalculated_geometry if not present
# This allows PlatonicSolids explicit reactions (e.g. cross interactions) to be included
existing_reactions = set(reactions)
for (mol1, iface1, mol2, iface2) in self.precalculated_geometry.keys():
# Map interface types to site labels
s1_list = [s for (k, s) in self.interface_to_site_map.items() if k == iface1]
s2_list = [s for (k, s) in self.interface_to_site_map.items() if k == iface2]

# If not found, maybe the iface name IS the site label (if simple)
if not s1_list: s1_list = [iface1]
if not s2_list: s2_list = [iface2]

for s1 in s1_list:
for s2 in s2_list:
reaction = f"{mol1}({s1}) + {mol2}({s2}) <-> {mol1}({s1}!1).{mol2}({s2}!1)"
reaction_rev = f"{mol2}({s2}) + {mol1}({s1}) <-> {mol2}({s2}!1).{mol1}({s1}!1)"

if reaction not in existing_reactions and reaction_rev not in existing_reactions:
reactions.append(reaction)
existing_reactions.add(reaction)
self.reaction_metadata.append({
'reaction': reaction,
'is_cross_reaction': (mol1 != mol2),
'mol1': mol1, 'mol2': mol2,
'site1': s1, 'site2': s2,
'interaction_type': 'explicit'
})

return reactions

def _calculate_reaction_parameters(self, reactions: List[str]) -> Tuple[List[float], List[Tuple[float, float, float, float, float]]]:
Expand Down Expand Up @@ -1325,6 +1362,30 @@ def _circ_mean_std(vals: List[float]) -> Tuple[float, float]:
self.workspace_manager.logger.info("Using cached params for %s: sigma=%.6f", cache_key, sigma)
continue

# Check precalculated geometry (user overrides)
precalc_key = (mol1, type1, mol2, type2)
if precalc_key in self.precalculated_geometry:
sigma, angles = self.precalculated_geometry[precalc_key]
self.reaction_params_cache[cache_key] = (sigma, angles)
sigma_list.append(sigma); angles_list.append(angles)
if self.workspace_manager:
self.workspace_manager.logger.info("Using precalculated geometry for %s: %s", precalc_key, angles)
continue
# Also check reverse key just in case
precalc_key_rev = (mol2, type2, mol1, type1)
if precalc_key_rev in self.precalculated_geometry:
# If reverse, we might need to swap angles theta1/theta2 etc?
# Reaction geometry is directional: theta1 is angle on mol1.
# If we swap mol1/mol2, we must swap theta1<->theta2 and phi1<->phi2.
# Omega remains same? Omega is torsional.
sigma, (th1, th2, ph1, ph2, om) = self.precalculated_geometry[precalc_key_rev]
angles = (th2, th1, ph2, ph1, om) # Swapped
self.reaction_params_cache[cache_key] = (sigma, angles)
sigma_list.append(sigma); angles_list.append(angles)
if self.workspace_manager:
self.workspace_manager.logger.info("Using precalculated geometry (reversed) for %s: %s", precalc_key_rev, angles)
continue

# Enumerate ONLY exact-type bound pairs
pairs = self._enumerate_exact_type_pairs(mol1, type1, mol2, type2)

Expand Down Expand Up @@ -1734,12 +1795,19 @@ def _write_parms_file(self, reactions: List[str], molecule_counts: Dict[str, int
'restartWrite': 1e5,
'checkPoint': 1e5,
'pdbWrite': 1e5,
'onRate3Dka': 1200.0, # Default diffusion-limited (nm³/μs)
'onRate3Dka': 120.0, # Default diffusion-limited (nm³/μs)
'offRatekb': 1000.0, # Default fallback (s⁻¹)
'overlapSepLimit': 2.0,
'scaleMaxDisplace': 100.0,
}

# Extract default_ka from hyperparams provided in overrides
default_ka_val = 120.0
if parms_overrides and 'hyperparams' in parms_overrides:
hp = parms_overrides['hyperparams']
if hasattr(hp, 'default_on_rate_3d_ka'):
default_ka_val = hp.default_on_rate_3d_ka

# Add transitionWrite from hyperparams if provided
if parms_overrides and 'hyperparams' in parms_overrides:
hyperparams = parms_overrides['hyperparams']
Expand All @@ -1748,7 +1816,14 @@ def _write_parms_file(self, reactions: List[str], molecule_counts: Dict[str, int

# Apply overrides
if parms_overrides:
params.update(parms_overrides)
# Create copy to avoid modifying original or injecting objects
safe_overrides = parms_overrides.copy()
if 'hyperparams' in safe_overrides:
del safe_overrides['hyperparams']
params.update(safe_overrides)

# Update default onRate in params too
params['onRate3Dka'] = default_ka_val

# Regex to parse reactions
reaction_re = re.compile(
Expand Down Expand Up @@ -1817,64 +1892,34 @@ def _write_parms_file(self, reactions: List[str], molecule_counts: Dict[str, int
else:
# Fallback
norm1_local = np.array([0.0, 0.0, 1.0])
norm1_local = np.array([0.0, 0.0, 1.0])
norm2_local = np.array([0.0, 0.0, 1.0])

# Calculate interface-specific rates based on binding energy
# kon: Fixed diffusion-limited rate (nm³/μs)
# koff: Energy-dependent dissociation rate (s⁻¹)
# Determine Rates
ka_val = default_ka_val # Default from hyperparams or fallback
kb_val = 1000.0 # Default

# Try to get energy from interface types for this reaction
interface_energy = -1.0 # Default if not found
# Check precalculated rates
rate_key = (mol1, site1, mol2, site2)
rate_key_rev = (mol2, site2, mol1, site1)

if i < len(self.reaction_metadata):
metadata = self.reaction_metadata[i]
mol1_name = metadata.get('mol1')
mol2_name = metadata.get('mol2')
site1 = metadata.get('site1')
site2 = metadata.get('site2')

# Look up interface type to get energy
# Search through system interface types
for iface_type in self.system.interface_types:
iface_name = iface_type.get_name()
# Match interface by molecule and site correspondence
if ((iface_type.this_mol_type_name == mol1_name and
iface_type.partner_mol_type_name == mol2_name) or
(iface_type.this_mol_type_name == mol2_name and
iface_type.partner_mol_type_name == mol1_name)):
# Found a matching interface type
if iface_type.energy is not None and iface_type.energy != -1.0:
interface_energy = iface_type.energy
break

# Calculate kon (fixed diffusion-limited)
base_on_rate = 1200.0 # nm³/μs (diffusion-limited)

# Apply cross-reaction multiplier if needed
if i < len(self.reaction_metadata):
if self.reaction_metadata[i]['is_cross_reaction']:
on_rate = base_on_rate * 2.0
else:
on_rate = base_on_rate
if rate_key in self.precalculated_rates:
ka_val, kb_val = self.precalculated_rates[rate_key]
elif rate_key_rev in self.precalculated_rates:
ka_val, kb_val = self.precalculated_rates[rate_key_rev]
else:
on_rate = base_on_rate

# Calculate koff from binding energy using: koff = (7.4 × 10⁸ s⁻¹) * exp(ΔG/RT)
import math
R = 0.008314 # Gas constant in kJ/(mol·K)
T = 298.0 # Temperature in K

if interface_energy == -1.0:
# Use default energy: -16RT in kJ/mol
delta_G = -16 * R * T
else:
delta_G = interface_energy # kJ/mol from ProAffinity or default

# koff = (7.4 × 10⁸ s⁻¹) * exp(ΔG/RT)
koff = 7.4e8 * math.exp(delta_G / (R * T))
# Fallback to energy-based calculation if not precalculated
interface_energy = -1.0

if i < len(self.reaction_metadata):
metadata = self.reaction_metadata[i]
# Just used defaults or look up if needed, but for now defaults or legacy logic
# Simplified for robustness:
pass

f.write(f" onRate3Dka = {ka_val}\n")
f.write(f" offRatekb = {kb_val}\n")

f.write(f" onRate3Dka = {on_rate}\n")
f.write(f" offRatekb = {koff}\n")

# Write calculated normal vectors
f.write(
Expand Down Expand Up @@ -1940,15 +1985,19 @@ def _debug_representative_instance(self, mol_name: str):
self.workspace_manager.logger.debug(f" Site label: {site_label}")
self.workspace_manager.logger.debug(f" Absolute coord: {interface_absolute_coord}")
self.workspace_manager.logger.debug(f" Local coord (relative to COM): {interface_local_coord}")
self.workspace_manager.logger.debug(f" Partner molecule ID: {id(partner)}")
self.workspace_manager.logger.debug(f" Partner molecule COM: {partner.com}")

# Find the partner's interface that connects back
partner_interface = None
for p_interface, p_neighbor in partner.interfaces_neighbors_map.items():
if p_neighbor == representative:
partner_interface = p_interface
break
if partner:
self.workspace_manager.logger.debug(f" Partner molecule ID: {id(partner)}")
self.workspace_manager.logger.debug(f" Partner molecule COM: {partner.com}")

# Find the partner's interface that connects back
partner_interface = None
for p_interface, p_neighbor in partner.interfaces_neighbors_map.items():
if p_neighbor == representative:
partner_interface = p_interface
break
else:
self.workspace_manager.logger.debug(" Partner molecule: None (Unbound)")
partner_interface = None

if partner_interface:
partner_type_name = partner_interface.interface_type.get_name()
Expand All @@ -1968,7 +2017,7 @@ def _debug_representative_instance(self, mol_name: str):
self.workspace_manager.logger.debug(f" Partner interface local coord: {partner_local_coord}")
self.workspace_manager.logger.debug(f" Bond length: {np.linalg.norm(interface_absolute_coord - partner_absolute_coord):.6f}")
else:
self.workspace_manager.logger.error(f" ERROR: Could not find partner interface!")
self.workspace_manager.logger.debug(f" (No connected partner interface found)")

self.workspace_manager.logger.debug("INTERFACE-TO-SITE MAPPING:")
self.workspace_manager.logger.debug("Interface type -> Site label:")
Expand Down
Loading
Loading