Source code for synkit.CRN.Props.dynamics

from __future__ import annotations

from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple

import networkx as nx
import numpy as np
from networkx.algorithms import bipartite as nx_bipartite

try:
    import sympy as sp  # type: ignore

    _SYMPY_AVAILABLE = True
except Exception:
    sp = None  # type: ignore
    _SYMPY_AVAILABLE = False

from .stoich import build_S, build_S_minus_plus

__all__ = [
    "StructuralSingularitySummary",
    "symbolic_reactivity_matrix",
    "symbolic_jacobian",
    "jacobian_sparsity",
    "jacobian_sign_pattern",
    "species_influence_graph",
    "structural_singularity_summary",
]


def _require_sympy() -> None:
    """
    Ensure that SymPy is available before using symbolic CRN dynamics helpers.

    This helper centralizes the optional dependency check used by symbolic
    routines such as symbolic Jacobian construction and exact determinant
    evaluation.

    :raises ImportError:
        If :mod:`sympy` is not installed or could not be imported.

    Example
    -------
    .. code-block:: python

        try:
            _require_sympy()
        except ImportError as exc:
            print(exc)
    """
    if not _SYMPY_AVAILABLE or sp is None:
        raise ImportError(
            "sympy is required for symbolic CRN dynamics helpers. "
            "Please install it, e.g. `pip install sympy`."
        )


def _safe_symbol_token(value: Any) -> str:
    """
    Convert an arbitrary object into a SymPy-safe symbol token.

    Non-alphanumeric characters are replaced by underscores. If the resulting
    token is empty, ``\"x\"`` is used. If the token starts with a digit, a
    leading underscore is prepended.

    :param value:
        Arbitrary object to convert into a symbol-safe token.
    :type value:
        Any

    :returns:
        Sanitized token suitable for building SymPy symbol names.
    :rtype:
        str

    Example
    -------
    .. code-block:: python

        _safe_symbol_token("A")
        _safe_symbol_token("species-1")
        _safe_symbol_token("3-node")

        # possible outputs:
        # "A"
        # "species_1"
        # "_3_node"
    """
    text = str(value)
    token = "".join(ch if ch.isalnum() else "_" for ch in text)
    if not token:
        token = "x"
    if token[0].isdigit():
        token = f"_{token}"
    return token


def _sympy_matrix_from_numpy(A: np.ndarray) -> "sp.Matrix":
    """
    Convert a numeric NumPy array into a SymPy matrix.

    Each entry is passed through :func:`sympy.nsimplify` so that integer and
    rational values are preserved when possible.

    :param A:
        Numeric array to convert.
    :type A:
        numpy.ndarray

    :returns:
        SymPy matrix with simplified symbolic entries.
    :rtype:
        sp.Matrix

    :raises ImportError:
        If :mod:`sympy` is not available.

    Example
    -------
    .. code-block:: python

        import numpy as np

        A = np.array([[1.0, -1.0], [0.0, 2.0]])
        M = _sympy_matrix_from_numpy(A)
        print(M)
    """
    _require_sympy()
    return sp.Matrix([[sp.nsimplify(x) for x in row] for row in np.asarray(A)])


