Source code for pykappa.system

"""Implements simulation of models."""

import os
import shutil
import tempfile
import random
import warnings
import csv
import subprocess
from collections import defaultdict
from dataclasses import dataclass
from functools import cached_property
from types import MappingProxyType
from typing import Optional, Iterable, Mapping, Self, TYPE_CHECKING

import cloudpickle

if TYPE_CHECKING:
    from graphviz import Source

from pykappa.mixture import Mixture
from pykappa.rule import Rule
from pykappa.pattern import Component, Pattern, Site
from pykappa.analysis import Monitor
from pykappa.expression import Expression
from pykappa._utils import str_table


[docs] @dataclass(frozen=True) class RuleTally: """Counts outcomes of stochastic attempts to apply a rule.""" applied: int = 0 failed: int = 0 @property def attempts(self) -> int: """The total number of attempted rule applications.""" return self.applied + self.failed
[docs] class System: """A Kappa system containing agents, rules, observables, and variables for simulation.""" _mixture: Mixture _rules: dict[str, Rule] _observables: dict[str, Expression] _variables: dict[str, Expression] _site_defaults: dict[str, Mapping[str, str]] _tokens: dict[str, float] _monitor: Optional["Monitor"] _time: float _next_update_time: Optional[float] _reactivity_cache: Optional[tuple[float, ...]] _tallies: dict[str, RuleTally] _rng: random.Random # Random number generator for reproducibility of updates
[docs] @classmethod def read_ka(cls, filepath: str, seed: Optional[int] = None) -> Self: """Read and parse a Kappa .ka file to create a System. Args: filepath: Path to the Kappa file. seed: Random seed for reproducibility. """ with open(filepath) as f: return cls.from_ka(f.read(), seed=seed)
[docs] @classmethod def from_ka(cls, ka_str: str, seed: Optional[int] = None) -> Self: """Create a System from a Kappa (.ka style) string. Args: ka_str: Kappa language string containing a system definition. seed: Random seed for reproducibility. """ from pykappa._parsing import ( kappa_parser, KappaTransformer, ExpressionTransformer, ) input_tree = kappa_parser.parse(ka_str) assert input_tree.data == "kappa_input" variables: dict[str, Expression] = {} observables: dict[str, Expression] = {} token_inits: list[tuple[str, Expression]] = [] rules: list[Rule] = [] inits: list[tuple[Expression, Pattern]] = [] for child in input_tree.children: tag = child.data if tag in ["f_rule", "fr_rule", "ambi_rule", "ambi_fr_rule"]: new_rules = KappaTransformer().transform(child) rules.extend(new_rules) elif tag == "variable_declaration": name_tree = child.children[0] assert name_tree.data == "declared_variable_name" name = name_tree.children[0].value.strip("'\"") expr_tree = child.children[1] assert expr_tree.data == "algebraic_expression" value = ExpressionTransformer.from_tree(expr_tree) variables[name] = value elif tag == "plot_declaration": raise NotImplementedError elif tag == "observable_declaration": label_tree = child.children[0] assert isinstance(label_tree, str) name = label_tree.strip("'\"") expr_tree = child.children[1] assert expr_tree.data == "algebraic_expression" value = ExpressionTransformer.from_tree(expr_tree) observables[name] = value elif tag == "signature_declaration": pass # ignore agent signatures elif tag == "declared_token": pass # token declarations are handled via init_declaration elif tag == "init_declaration": amount = ExpressionTransformer.from_tree(child.children[0]) target = child.children[1] if target.data == "declared_token_name": token_inits.append((str(target.children[0]), amount)) else: pattern = KappaTransformer().transform(target) inits.append((amount, pattern)) elif tag == "definition": pass # %def: directives not used elif tag == "pattern": raise NotImplementedError else: raise TypeError(f"Unsupported input type: {tag}") system = cls(None, rules, observables, variables, seed=seed) for init in inits: system.add(init[1], int(init[0].evaluate(system))) for token_name, amount_expr in token_inits: system._tokens[token_name] = float(amount_expr.evaluate(system)) return system
[docs] @classmethod def from_kappa( cls, mixture: Optional[dict[str, int]] = None, rules: Optional[Iterable[str]] = None, observables: Optional[list[str] | dict[str, str]] = None, variables: Optional[dict[str, str]] = None, *args, **kwargs, ) -> Self: """Create a System from Kappa strings. Args: mixture: Dictionary mapping agent patterns to initial counts. rules: Iterable of rule strings in Kappa format. observables: List of observable expressions or dict mapping names to expressions. variables: Dictionary mapping variable names to expressions. *args: Additional arguments passed to System constructor. **kwargs: Additional keyword arguments passed to System constructor. """ real_rules = [] if rules is not None: for rule in rules: real_rules.extend(Rule.list_from_kappa(rule)) if observables is None: real_observables = {} elif isinstance(observables, list): real_observables = { f"o{i}": Expression.from_kappa(obs) for i, obs in enumerate(observables) } else: real_observables = { name: Expression.from_kappa(obs) for name, obs in observables.items() } real_variables = ( {} if variables is None else {name: Expression.from_kappa(var) for name, var in variables.items()} ) system = cls( None, real_rules, real_observables, real_variables, *args, **kwargs ) if mixture is not None: for pattern_str, count in mixture.items(): system.add(pattern_str, count) return system
def __init__( self, mixture: Optional[Mixture] = None, rules: Optional[Iterable[Rule]] = None, observables: Optional[dict[str, Expression]] = None, variables: Optional[dict[str, Expression]] = None, tokens: Optional[dict[str, float]] = None, site_defaults: Optional[dict[str, dict[str, str]]] = None, monitor: bool = True, seed: Optional[int] = None, ): """ Args: mixture: Initial mixture state. rules: Collection of rules to apply. observables: Dictionary of observable expressions. variables: Dictionary of variable expressions. tokens: Dictionary of token names to initial values. site_defaults: Maps agent types to site default states. monitor: Whether to enable monitoring of simulation history. seed: Random seed for reproducibility. """ self._rng = random.Random() if seed is None else random.Random(seed) self._rules = ( {} if rules is None else {f"r{i}": rule for i, rule in enumerate(rules)} ) if mixture is None: mixture = Mixture( track_components=any( rule.component_constraint != "any" for rule in self._rules.values() ) ) self._observables = {} if observables is None else dict(observables) self._variables = {} if variables is None else dict(variables) self._site_defaults = { agent_type: MappingProxyType(dict(defaults)) for agent_type, defaults in (site_defaults or {}).items() } self._set_mixture(mixture) self._time = 0 self._next_update_time = None self._reactivity_cache = None self._tokens = {} if tokens is None else dict(tokens) self._tallies = {} self._monitor = Monitor(self) if monitor else None def __str__(self): return self.kappa_str def __getitem__(self, name: str) -> int | float: """Get the value of an observable or variable. Raises: KeyError: If name doesn't correspond to any observable or variable. """ if name in self._observables: return self._observables[name].evaluate(self) elif name in self._variables: return self._variables[name].evaluate(self) else: raise KeyError( f"Name {name} doesn't correspond to a declared observable or variable" ) def __setitem__(self, name: str, value: float) -> None: """Update an existing variable to a new numeric value. Args: name: Name of a declared variable. value: New numeric value. Raises: KeyError: If the name is not a declared variable. ValueError: If the declared variable is not a numeric literal. """ if name not in self._variables: raise KeyError(f"'{name}' is not a declared variable") if self._variables[name]._type != "literal": raise ValueError( f"'{name}' is not a numeric literal and cannot be reassigned" ) self._variables[name] = Expression("literal", value=value) self._invalidate_next_event()
[docs] def set_token(self, name: str, value: float) -> None: """Set a token's value.""" self._tokens[name] = value self._invalidate_next_event()
@property def mixture(self) -> Mixture: """The current state of agents and their connections.""" return self._mixture @property def time(self) -> float: """The current simulation time.""" return self._time @property def next_update_time(self) -> Optional[float]: """The time of the next update, or ``None`` if the system is nonreactive.""" if self._next_update_time is None: if (reactivity := self.reactivity) == 0: return None self._next_update_time = self._time + self._rng.expovariate(reactivity) return self._next_update_time @property def monitor(self) -> Optional["Monitor"]: """The monitor tracking simulation history, if enabled.""" return self._monitor @property def rules(self) -> Mapping[str, Rule]: """Maps rule names to rules.""" return MappingProxyType(self._rules) @cached_property def _signatures(self) -> dict[str, frozenset[str]]: sites_by_type: dict[str, set[str]] = defaultdict(set) for rule in self._rules.values(): for pattern in (rule.left, rule.right): for agent in pattern.agents: if agent is not None: sites_by_type[agent.type].update(site.label for site in agent) return { agent_type: frozenset(sites) for agent_type, sites in sites_by_type.items() } @property def signatures(self) -> Mapping[str, frozenset[str]]: """The complete site interface for each agent type as inferred from the rule set.""" return MappingProxyType(self._signatures) @property def site_defaults(self) -> Mapping[str, Mapping[str, str]]: """Maps agent types to their default site states.""" return MappingProxyType(self._site_defaults) @property def observables(self) -> Mapping[str, Expression]: """Maps observable names to expressions.""" return MappingProxyType(self._observables) @property def variables(self) -> Mapping[str, Expression]: """Maps variable names to expressions.""" return MappingProxyType(self._variables) @property def tokens(self) -> Mapping[str, float]: """Maps token names to their current values.""" return MappingProxyType(self._tokens) @property def tallies(self) -> Mapping[str, RuleTally]: """Maps rule names to counts of stochastic application outcomes.""" return MappingProxyType(self._tallies) @property def tally_totals(self) -> RuleTally: """Counts of all stochastic application attempts, combined across rules.""" return RuleTally( applied=sum(tally.applied for tally in self._tallies.values()), failed=sum(tally.failed for tally in self._tallies.values()), ) @property def tallies_table(self) -> str: """A formatted summary of stochastic rule application outcomes.""" totals = self.tally_totals return str_table( [ [str(rule), tally.applied, tally.failed, tally.attempts] for rule, tally in self._tallies.items() ] + [["Total", totals.applied, totals.failed, totals.attempts]], header=["Rule", "Applied", "Failed", "Attempts"], ) @cached_property def _reversible_rules(self) -> list[tuple[str, str]]: """Find forward/reverse rule pairs by checking pattern symmetry.""" names = list(self._rules.keys()) pairs = [] used = set() for i, name_a in enumerate(names): if name_a in used: continue rule_a = self._rules[name_a] for name_b in names[i + 1 :]: if name_b in used: continue rule_b = self._rules[name_b] if rule_a.left.n_isomorphisms( rule_b.right ) and rule_a.right.n_isomorphisms(rule_b.left): pairs.append((name_a, name_b)) used.add(name_a) used.add(name_b) break return pairs @property def kappa_str(self) -> str: """The system representation in Kappa (.ka style) format.""" kappa_list = [] # Append the inferred agent signature at the top for agent, sites in self._signatures.items(): sig = ", ".join(sites) kappa_list.append(f"%agent: {agent}({sig})") # Format reversible rules with <-> notation pairs = self._reversible_rules paired = {name for pair in pairs for name in pair} for fwd_name, rev_name in pairs: fwd = self._rules[fwd_name] rev = self._rules[rev_name] kappa_list.append( f"{fwd.left.kappa_str} <-> {fwd.right.kappa_str} " f"@ {fwd._rate_str}, {rev._rate_str}" ) # Otherwise format with -> notation for name, rule in self._rules.items(): if name not in paired: kappa_list.append(rule.kappa_str) for var_name, var in self._variables.items(): kappa_list.append(f"%var: '{var_name}' {var.kappa_str}") for obs_name, obs in self._observables.items(): obs_str = ( f"|{obs.kappa_str}|" if isinstance(obs, Component) else obs.kappa_str ) kappa_list.append(f"%obs: '{obs_name}' {obs_str}") kappa_list.append(self._mixture.kappa_str) return "\n".join(kappa_list)
[docs] def write_ka(self, filepath: str) -> None: """Write system information to a Kappa file.""" with open(filepath, "w") as f: f.write(self.kappa_str)
[docs] def save(self, filepath: str) -> None: """Save a checkpoint that can be continued with :meth:`System.load`.""" with open(filepath, "wb") as f: cloudpickle.dump(self, f)
[docs] @classmethod def load(cls, filepath: str) -> Self: """Load a trusted checkpoint created by :meth:`System.save`. Note: Checkpoints must only be loaded from a trusted source and are intended for use with the same PyKappa and Python versions. """ with open(filepath, "rb") as f: system = cloudpickle.load(f) if not isinstance(system, cls): raise TypeError( f"Checkpoint contains {type(system).__name__}, not {cls.__name__}" ) return system
def _set_mixture(self, mixture: Mixture) -> None: """Set the system's mixture and update tracking.""" self._mixture = mixture for rule in self._rules.values(): for component in rule.left.components: if component not in mixture._embeddings: mixture._track_component(component) for expr in [*self._observables.values(), *self._variables.values()]: for component_expr in expr._filter("component_pattern"): mixture._track_component(component_expr._attrs["value"]) def _invalidate_next_event(self) -> None: """Discard cached reactivities after a state or rate change.""" self._next_update_time = None self._reactivity_cache = None def _enforce_signature(self, agent: "Agent") -> None: """Validate agent type and sites against the inferred signature and fill missing sites. Raises: ValueError: If the agent type is unknown or has sites not in the signature. """ if not self._signatures: return known = self._signatures.get(agent.type) if known is None: raise ValueError( f"Agent type '{agent.type}' is not declared by any rule. " f"Known agent types: {set(self._signatures)}" ) unknown = {s.label for s in agent} - known if unknown: raise ValueError( f"Agent '{agent.type}' has unknown site(s) {unknown}. " f"Known sites for this type: {known}" ) for label in known - agent.interface.keys(): agent._add_site( Site( label, self._site_defaults.get(agent.type, {}).get(label, "?"), "." ) )
[docs] def add(self, pattern: Pattern | Component | str, n_copies: int = 1) -> None: """Add instances of a pattern or component to the mixture using inferred agent signatures.""" if isinstance(pattern, str): pattern = Pattern.from_kappa(pattern) components = [pattern] if isinstance(pattern, Component) else pattern.components for component in components: for _ in range(n_copies): self._mixture._add_component( component, prepare_agent=self._enforce_signature ) self._invalidate_next_event()
[docs] def remove(self, component: Component) -> None: """Remove a specific component from the current mixture.""" self._mixture._remove_component(component) self._invalidate_next_event()
@property def reactivity(self) -> float: """The total reactivity of the system.""" return sum(self._rule_reactivities()) def _rule_reactivities(self) -> tuple[float, ...]: """Return rule reactivities cached for the pending simulation event.""" if self._reactivity_cache is None: self._reactivity_cache = tuple( rule.reactivity(self) for rule in self._rules.values() ) return self._reactivity_cache
[docs] def advance_time_to(self, time: float) -> None: """Advance time without applying an update. Raises: ValueError: If ``time`` is outside the interval before the next update. """ if time < self._time: raise ValueError("cannot run backwards in simulation time") if self.next_update_time is not None and time >= self.next_update_time: raise ValueError("cannot advance to or past the next update") self._time = time
[docs] def update(self) -> None: """Perform one simulation step.""" if self._monitor is not None and not self._monitor.history["time"]: self._monitor.update() if (next_update_time := self.next_update_time) is None: warnings.warn("system has no reactivity", RuntimeWarning) if self._monitor is not None: self._monitor.update() return self._time = next_update_time self._next_update_time = None rule = self._rng.choices( list(self._rules.values()), weights=self._rule_reactivities(), )[0] # Apply the rule update = rule._select(self._mixture, rng=self._rng) name = str(rule) tally = self._tallies.get(name, RuleTally()) if update is not None: self._tallies[name] = RuleTally( applied=tally.applied + 1, failed=tally.failed ) for agent in update.agents_to_add: self._enforce_signature(agent) previous_components, current_components = self._mixture._apply_update( update ) for expr, name in rule.token_updates: self._tokens[name] += expr.evaluate(self) self._reactivity_cache = tuple( ( candidate._reactivity_from_embeddings( candidate.update_component_weights( self._mixture, previous_components, current_components ), self, ) if candidate.component_constraint != "any" else candidate.reactivity(self) ) for candidate in self._rules.values() ) else: self._tallies[name] = RuleTally( applied=tally.applied, failed=tally.failed + 1 ) if self._monitor is not None: self._monitor.update() if update is None: self._reactivity_cache = None
[docs] def apply(self, transformation: str, n: int = 1) -> None: """Apply a transformation immediately for a specified number of times. Unlike `update`, this does not advance simulation time or use stochastic selection — the rule fires exactly ``n`` times using randomly chosen embeddings. Args: transformation: Kappa string representation of the rule. n: Number of times to apply the rule. """ rule = Rule.from_kappa(transformation + " @ 0") self._invalidate_next_event() for _ in range(n): update = rule._select(self._mixture, rng=self._rng) if update is not None: for agent in update.agents_to_add: self._enforce_signature(agent) self._mixture._apply_update(update)
[docs] def update_via_kasim(self, time: float) -> None: """Simulate for a given amount of time using KaSim. Note: KaSim must be installed and in the PATH. Some features are not compatible between PyKappa and KaSim. """ self._invalidate_next_event() assert shutil.which("KaSim"), "KaSim not found in the PATH." if any(rule.n_symmetries > 1 for rule in self._rules.values()): warnings.warn( "Some rules have multiple symmetries. " "PyKappa normalizes reactivities accordingly: results may differ from KaSim." ) history = None # the observable history with tempfile.TemporaryDirectory() as tmpdirname: snap_path = os.path.join(tmpdirname, "snap.ka") out_path = os.path.join(tmpdirname, "out.ka") in_path = os.path.join(tmpdirname, "in.ka") output_lines = [ self.kappa_str, f'%mod: alarm {time} do $SNAPSHOT "{snap_path}";', ] if self._observables: output_lines.append("%mod: [true] do $PLOTENTRY; repeat [true]") # Run KaSim with open(in_path, "w") as f: f.write("\n".join(output_lines)) subprocess.run( ["KaSim", in_path, "-l", str(time), "-d", tmpdirname, "-o", out_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) # Read KaSim output with open(snap_path) as f: content = f.read() if self._observables: with open(out_path) as f: reader = csv.reader(f) header = next(row for row in reader if row and row[0] == "[T]") columns = ["time", *header[1:]] history = {name: [] for name in columns} for row in reader: history["time"].append(self._time + float(row[0])) for name, value in zip(columns[1:], row[1:]): history[name].append(float(value)) content = content.replace( ",\n", ", " ) # KaSim splits long components across lines output_kappa_str = "".join( line.split("/")[0] + line.split("/")[-1] for line in content.splitlines(keepends=True) if line.startswith("%init") ) # Apply the update self._set_mixture(System.from_ka(output_kappa_str).mixture) self._time += time # Update the monitor if self._monitor is not None and history is not None: for name, values in history.items(): self._monitor.history[name].extend(values)
[docs] def kd_table(self, volume: float = 1.0) -> str: """Summarize kinetic constants of two-component binding/unbinding rules given volume in liters.""" from pykappa.analysis import _kd_table return _kd_table(self, volume=volume)
[docs] def rule_graph(self) -> "Source": """Visualize a ruleset as a site graph of local transformations. Solid edges = bond formation; dashed edges = bond breaking. Sites that change state show their transition as ``site {old→new}``. Creation and degradation are shown as directed edges to/from a sink node. Note: This is a lossy projection that neglects conditions of transformations; multiple rulesets can yield the same graph. """ from pykappa.analysis import _rule_graph return _rule_graph(self)
[docs] def contact_map(self) -> "Source": """Generate a graphviz contact map using the KaSa static analyzer.""" from pykappa.analysis import _contact_map return _contact_map(self)