Source code for synkit.CRN.Props.thermo

from __future__ import annotations

from dataclasses import dataclass
from typing import Any, Optional

import numpy as np

from .stoich import right_nullspace, stoichiometric_matrix


[docs] @dataclass class ThermoSummary: """ Lightweight container summarizing thermodynamic-like stoichiometric properties of a chemical reaction network. This dataclass gathers the main outputs of the thermo module into a single object for convenient downstream inspection, reporting, or serialization. It is intentionally compact and stores only the most interpretable summary flags and one example conservation-law vector when available. :param conservative: Whether the network is conservative, i.e. whether there exists a strictly positive vector :math:`m > 0` such that :math:`m^T S = 0`. The value may be ``None`` if the check is inconclusive. :type conservative: Optional[bool] :param consistent: Whether the network is consistent, i.e. whether there exists a strictly positive vector :math:`v > 0` such that :math:`S v = 0`. The value may be ``None`` if the check is inconclusive. :type consistent: Optional[bool] :param example_conservation_law: Example normalized strictly positive left-kernel vector when one is found, otherwise ``None``. :type example_conservation_law: Optional[numpy.ndarray] :param irreversible_futile_cycles: Boolean indicator of whether the right kernel :math:`\\ker(S)` is non-trivial. This is used here as a structural proxy for the presence of futile-cycle-like steady flux modes. :type irreversible_futile_cycles: bool .. code-block:: python from synkit.CRN.Props.thermo import compute_thermo_summary summary = compute_thermo_summary(hg) print(summary.conservative) print(summary.consistent) print(summary.irreversible_futile_cycles) print(summary.example_conservation_law) """ conservative: Optional[bool] consistent: Optional[bool] example_conservation_law: Optional[np.ndarray] irreversible_futile_cycles: bool
def _normalize_positive(vec: np.ndarray, *, eps: float = 1e-8) -> Optional[np.ndarray]: """ Normalize a strictly signed vector to a positive unit-:math:`\\ell_1` vector. This helper accepts a candidate vector and checks whether all entries are strictly positive or strictly negative up to the threshold ``eps``. If the vector is strictly negative, it is sign-flipped. A valid vector is then normalized to unit 1-norm and returned. This is primarily used to post-process candidate conservation laws or flux modes so they are easier to inspect and compare numerically. :param vec: Input vector to test and normalize. :type vec: numpy.ndarray :param eps: Absolute threshold used to determine strict positivity or negativity. :type eps: float :returns: A strictly positive vector normalized to sum to 1, or ``None`` if the input does not have a uniform strict sign. :rtype: Optional[numpy.ndarray] .. code-block:: python import numpy as np from synkit.CRN.Props.thermo import _normalize_positive v = np.array([2.0, 3.0, 5.0]) out = _normalize_positive(v) print(out) # array summing to 1.0 """ v = np.asarray(vec, dtype=float).reshape(-1) if np.all(v > eps): s = float(np.sum(v)) if s > 0: return v / s if np.all(v < -eps): v = -v s = float(np.sum(v)) if s > 0: return v / s return None def _find_positive_left_kernel_vector( S: np.ndarray, *, eps: float = 1e-8, rtol: float = 1e-12, ) -> tuple[Optional[bool], Optional[np.ndarray]]: """ Attempt to find a strictly positive conservation-law vector :math:`m > 0` satisfying :math:`m^T S = 0`. This helper implements the main conservativity search logic used by :func:`is_conservative` and :func:`compute_conservativity`. The search is performed in several stages: 1. compute a basis of :math:`\\ker(S^T)`, 2. test whether any basis vector is already strictly positive up to sign, 3. project the all-ones vector into the left kernel and test whether the projection is strictly positive, 4. if SciPy is available, solve a linear feasibility problem enforcing :math:`S^T m = 0` and :math:`m_i \\ge \\varepsilon`. This layered approach makes the routine useful both with and without SciPy. :param S: Stoichiometric matrix of shape ``(n_species, n_reactions)``. :type S: numpy.ndarray :param eps: Absolute threshold used to test strict positivity. :type eps: float :param rtol: Relative tolerance used in nullspace computations. :type rtol: float :returns: A tuple ``(flag, m)`` where ``flag`` is: - ``True`` if a strictly positive conservation law was found, - ``False`` if no such law exists, - ``None`` if the result is inconclusive, and ``m`` is an example normalized positive conservation law if one was found, otherwise ``None``. :rtype: Tuple[Optional[bool], Optional[numpy.ndarray]] .. code-block:: python import numpy as np from synkit.CRN.Props.thermo import _find_positive_left_kernel_vector S = np.array([[-1.0], [1.0]]) flag, m = _find_positive_left_kernel_vector(S) print(flag) print(m) """ S = np.asarray(S, dtype=float) n_species, n_reactions = S.shape # No reactions: every species is trivially conserved if n_reactions == 0: if n_species == 0: return True, None m = np.ones(n_species, dtype=float) m /= np.sum(m) return True, m B = left_nullspace_from_matrix(S, rtol=rtol) if B is None or B.size == 0: return False, None B = np.atleast_2d(B) # 1) quick scan of basis columns for j in range(B.shape[1]): m = _normalize_positive(B[:, j], eps=eps) if m is not None: return True, m # 2) projection fallback: # project the all-ones vector onto ker(S^T) ones = np.ones(n_species, dtype=float) # works whether or not B is perfectly orthonormal enough for practice coeff = B.T @ ones proj = B @ coeff m = _normalize_positive(proj, eps=eps) if m is not None: return True, m # 3) exact LP feasibility check, if SciPy is available try: from scipy.optimize import linprog # type: ignore c = np.zeros(n_species, dtype=float) A_eq = S.T b_eq = np.zeros(n_reactions, dtype=float) bounds = [(eps, None) for _ in range(n_species)] res = linprog(c, A_eq=A_eq, b_eq=b_eq, bounds=bounds, method="highs") if res.success and res.x is not None: m = _normalize_positive(np.asarray(res.x, dtype=float), eps=eps) if m is not None: return True, m return False, None return False, None except Exception: pass # Without LP, 1D kernel is definitive; higher-dimensional is otherwise inconclusive. if B.shape[1] == 1: return False, None return None, None
[docs] def left_nullspace_from_matrix(S: np.ndarray, *, rtol: float = 1e-12) -> np.ndarray: """ Compute a basis for the left kernel :math:`\\ker(S^T)` directly from a stoichiometric matrix. This helper is a matrix-level analogue of the graph-based nullspace functions in :mod:`stoich`. It is useful when the stoichiometric matrix is already available and one wants to avoid reconstructing it from the CRN representation. If SciPy is available, the function uses :func:`scipy.linalg.null_space`. Otherwise it falls back to an SVD-based implementation using NumPy. :param S: Stoichiometric matrix of shape ``(n_species, n_reactions)``. :type S: numpy.ndarray :param rtol: Relative tolerance used to determine the effective numerical rank in the nullspace computation. :type rtol: float :returns: Matrix whose columns form a basis for :math:`\\ker(S^T)`. The returned array has shape ``(n_species, k)``, where ``k`` is the dimension of the left kernel. :rtype: numpy.ndarray .. code-block:: python import numpy as np from synkit.CRN.Props.thermo import left_nullspace_from_matrix S = np.array([[-1.0], [1.0]]) L = left_nullspace_from_matrix(S) print(L.shape) """ try: from scipy.linalg import null_space as scipy_null_space # type: ignore return scipy_null_space(S.T, rcond=rtol) except Exception: _u, s, vh = np.linalg.svd(S.T, full_matrices=True) if s.size == 0: return np.eye(S.shape[0]) tol = rtol * s[0] rank = int((s > tol).sum()) ns = vh[rank:].T if ns.size == 0: return np.zeros((S.shape[0], 0)) return ns
[docs] def is_conservative( crn: Any, *, eps: float = 1e-8, rtol: float = 1e-12, ) -> Optional[bool]: """ Check whether the chemical reaction network is conservative. A network is called conservative here if there exists a strictly positive vector :math:`m > 0` such that .. math:: m^T S = 0, where :math:`S` is the stoichiometric matrix. Such a vector can be interpreted as a positive conservation law over species. This function returns only the Boolean-style status of the check. Use :func:`compute_conservativity` when an example conservation-law vector is also desired. :param crn: Hypergraph or bipartite NetworkX graph representing the chemical reaction network. :type crn: Any :param eps: Absolute threshold used to test strict positivity of candidate conservation-law vectors. :type eps: float :param rtol: Relative tolerance used in nullspace-based computations. :type rtol: float :returns: - ``True`` if a strictly positive conservation law exists, - ``False`` if no such law exists, - ``None`` if the result is inconclusive. :rtype: Optional[bool] .. code-block:: python from synkit.CRN.Props.thermo import is_conservative flag = is_conservative(hg) print("Conservative?", flag) """ S = stoichiometric_matrix(crn) flag, _m = _find_positive_left_kernel_vector(S, eps=eps, rtol=rtol) return flag
[docs] def compute_conservativity( crn: Any, *, rtol: float = 1e-12, eps: float = 1e-8, ) -> tuple[Optional[bool], Optional[np.ndarray]]: """ Compute the conservativity status of a network together with an example positive conservation law when available. This is the more informative counterpart of :func:`is_conservative`. It checks whether there exists a strictly positive vector :math:`m > 0` such that :math:`m^T S = 0`, and if successful it also returns one normalized example vector. :param crn: Hypergraph or bipartite NetworkX graph representing the chemical reaction network. :type crn: Any :param rtol: Relative tolerance used in nullspace-based computations. :type rtol: float :param eps: Absolute threshold used to test strict positivity of candidate conservation-law vectors. :type eps: float :returns: Tuple ``(flag, m)`` where ``flag`` is the conservativity result and ``m`` is an example normalized strictly positive conservation law if one was found, otherwise ``None``. :rtype: Tuple[Optional[bool], Optional[numpy.ndarray]] .. code-block:: python from synkit.CRN.Props.thermo import compute_conservativity flag, m = compute_conservativity(hg) print("Conservative?", flag) if m is not None: print("Example law:", m) """ S = stoichiometric_matrix(crn) return _find_positive_left_kernel_vector(S, eps=eps, rtol=rtol)
[docs] def is_consistent(crn: Any, *, eps: float = 1e-8) -> Optional[bool]: """ Check whether the chemical reaction network is consistent. Consistency is tested here by asking whether there exists a strictly positive flux vector :math:`v > 0` such that .. math:: S v = 0, where :math:`S` is the stoichiometric matrix. In CRN terminology, this corresponds to the existence of a strictly positive right-kernel vector, or equivalently a positive T-semiflow. If SciPy is available, the function attempts a linear-programming check. Otherwise it falls back to a nullspace-based heuristic. :param crn: Hypergraph or bipartite NetworkX graph representing the chemical reaction network. :type crn: Any :param eps: Absolute lower bound used to enforce strict positivity of candidate flux vectors. :type eps: float :returns: - ``True`` if a strictly positive right-kernel vector exists, - ``False`` if no such vector exists, - ``None`` if the result is inconclusive. :rtype: Optional[bool] .. code-block:: python from synkit.CRN.Props.thermo import is_consistent flag = is_consistent(hg) print("Consistent?", flag) """ S = stoichiometric_matrix(crn) n_species, n_reactions = S.shape if n_reactions == 0: return False if n_species > 0 else True try: from scipy.optimize import linprog # type: ignore c = np.ones(n_reactions) A_eq = S b_eq = np.zeros(n_species) bounds = [(eps, None) for _ in range(n_reactions)] res = linprog(c, A_eq=A_eq, b_eq=b_eq, bounds=bounds, method="highs") if res.success and res.x is not None: v = res.x residual = S @ v max_v = float(np.max(np.abs(v))) or 1.0 rel_err = np.linalg.norm(residual, ord=np.inf) / max_v return bool(rel_err <= 1e-8) return False except Exception: pass B = right_nullspace(crn) if B is None or B.size == 0: return False for k in range(B.shape[1]): v = B[:, k] if np.all(v > eps) or np.all(v < -eps): return True # projection fallback onto ker(S) B = np.atleast_2d(B) ones = np.ones(B.shape[0], dtype=float) coeff = B.T @ ones proj = B @ coeff if np.all(proj > eps) or np.all(proj < -eps): return True return None
[docs] def has_irreversible_futile_cycles(crn: Any, *, rtol: float = 1e-12) -> bool: """ Check whether the network admits non-trivial steady-state flux modes. This function tests whether the right kernel :math:`\\ker(S)` is non-trivial, i.e. whether there exists a nonzero vector :math:`v` such that .. math:: S v = 0. A non-trivial right kernel indicates the presence of flux-mode-like directions that do not change net species amounts. In this module, this is used as a structural proxy for irreversible futile cycles, although it should be interpreted cautiously as a stoichiometric indicator rather than a full mechanistic proof. :param crn: Hypergraph or bipartite NetworkX graph representing the chemical reaction network. :type crn: Any :param rtol: Relative tolerance used in the nullspace computation. :type rtol: float :returns: ``True`` if :math:`\\ker(S)` is non-trivial, otherwise ``False``. :rtype: bool .. code-block:: python from synkit.CRN.Props.thermo import has_irreversible_futile_cycles has_cycles = has_irreversible_futile_cycles(hg) print("Has futile-cycle-like modes?", has_cycles) """ V = right_nullspace(crn, rtol=rtol) V = np.atleast_2d(V) if V.size == 0: return False return V.shape[1] > 0
[docs] def compute_thermo_summary( crn: Any, *, rtol: float = 1e-12, eps: float = 1e-8 ) -> ThermoSummary: """ Compute a composite :class:`ThermoSummary` describing key thermodynamic-like and structural stoichiometric properties of a chemical reaction network. This helper combines the main thermo-related analyses into a single call: - conservativity: whether there exists a strictly positive vector :math:`m > 0` such that :math:`m^T S = 0`, - consistency: whether there exists a strictly positive flux vector :math:`v > 0` such that :math:`S v = 0`, - irreversible futile-cycle proxy: whether the right kernel :math:`\\ker(S)` is non-trivial, - example conservation law: one normalized strictly positive left-kernel vector, when such a vector can be found. The returned summary is intended as a lightweight diagnostic object for quick inspection of CRN thermodynamic structure without calling each helper individually. :param crn: Hypergraph or bipartite NetworkX graph representing the chemical reaction network. :type crn: Any :param rtol: Relative tolerance used in nullspace-based computations, especially when estimating kernel dimensions or checking whether the right kernel is non-trivial. :type rtol: float :param eps: Absolute positivity threshold used when testing whether a candidate conservation law or flux mode is strictly positive. :type eps: float :returns: A :class:`ThermoSummary` instance containing conservativity, consistency, an optional example positive conservation law, and a Boolean indicator for non-trivial steady-state flux cycles. :rtype: ThermoSummary .. code-block:: python from synkit.CRN.Props.thermo import compute_thermo_summary summary = compute_thermo_summary(hg) print("Conservative:", summary.conservative) print("Consistent:", summary.consistent) print("Has futile-cycle proxy:", summary.irreversible_futile_cycles) if summary.example_conservation_law is not None: print("Example conservation law:", summary.example_conservation_law) .. note:: The field ``irreversible_futile_cycles`` is used here as a structural proxy based on the existence of nonzero vectors in :math:`\\ker(S)`. It should be interpreted as a stoichiometric indicator rather than as a full mechanistic proof of thermodynamic infeasibility. """ conservative, m = compute_conservativity(crn, rtol=rtol, eps=eps) consistent = is_consistent(crn, eps=eps) cyc = has_irreversible_futile_cycles(crn, rtol=rtol) return ThermoSummary( conservative=conservative, consistent=consistent, example_conservation_law=m, irreversible_futile_cycles=cyc, )