def _structural_sign_pattern(
    S: np.ndarray,
    S_minus: np.ndarray,
    *,
    tol: float = 1e-12,
) -> np.ndarray:
    """
    Compute the structural sign pattern of the symbolic Jacobian.

    For a symbolic Jacobian of the form ``G = S R``, the sign of entry
    ``G[i, k]`` depends on the stoichiometric effects in row ``i`` and on
    whether species ``k`` participates as a reactant in the corresponding
    rule columns. This routine derives the sign pattern directly from the
    stoichiometric matrices without constructing the full symbolic Jacobian.

    Returned entries are one of:

    - ``"0"``
    - ``"+"``
    - ``"-"``
    - ``"mixed"``

    :param S:
        Net stoichiometric matrix with shape ``(n_species, n_rules)``.
    :type S:
        numpy.ndarray
    :param S_minus:
        Reactant stoichiometric matrix with shape ``(n_species, n_rules)``.
    :type S_minus:
        numpy.ndarray
    :param tol:
        Numerical tolerance used to test structural positivity or negativity.
    :type tol:
        float

    :returns:
        Matrix of sign labels with shape ``(n_species, n_species)``.
    :rtype:
        numpy.ndarray

    Example
    -------
    .. code-block:: python

        import numpy as np

        S = np.array([
            [-1,  0],
            [ 1, -1],
            [ 0,  1],
        ], dtype=float)

        S_minus = np.array([
            [1, 0],
            [0, 1],
            [0, 0],
        ], dtype=float)

        P = _structural_sign_pattern(S, S_minus)
        print(P)
    """
    n_species, n_rules = S.shape
    out = np.full((n_species, n_species), "0", dtype=object)

    for i in range(n_species):  # affected species / row of G
        for k in range(n_species):  # source species / column of G
            has_pos = False
            has_neg = False

            for j in range(n_rules):
                if float(S_minus[k, j]) > tol:
                    coeff = float(S[i, j])
                    if coeff > tol:
                        has_pos = True
                    elif coeff < -tol:
                        has_neg = True

                    if has_pos and has_neg:
                        out[i, k] = "mixed"
                        break
            else:
                if has_pos and has_neg:
                    out[i, k] = "mixed"
                elif has_pos:
                    out[i, k] = "+"
                elif has_neg:
                    out[i, k] = "-"
                else:
                    out[i, k] = "0"

    return out


def _jacobian_pattern_bipartite(A: np.ndarray) -> Tuple[nx.Graph, List[str], List[str]]:
    """
    Build the row/column bipartite graph for a Jacobian sparsity pattern.

    The bipartite graph is used for structural-rank diagnostics via maximum
    matching. Row nodes correspond to Jacobian rows and column nodes correspond
    to Jacobian columns. An edge is added when the corresponding entry in the
    sparsity matrix is nonzero.

    :param A:
        Boolean or truthy/falsy Jacobian sparsity matrix.
    :type A:
        numpy.ndarray

    :returns:
        Tuple containing:

        - bipartite graph,
        - ordered row-node names,
        - ordered column-node names.
    :rtype:
        Tuple[nx.Graph, List[str], List[str]]

    Example
    -------
    .. code-block:: python

        import numpy as np

        A = np.array([
            [True, False],
            [True, True],
        ], dtype=bool)

        B, row_nodes, col_nodes = _jacobian_pattern_bipartite(A)
        print(row_nodes)
        print(col_nodes)
        print(B.number_of_edges())
    """
    n_rows, n_cols = A.shape
    B = nx.Graph()

    row_nodes = [f"row:{i}" for i in range(n_rows)]
    col_nodes = [f"col:{j}" for j in range(n_cols)]

    B.add_nodes_from(row_nodes, bipartite=0)
    B.add_nodes_from(col_nodes, bipartite=1)

    for i in range(n_rows):
        for j in range(n_cols):
            if bool(A[i, j]):
                B.add_edge(row_nodes[i], col_nodes[j])

    return B, row_nodes, col_nodes


