Source code for nskinetics.species.species

# -*- coding: utf-8 -*-
# NSKinetics: simulation of Non-Steady state enzyme Kinetics and inhibitory phenomena
# Copyright (C) 2025-, Sarang S. Bhagwat <sarangbhagwat.developer@gmail.com>
# 
# This module is under the MIT open-source license. See 
# https://github.com/sarangbhagwat/nskinetics/blob/main/LICENSE
# for license details.

import numpy as np

__all__ = ('Species', 
           'SpeciesSystem',
           'SimpleSpecies', 'ComplexSpecies')

#%% Species and species system

[docs] class Species(): """ Abstract class for a chemical species. Parameters ---------- ID : str ID. MW: float or int Molar mass (molecular weight) of this species. """
[docs] def __init__(self, ID, MW=1.0, compartment=None): self._ID = ID self._MW = MW # Parse "BaseID[comp]" if present unless explicitly given if compartment is None and ('[' in ID and ID.endswith(']')): base, comp = ID[:-1].split('[', 1) self._base_ID = base self._compartment = comp else: self._base_ID = ID self._compartment = compartment self._ID = f"{self._base_ID}[{self._compartment}]" if self.compartment else self._ID
@property def compartment(self): return getattr(self, '_compartment', None) @property def ID(self): """Return 'base_ID[comp]' if compartmented, else base ID.""" return f"{self._base_ID}[{self._compartment}]" if self.compartment else self._base_ID @property def base_ID(self): return self._base_ID @property def MW(self): return self._MW
class SimpleSpecies(Species): """ Abstract class for a single chemical species that is not a complex of other chemical species participating in the expected species system. Parameters ---------- ID : str ID. MW: float or int Molar mass (molecular weight) of this species. """ def __init__(self, ID, MW=1.): Species.__init__(self, ID=ID, MW=MW) class ComplexSpecies(Species): """ Represents a complex formed by two or more constituent Species. Must provide exactly one combination of these parameters: - ID and simple_species_system; - components Other parameters are optional. Parameters ---------- ID : str, optional If provided, must be a string created by conjugating IDs of components with '~'. For components with non-1 stoichiometries, ID must include these in the format f'number*species_ID' (as shown below), with or without parentheses (either is fine, although will be stored with parentheses). Can be conjugated in any order, but will be stored in alphabetic order of component Species IDs. Defaults to a string automatically created by this method from components. E.g., if components == {'E':1, 'S':1, 'I':2}, ID defaults to 'E~2*(I)~S'. components : dict, optional If provided, must be a dictionary mapping Species objects or Species IDs to their stoichiometric coefficients in the complex. E.g., {speciesA: 1, speciesB: 2}. MW: float or int, optional Molar mass (molecular weight) of this species. Defaults to the stoichiometry-adjusted sum of its component species' molecular weights. simple_species_system: SimpleSpeciesSystem, optional Simple species system that includes all components of this complex species. Attributes ---------- ID : str ID of the complex species. components : dict Species objects with their stoichiometric coefficients. MW : float Molecular weight of the complex, automatically computed from the sum of the components. """ def __init__(self, ID=None, simple_species_system=None, components=None, MW=None): if ID is None and components is None: raise ValueError('\nEither ID or components must be provided, but both were None.') if ID is None: if isinstance(components, list): components = {i: 1 for i in components} for k, v in list(components.items()): if isinstance(k, str): k_sp = simple_species_system.species(k) components[k_sp] = v del components[k] self._components = components ID = self.ID_from_components(components) elif components is None: self._components = self.components_from_ID(ID) else: ValueError(f'\nOnly one of ID or components must be provided, but both were provided (ID={ID}, components={components}).') if MW is None: # calculate the MW as the sum of its parts MW = sum(species.MW * stoich for species, stoich in components.items()) Species.__init__(self, ID=ID, MW=MW) def ID_from_components(self, components): components_sorted = sorted(components.items(), key=lambda i: i[0].ID) ID = '' for c in components_sorted: if c[1]==1: ID+='~'+c[0].ID else: ID+='~'+str(c[1])+'*('+c[0].ID+')' ID = ID[1:] return ID #%% class Compartment: """ A physical space with a volume. Parameters ---------- ID : str Compartment identifier, e.g., 'c' (cytosol), 'm' (mitochondria). volume : float Volume [L] of this compartment. """ def __init__(self, ID, volume=1.0): self.ID = ID self.volume = float(volume) #%%
[docs] class SpeciesSystem(): """ Abstract class for a system containing defined chemical species. Note this class will, by convention, use "sp" to denote a single species and use "sps" to denote multiple species. Parameters ---------- ID : str ID. all_sps : list List of Species objects or strings. If the latter, new Species objects will be created. concentrations : list or np.ndarray, optional Concentrations of species, indexed the same way as all_sps. Defaults to zero-array of length equal to that of all_sps. compartments: dict Dictionary with keys as compartment IDs and values as volumes. Volumes can be either a float or a callable that returns a float given the SpeciesSystem object as an argument. """
[docs] def __init__(self, ID, all_sps, concentrations=None, compartments=None, default_volume=1.0): self.ID = ID # compartments: dict like {'c': 1.0, 'm': 0.5} (liters) self._compartments = {} if compartments is not None: for k, v in compartments.items(): if not callable(v): self._compartments[k] = float(v) else: self._compartments[k] = v else: # Provide a default unnamed compartment if none specified self._compartments[''] = default_volume processed_all_sps = [] for i in all_sps: if isinstance(i, str): # parse "ID[comp]" if present if ('[' in i and i.endswith(']')): base, comp = i[:-1].split('[', 1) if comp not in self._compartments: # if unseen, add with default volume self._compartments[comp] = default_volume processed_all_sps.append(Species(ID=base, compartment=comp)) else: processed_all_sps.append(Species(ID=i)) elif isinstance(i, Species): # ensure its compartment exists comp = i.compartment or '' if comp not in self._compartments: self._compartments[comp] = default_volume processed_all_sps.append(i) else: raise TypeError(f"\nProvided member of all_sps '{i}' must be of type str or Species.\n") self.all_sps = processed_all_sps if concentrations is None: concentrations = np.zeros(len(processed_all_sps)) else: concentrations = np.array(concentrations, dtype=float) self._concentrations = concentrations self._volume = default_volume
@property def compartments(self): """dict: {compartment_id: volume}""" return self._compartments
[docs] def set_compartment_volume(self, comp_ID, volume): self._compartments[comp_ID] = volume
[docs] def get_compartment_volume(self, comp_ID): _vol = self._compartments[comp_ID] if not callable(_vol): return _vol else: return _vol(self)
[docs] def species(self, ID_or_index): if isinstance(ID_or_index, int): return self.all_sps[ID_or_index] elif isinstance(ID_or_index, str): # Full-ID exact match first for sp in self.all_sps: if sp.ID == ID_or_index: return sp # If a bare ID was given and ambiguous, be explicit matches = [sp for sp in self.all_sps if sp.base_ID == ID_or_index] if len(matches) == 1: return matches[0] elif len(matches) > 1: raise ValueError( f"Ambiguous species '{ID_or_index}'. Use full ID like '{ID_or_index}[c]'." ) raise ValueError(f"Species '{ID_or_index}' not found.") else: raise ValueError(f"ID_or_index must be int or str, got {type(ID_or_index)}")
@property def all_sp_IDs(self): # Return display IDs including compartment tags (so parsers/events work) return [sp.ID for sp in self.all_sps]
[docs] def index_from_ID(self, sp_ID: str) -> int: # Exact full-ID match required; fall back to base_ID if unique for i, sp in enumerate(self.all_sps): if sp.ID == sp_ID: return i matches = [i for i, sp in enumerate(self.all_sps) if sp.base_ID == sp_ID] if len(matches) == 1: return matches[0] elif len(matches) > 1: raise ValueError( f"Ambiguous species '{sp_ID}'. Use full ID like '{sp_ID}[c]'." ) raise ValueError(f"Species '{sp_ID}' not found.")
# in case explicit base_ID indexing is ever needed
[docs] def index_from_base_ID(self, base_ID: str) -> int: matches = [i for i, sp in enumerate(self.all_sps) if sp.base_ID == base_ID] if len(matches) == 1: return matches[0] elif len(matches) > 1: raise ValueError( f"Ambiguous base_ID '{base_ID}'. Use index_from_ID with a full ID." ) raise ValueError(f"Species with base_ID '{base_ID}' not found.")
[docs] def index(self, sp): if isinstance(sp, Species): return self.all_sps.index(sp) elif isinstance(sp, str): return self.index_from_ID(sp)
@property def amounts(self): """ Amounts = concentration * compartment_volume for each species. """ amts = np.zeros_like(self._concentrations) for i, sp in enumerate(self.all_sps): comp = sp.compartment or '' # vol = self._compartments.get(comp, self._volume) vol = self.get_compartment_volume(comp) amts[i] = vol * self._concentrations[i] return amts
[docs] def indices(self, some_sps): all_sps = self.all_sps indices = [all_sps.index(i) for i in some_sps] return indices
[docs] def contains(self, sp): if sp in self.all_sps + self.all_sp_IDs: return True else: return False
@property def concentrations(self): return self._concentrations @concentrations.setter def concentrations(self, concentrations): self._concentrations = concentrations
[docs] def concentration(self, sp_ID): concs = self.concentrations return concs[self.index_from_ID(sp_ID)]
[docs] def add_species(self, species, concentration=0.0): all_sps = self.all_sps if isinstance(species, str): species = Species(ID=species) all_sps.append(species) elif isinstance(species, Species): all_sps.append(species) else: raise TypeError(f"\nProvided member of all_sps '{species}' must be of type str or Species.\n") concs = list(self._concentrations) concs.insert(all_sps.index(species), concentration) self._concentrations = np.array(concs)
[docs] def remove_species(self, species): all_sps, all_sp_IDs = self.all_sps, self.all_sp_IDs index = None if isinstance(species, str): index = all_sp_IDs.index(species) elif isinstance(species, Species): index = all_sps.index(species) else: raise TypeError(f"\nProvided member of all_sps '{species}' must be of type str or Species.\n") all_sps.pop(index) concs = list(self._concentrations) concs.pop(index) self._concentrations = np.array(concs)
@property def volume(self): return self._volume @volume.setter def volume(self, volume): self._volume = volume