[docs] @dataclass class StructuralSingularitySummary: """ Summary of structural singularity diagnostics for a symbolic Jacobian. The diagnostics are species-level because the Jacobian is a species-by-species object, even though the underlying SynCRN graph is bipartite with species nodes and rule nodes. :param n_species: Number of species, i.e. Jacobian dimension. :type n_species: int :param structural_rank: Structural rank inferred from the Jacobian sparsity pattern. :type structural_rank: int :param has_perfect_matching: Whether the bipartite sparsity graph admits a perfect matching. :type has_perfect_matching: bool :param pattern_singular: Whether the Jacobian is singular at the structural-pattern level. :type pattern_singular: bool :param determinant_checked: Whether an exact symbolic determinant was computed. :type determinant_checked: bool :param determinant_expr: Exact symbolic determinant expression, if evaluated. :type determinant_expr: Optional[Any] :param determinant_is_zero: Whether the exact symbolic determinant simplified to zero. :type determinant_is_zero: Optional[bool] Example ------- .. code-block:: python summary = StructuralSingularitySummary( n_species=3, structural_rank=2, has_perfect_matching=False, pattern_singular=True, determinant_checked=False, ) print(summary.classification) print(summary.to_dict()) """ n_species: int structural_rank: int has_perfect_matching: bool pattern_singular: bool determinant_checked: bool determinant_expr: Optional[Any] = None determinant_is_zero: Optional[bool] = None @property def classification(self) -> str: """ Return a concise structural-singularity classification label. Possible values include pattern-level singularity, exact symbolic singularity, exact symbolic nonsingularity, or the case where only the sparsity-pattern analysis was performed. :returns: Classification string summarizing the diagnostic outcome. :rtype: str Example ------- .. code-block:: python summary = StructuralSingularitySummary( n_species=4, structural_rank=4, has_perfect_matching=True, pattern_singular=False, determinant_checked=True, determinant_is_zero=False, ) print(summary.classification) """ if self.pattern_singular: return "singular_by_pattern" if self.determinant_is_zero is True: return "singular_by_exact_determinant" if self.determinant_is_zero is False: return "structurally_nonsingular" return "pattern_nonsingular_exact_unchecked"
[docs] def to_dict(self) -> Dict[str, Any]: """ Convert the summary to a plain dictionary. Symbolic determinant expressions are stringified so the result is easier to serialize or log. :returns: Plain dictionary representation of the summary. :rtype: Dict[str, Any] Example ------- .. code-block:: python d = summary.to_dict() print(d["classification"]) """ return { "n_species": self.n_species, "structural_rank": self.structural_rank, "has_perfect_matching": self.has_perfect_matching, "pattern_singular": self.pattern_singular, "determinant_checked": self.determinant_checked, "determinant_expr": ( None if self.determinant_expr is None else str(self.determinant_expr) ), "determinant_is_zero": self.determinant_is_zero, "classification": self.classification, }
def __str__(self) -> str: """ Return a readable multiline summary. :returns: Human-readable diagnostic summary. :rtype: str Example ------- .. code-block:: python print(summary) """ lines = [ "StructuralSingularitySummary(", f" n_species = {self.n_species}", f" structural_rank = {self.structural_rank}", f" has_perfect_matching = {self.has_perfect_matching}", f" pattern_singular = {self.pattern_singular}", f" determinant_checked = {self.determinant_checked}", f" determinant_is_zero = {self.determinant_is_zero}", f" classification = {self.classification}", ")", ] return "\n".join(lines)
[docs] def symbolic_reactivity_matrix( crn: Any, *, symbol_prefix: str = "rprime", tol: float = 1e-12, ) -> Tuple[List[Any], List[Any], "sp.Matrix"]: """ Build the symbolic reactivity matrix ``R``. For a SynCRN with ``n_species`` species nodes and ``n_rules`` rule nodes, the matrix ``R`` has shape ``(n_rules, n_species)``. Entry ``R[j, i]`` is a positive symbolic variable if species ``i`` is a reactant of rule ``j``, and zero otherwise. This matrix captures the structural dependence of reaction rates on species concentrations without assuming a specific kinetic law beyond positive reactant sensitivity. :param crn: SynCRN graph or SynCRN-like object in the species/rule representation. :type crn: Any :param symbol_prefix: Prefix used when naming symbolic reactivity variables. :type symbol_prefix: str :param tol: Tolerance used to decide whether a reactant stoichiometric entry is structurally positive. :type tol: float :returns: Tuple containing: - species node order, - rule node order, - symbolic reactivity matrix ``R``. :rtype: Tuple[List[Any], List[Any], sp.Matrix] :raises ImportError: If :mod:`sympy` is not available. Example ------- .. code-block:: python species_order, rule_order, R = symbolic_reactivity_matrix(crn) print(species_order) print(rule_order) print(R) """ _require_sympy() species_order, rule_order, S_minus, _S_plus = build_S_minus_plus(crn) n_species = len(species_order) n_rules = len(rule_order) rows = [] for j in range(n_rules): r_tok = _safe_symbol_token(rule_order[j]) row = [] for i in range(n_species): s_tok = _safe_symbol_token(species_order[i]) if float(S_minus[i, j]) > tol: row.append(sp.Symbol(f"{symbol_prefix}_{r_tok}_{s_tok}", positive=True)) else: row.append(sp.Integer(0)) rows.append(row) R = sp.Matrix(rows) return species_order, rule_order, R
[docs] def symbolic_jacobian( crn: Any, *, symbol_prefix: str = "rprime", tol: float = 1e-12, ) -> Tuple[List[Any], List[Any], "sp.Matrix"]: """ Build the symbolic Jacobian ``G = S R``. Here ``S`` is the species-by-rule stoichiometric matrix and ``R`` is the rule-by-species symbolic reactivity matrix induced by reactant incidence. The resulting Jacobian is a species-by-species symbolic matrix describing local structural influence in the CRN dynamics. :param crn: SynCRN graph or SynCRN-like object in the species/rule representation. :type crn: Any :param symbol_prefix: Prefix used for symbolic reactivity variables. :type symbol_prefix: str :param tol: Tolerance forwarded to :func:`symbolic_reactivity_matrix`. :type tol: float :returns: Tuple containing: - species node order, - rule node order, - symbolic Jacobian ``G``. :rtype: Tuple[List[Any], List[Any], sp.Matrix] :raises ImportError: If :mod:`sympy` is not available. Example ------- .. code-block:: python species_order, rule_order, G = symbolic_jacobian(crn) print(G.shape) print(G) """ _require_sympy() species_order, rule_order, S = build_S(crn) _sp_order, _rule_order, R = symbolic_reactivity_matrix( crn, symbol_prefix=symbol_prefix, tol=tol, ) S_sym = _sympy_matrix_from_numpy(S) G = S_sym * R return species_order, rule_order, G
[docs] def jacobian_sparsity( crn: Any, *, tol: float = 1e-12, ) -> Tuple[List[Any], np.ndarray]: """ Return the boolean sparsity pattern of the symbolic Jacobian. Entry ``A[i, k]`` is ``True`` if species ``k`` can structurally influence species ``i`` through at least one rule under the local linearized dynamics. :param crn: SynCRN graph or SynCRN-like object in the species/rule representation. :type crn: Any :param tol: Tolerance used for structural zero testing. :type tol: float :returns: Tuple containing: - species node order, - boolean Jacobian sparsity matrix. :rtype: Tuple[List[Any], numpy.ndarray] Example ------- .. code-block:: python species_order, A = jacobian_sparsity(crn) print(species_order) print(A.astype(int)) """ species_order, _rule_order, S_minus, S_plus = build_S_minus_plus(crn) S = S_plus - S_minus sign_pat = _structural_sign_pattern(S, S_minus, tol=tol) A = sign_pat != "0" return species_order, A
[docs] def jacobian_sign_pattern( crn: Any, *, tol: float = 1e-12, ) -> Tuple[List[Any], np.ndarray]: """ Compute the structural sign pattern of the symbolic Jacobian. Returned entries are one of: - ``"0"`` - ``"+"`` - ``"-"`` - ``"mixed"`` The value ``"mixed"`` means that multiple structurally valid paths exist with conflicting positive and negative net effects. :param crn: SynCRN graph or SynCRN-like object in the species/rule representation. :type crn: Any :param tol: Tolerance used for structural zero testing. :type tol: float :returns: Tuple containing: - species node order, - Jacobian sign-pattern matrix. :rtype: Tuple[List[Any], numpy.ndarray] Example ------- .. code-block:: python species_order, P = jacobian_sign_pattern(crn) print(species_order) print(P) """ species_order, _rule_order, S_minus, S_plus = build_S_minus_plus(crn) S = S_plus - S_minus P = _structural_sign_pattern(S, S_minus, tol=tol) return species_order, P
[docs] def species_influence_graph( crn: Any, *, tol: float = 1e-12, use_labels: bool = False, ) -> nx.DiGraph: """ Build the species influence graph induced by the symbolic Jacobian. Nodes represent species. A directed edge ``u -> v`` is added when species ``u`` can structurally influence species ``v`` in the local linearized dynamics. Edge attribute ``sign`` is one of: - ``"+"`` - ``"-"`` - ``"mixed"`` Additional edge attributes record the original source and target species node identifiers. :param crn: SynCRN graph or SynCRN-like object in the species/rule representation. :type crn: Any :param tol: Tolerance used for structural zero testing. :type tol: float :param use_labels: If ``True`` and ``crn`` is a NetworkX graph, use node labels when available; otherwise use original node identifiers. :type use_labels: bool :returns: Directed species influence graph. :rtype: nx.DiGraph Example ------- .. code-block:: python G_inf = species_influence_graph(crn, use_labels=True) print(G_inf.nodes(data=True)) print(G_inf.edges(data=True)) """ species_order, P = jacobian_sign_pattern(crn, tol=tol) label_map: Dict[Any, Any] = {} if use_labels and isinstance( crn, (nx.Graph, nx.DiGraph, nx.MultiGraph, nx.MultiDiGraph) ): for s in species_order: label_map[s] = crn.nodes[s].get("label", s) else: for s in species_order: label_map[s] = s G_inf = nx.DiGraph() for s in species_order: G_inf.add_node(label_map[s], source_node=s) n_species = len(species_order) for i in range(n_species): # target row for k in range(n_species): # source column sign = str(P[i, k]) if sign != "0": G_inf.add_edge( label_map[species_order[k]], label_map[species_order[i]], sign=sign, source_species=species_order[k], target_species=species_order[i], ) return G_inf
[docs] def structural_singularity_summary( crn: Any, *, tol: float = 1e-12, max_exact_size: int = 7, symbol_prefix: str = "rprime", ) -> StructuralSingularitySummary: """ Diagnose structural singularity of the symbolic Jacobian. This routine performs two levels of analysis: 1. structural-rank and perfect-matching diagnostics on the Jacobian sparsity pattern, and 2. optional exact symbolic determinant evaluation for sufficiently small systems. Pattern-level singularity indicates that the Jacobian is singular for all admissible parameter values consistent with the sparsity structure. If the pattern is not singular, an exact determinant test may still detect symbolic cancellation and prove singularity for small systems. :param crn: SynCRN graph or SynCRN-like object in the species/rule representation. :type crn: Any :param tol: Tolerance used for structural zero testing. :type tol: float :param max_exact_size: Maximum number of species for which the exact symbolic determinant is computed. :type max_exact_size: int :param symbol_prefix: Prefix used for symbolic reactivity variables. :type symbol_prefix: str :returns: Structured summary of Jacobian structural-singularity diagnostics. :rtype: StructuralSingularitySummary :raises ImportError: If exact symbolic determinant evaluation is requested but :mod:`sympy` is not available. Example ------- .. code-block:: python summary = structural_singularity_summary( crn, max_exact_size=6, symbol_prefix="rprime", ) print(summary) print(summary.to_dict()) print(summary.classification) """ species_order, A = jacobian_sparsity(crn, tol=tol) n_species = len(species_order) B, row_nodes, _col_nodes = _jacobian_pattern_bipartite(A) matching = nx_bipartite.maximum_matching(B, top_nodes=set(row_nodes)) structural_rank = sum(1 for u in row_nodes if u in matching) has_perfect_matching = structural_rank == n_species pattern_singular = structural_rank < n_species determinant_checked = False determinant_expr: Optional[Any] = None determinant_is_zero: Optional[bool] = None if n_species <= max_exact_size: _require_sympy() determinant_checked = True _sp_order, _rule_order, G = symbolic_jacobian( crn, symbol_prefix=symbol_prefix, tol=tol, ) determinant_expr = sp.simplify(G.det()) determinant_is_zero = bool(determinant_expr == 0) return StructuralSingularitySummary( n_species=n_species, structural_rank=structural_rank, has_perfect_matching=has_perfect_matching, pattern_singular=pattern_singular, determinant_checked=determinant_checked, determinant_expr=determinant_expr, determinant_is_zero=determinant_is_zero, )