Source code for bngsim._model

"""bngsim.Model — High-level Python wrapper for NetworkModel.

This class delegates to the C++ ``NetworkModel`` and provides Python-friendly
helpers for loading models, updating parameters, and inspecting model state.
"""

from __future__ import annotations

import logging
import time
from pathlib import Path
from typing import TYPE_CHECKING

import numpy as np

from bngsim._exceptions import ModelError, ParameterError

if TYPE_CHECKING:
    from bngsim._bngsim_core import NetworkModel

logger = logging.getLogger("bngsim")


[docs] class Model: """A BioNetGen reaction network model. A Model holds species, reactions, observables, parameters, and functions. It can be loaded from ``.net`` files and, via the factory methods below, from Antimony and SBML inputs. Models are **not** thread-safe. For parallel workers, use :meth:`clone` to create independent copies. Parameters ---------- _core : NetworkModel Internal C++ model object. Users should not construct this directly; use the factory methods instead. Examples -------- >>> model = bngsim.Model.from_net("model.net") >>> model.n_species 5 >>> model.set_param("kf", 0.5) >>> model.get_param("kf") 0.5 >>> model.set_params({"kf": 1.0, "kr": 0.1}) """ __slots__ = ( "_core", "_codegen_so_path", "_codegen_c_source", "_codegen_sec", "_codegen_cache_hit", "_libsbml_parse_sec", "_interpret_sec", "_jac_derive_sec", "_jac_attempted", "_net_path", "_ssa_issues", "_ar_report_map", "_varvol_conc_map", "_varvol_amount_map", "_varvol_ar_conc_map", "_varvol_ar_amount_map", "_varvol_event_resize_map", "_periodic_disc_max_step", "_want_output_sens", ) def __init__(self, _core: NetworkModel) -> None: self._core = _core self._codegen_so_path: str = "" # GH #198: whether codegen should emit the expression output-sensitivity # evaluator. Set by the Simulator before codegen prep (only a sensitivity # run needs it, since its build-time differentiation is expensive). self._want_output_sens: bool = False # In-process MIR micro-JIT codegen source (GH #78); set when the JIT # backend (BNGSIM_CODEGEN_JIT=mir) prepares codegen for this model. self._codegen_c_source: str = "" # Wall seconds the model's codegen prepare spent (T0.3). Set by the # _codegen.prepare_* entry points (~0 for ExprTk models that never # codegen or a cache hit; the cc compile time on a cold large model). # Read by Simulator.last_codegen_sec; surfaced by the rr_parity harness # so one run yields the setup cost without a run-twice-and-subtract. self._codegen_sec: float = 0.0 # Whether the codegen .so was reused from the on-disk cache (True), freshly # compiled (False), or no .so was involved (None — ExprTk or MIR). Set by # the _codegen.prepare_* entry points; read by Simulator.codegen_cache_hit. # The definitive cache signal, not inferred from wall time. self._codegen_cache_hit: bool | None = None # Per-model setup wall seconds, each timed at its own boundary in the # SBML loader (read by Simulator.last_libsbml_parse_sec / # last_interpret_sec / last_jacobian_sec; surfaced by the rr_parity # harness). The per-step integration hot path is never instrumented. # _libsbml_parse_sec — libSBML readSBML* + error check (shared C++ core). # _interpret_sec — doc → internal _core (bngsim Python interpretation). # _jac_derive_sec — analytical Functional Jacobian derivation (sympy # sp.diff, GH #76); 0 for all-Elementary models, an # FD fallback, or BNGSIM_ANALYTICAL_FUNCTIONAL_JAC=0. self._libsbml_parse_sec: float = 0.0 self._interpret_sec: float = 0.0 self._jac_derive_sec: float = 0.0 # GH #145 once-only sentinel for the lazy analytical Functional Jacobian. # False until prepare_analytical_jacobian() has *attempted* the SymPy # derivation — set True regardless of whether it attached or fell back to # finite differences — so the ODE-solve trigger derives at most once per # model. analytical_jacobian_complete cannot be the sentinel: it is also # False for all-Elementary models and for legitimate FD fallbacks, which # would make a non-differentiable model re-run SymPy on every solve. self._jac_attempted: bool = False # Set by Model.from_net so downstream consumers (esp. the codegen # auto-trigger in Simulator) can route to the .net codegen path, # which handles derived-parameter chain rules that the model-based # path does not (issue #15). self._net_path: str = "" # Populated by the SBML loader (and only the SBML loader) with a # list of SsaIssue records for SSA-incompatible constructs. Empty # list means the model is SSA-clean as far as the loader can see. # See bngsim._ssa_validation.validate_for_ssa. self._ssa_issues: list = [] # Populated by the SBML loader: maps a mangled AssignmentRule-target # species name to ``(kind, source_name)`` where kind is # "observable" or "expression". Simulator.run uses it to report the # rule's live value in the species column instead of the frozen # initial value (the species is emitted ``fixed``). Empty for .net # and non-AR models. See _sbml_loader.py section 11. self._ar_report_map: dict[str, tuple[str, str]] = {} # Populated by the SBML loader (GH #85): maps a mangled species name to # the mangled name of its variable-volume compartment (a rate-rule or # event-driven compartment, promoted to a species column). Simulator.run # uses it to rescale the reported concentration of that species from # ``amount / V_static`` to ``amount / V_live(t)``. Empty for .net, # static-compartment, and unit-volume models. See _sbml_loader.py. self._varvol_conc_map: dict[str, str] = {} # Populated by the SBML loader (GH #86): maps a mangled hOSU=false # species name to its rate-rule compartment, for the *amount* (bare-id) # report only. Such a species is integrated in concentration space and # its stored concentration is already correct (the dilution term is in # the dynamics), so — unlike _varvol_conc_map — its concentration column # is NOT rescaled; only as_roadrunner's bare-id selector must recover the # amount as ``conc * V_live(t)`` instead of ``conc * V_static``. Empty # for .net, static, and amount-valued-only models. See _sbml_loader.py. self._varvol_amount_map: dict[str, str] = {} # Populated by the SBML loader (GH #87): maps a mangled amount-valued # species name to ``(comp_expr_name, V_static)`` for a species in an # ASSIGNMENT-RULE compartment (e.g. ``tV := mV + dV``). Simulator.run # rescales its reported concentration from ``amount / V_static`` to # ``amount / V_live(t)``, reading V_live(t) from the compartment's own # assignment-rule *expression* column. Empty for .net, static, rate-rule- # only, and unit-volume models. See _sbml_loader.py. self._varvol_ar_conc_map: dict[str, tuple[str, float]] = {} # Populated by the SBML loader (GH #234): the hOSU=false counterpart of # _varvol_ar_conc_map. Maps a mangled species name that received the §8c # dilution term (in a time-varying ASSIGNMENT-RULE compartment) to the # compartment's expression-column name. Simulator._apply_varvol_ar_conc_map # records V_live(t) from that column so the bare-id amount selector reports # conc·V_live(t); the concentration column is already correct. Empty for # .net, static, and AR-compartment-free models. See _sbml_loader.py. self._varvol_ar_amount_map: dict[str, str] = {} # Populated by the SBML loader (GH #131): maps a mangled species name in # an EVENT-RESIZED compartment to ``(comp_obs_name, V_static, hOSU)``. # Simulator._apply_varvol_event_resize_map applies the report-time # concentration correction ``× V_static/V_live`` — for every hOSU=true # species (both ODE and SSA) and for hOSU=false species under SSA only — # reading V_live from the compartment's same-named observable column. # Empty for .net, static, and event-resize-free models. See _sbml_loader.py. self._varvol_event_resize_map: dict[str, tuple[str, float, bool]] = {} # Populated by the SBML loader (GH #88): a recommended integrator # ``max_step_size`` (float) for a model whose ODE RHS is forced by a # periodic floor()/modulo dosing schedule, so the adaptive integrator # cannot step over a narrow dose pulse. None (the default) for every # model without such a schedule — the integrator is then unconstrained, # byte-identical to before. Simulator.run applies it unless the caller # passes an explicit ``max_step``. See _sbml_loader.py. self._periodic_disc_max_step: float | None = None # ─── Factory methods ──────────────────────────────────────────────────
[docs] @classmethod def from_antimony(cls, path: str | Path) -> Model: """Load a model from an Antimony ``.ant`` file. Antimony is a human-readable model description language. Internally converts to SBML via libantimony, then loads via libsbml for correct SBML semantics. Requires: ``pip install antimony python-libsbml`` Parameters ---------- path : str or Path Path to the ``.ant`` file. Returns ------- Model The loaded model. Raises ------ ImportError If ``antimony`` or ``libsbml`` is not installed. FileNotFoundError If the file does not exist. ModelError If the file cannot be parsed. """ from bngsim._sbml_loader import load_antimony_via_sbml try: return load_antimony_via_sbml(path) except (ImportError, FileNotFoundError): raise except Exception as e: raise ModelError(f"Failed to load Antimony file {path}: {e}") from e
[docs] @classmethod def from_antimony_string(cls, text: str) -> Model: """Load a model from an Antimony string. Parameters ---------- text : str Antimony model text. Returns ------- Model The loaded model. """ from bngsim._sbml_loader import load_antimony_string_via_sbml try: return load_antimony_string_via_sbml(text) except ImportError: raise except Exception as e: raise ModelError(f"Failed to load Antimony string: {e}") from e
[docs] @classmethod def from_sbml(cls, path: str | Path, *, defer_jacobian: bool | None = None) -> Model: """Load a model from an SBML ``.xml`` file. Parameters ---------- path : str or Path Path to the SBML file. defer_jacobian : bool, optional GH #145 escape hatch. The analytical Functional Jacobian (GH #76) is derived lazily at the first ODE-solve setup by default (``None``); pass ``defer_jacobian=False`` to derive it eagerly at load instead (the pre-#145 behavior, for A/B and safety). ``BNGSIM_EAGER_JACOBIAN=1`` forces eager for every load path. Returns ------- Model The loaded model. Raises ------ ImportError If ``python-libsbml`` is not installed. FileNotFoundError If the file does not exist. ModelError If the file cannot be parsed. """ from bngsim._sbml_loader import load_sbml try: model = load_sbml(path) except (ImportError, FileNotFoundError): raise except Exception as e: raise ModelError(f"Failed to load SBML file {path}: {e}") from e # GH #145 eager escape hatch: BNGSIM_EAGER_JACOBIAN=1 is honored inside the # loader for every SBML-family entry point; this restores derive-at-load # for the explicit ``defer_jacobian=False`` request. Default is lazy. if defer_jacobian is False: model.prepare_analytical_jacobian() return model
[docs] @classmethod def from_sbml_string(cls, text: str, *, defer_jacobian: bool | None = None) -> Model: """Load a model from an SBML XML string. Parameters ---------- text : str SBML XML text. defer_jacobian : bool, optional GH #145 escape hatch (see :meth:`from_sbml`). Default lazy; pass ``defer_jacobian=False`` (or set ``BNGSIM_EAGER_JACOBIAN=1``) to derive the analytical Functional Jacobian eagerly at load. Returns ------- Model The loaded model. """ from bngsim._sbml_loader import load_sbml_string try: model = load_sbml_string(text) except ImportError: raise except Exception as e: raise ModelError(f"Failed to load SBML string: {e}") from e if defer_jacobian is False: model.prepare_analytical_jacobian() return model
[docs] @classmethod def from_net(cls, path: str | Path, *, defer_jacobian: bool | None = None) -> Model: """Load a model from a BNG ``.net`` file. Parameters ---------- path : str or Path Path to the ``.net`` file. defer_jacobian : bool, optional GH #145 escape hatch. The analytical Functional Jacobian (GH #76) is derived lazily at the first ODE-solve setup by default (``None``); pass ``defer_jacobian=False`` (or set ``BNGSIM_EAGER_JACOBIAN=1``) to derive it eagerly at load instead (pre-#145 behavior, for A/B). Returns ------- Model The loaded model. Raises ------ ModelError If the file cannot be parsed. FileNotFoundError If the file does not exist. """ from bngsim._bngsim_core import NetworkModel path = Path(path) if not path.exists(): raise FileNotFoundError(f"Net file not found: {path}") try: core = NetworkModel.from_net(str(path)) except (ValueError, RuntimeError) as e: raise ModelError(f"Failed to load {path}: {e}") from e m = cls(_core=core) m._net_path = str(path) # GH #145: the analytical Functional Jacobian (GH #76) is consumed only by # ODE solves, so it is no longer derived here at load — it is deferred to # the first ODE-solve setup (Simulator.__init__ → # prepare_analytical_jacobian). A .net model run under SSA/PSA/NFsim/ # RuleMonkey, or merely inspected, never pays the SymPy derivation. # (All-Elementary .net models carry the closed-form analytical Jacobian # from the C++ build regardless — there are no Functional reactions to # differentiate.) Eager escape hatch (A/B, safety): defer_jacobian=False # or BNGSIM_EAGER_JACOBIAN=1 restores the pre-#145 derive-at-load. from bngsim._jacobian import eager_jacobian_requested if eager_jacobian_requested(defer_jacobian): m.prepare_analytical_jacobian() return m
# ─── Lazy analytical Jacobian (GH #145) ───────────────────────────────
[docs] def prepare_analytical_jacobian(self) -> bool: """Derive and attach the analytical Functional Jacobian (GH #76), at most once. Idempotent (GH #145): the SymPy derivation runs only on the first call; later calls are no-ops guarded by the model's once-only sentinel. Returns whether the model now carries a *complete* analytical Jacobian (``False`` if it fell back to finite differences, or was already FD / all-Elementary with the closed-form C++ Jacobian). This is the lazy-derivation entry point. The Jacobian is consumed only by ODE solves (CVODE's dense Jacobian, the steady-state Newton solver, and codegen's analytical-Jacobian emitter), so it is deferred off the model- load path (``from_sbml`` / ``from_net`` no longer derive) and triggered at ODE-solve setup. Call it directly to **warm a parent template before** :meth:`clone` fan-out: a warmed parent passes the derived terms to clones (which re-compile the derivative ExprTk strings with no SymPy), so parallel fitting derives once, not once per worker. """ if self._jac_attempted: return bool(self._core.analytical_jacobian_complete) self._jac_attempted = True try: from bngsim._jacobian import attach_functional_jacobian t0 = time.perf_counter() attach_functional_jacobian(self._core) self._jac_derive_sec = time.perf_counter() - t0 except Exception as e: # attach_functional_jacobian is contractually no-raise (it falls back # to FD and logs over-budget / unsupported cases itself, GH #95); this # guard only surfaces a genuinely unexpected error without re-deriving. logger.debug("Analytical Functional Jacobian skipped: %s", e) return bool(self._core.analytical_jacobian_complete)
# ─── Load-phase timing accessors ────────────────────────────────────── # Public read-only views of the per-model setup timings the SBML loader # records, mirroring Simulator.last_libsbml_parse_sec / last_interpret_sec / # last_jacobian_sec for callers that hold only a Model (e.g. the rr_parity SSA # screen loads via Model.from_sbml and runs per-replicate Simulators, so the # parse/interpret cost lives on the Model, not on any one Simulator). Setup- # time only; never the integration hot path. @property def last_libsbml_parse_sec(self) -> float: """Wall seconds the SBML loader spent in the libSBML parse phase (``readSBML*`` + document-level error check). ``0.0`` for a non-SBML model (e.g. ``Model.from_net``). See :attr:`Simulator.last_libsbml_parse_sec`.""" return float(self._libsbml_parse_sec) @property def last_interpret_sec(self) -> float: """Wall seconds spent interpreting the parsed libSBML document into the internal ``_core`` model (excludes libSBML parse, Jacobian derivation, and codegen). ``0.0`` for a non-SBML model. See :attr:`Simulator.last_interpret_sec`.""" return float(self._interpret_sec) @property def last_jacobian_sec(self) -> float: """Wall seconds spent symbolically deriving this model's analytical Functional Jacobian (GH #76). ``0.0`` until the derivation runs (it is lazy since GH #145, and never runs on the SSA/PSA/NFsim paths). See :attr:`Simulator.last_jacobian_sec`.""" return float(self._jac_derive_sec) # ─── Clone ────────────────────────────────────────────────────────────
[docs] def clone(self) -> Model: """Deep copy the model for parallel workers. Each clone is fully independent — it has its own parameter values, species concentrations, and expression evaluator state. Returns ------- Model An independent deep copy. """ m = Model(_core=self._core.clone()) m._net_path = self._net_path m._want_output_sens = self._want_output_sens m._codegen_so_path = self._codegen_so_path m._codegen_c_source = self._codegen_c_source m._codegen_sec = self._codegen_sec m._codegen_cache_hit = self._codegen_cache_hit # Carry the populated Jacobian + its derive time to clones (the existing # warm-clone path re-compiles the derivative ExprTk strings with NO sympy), # so a warmed parent yields cheap clones — the key invariant a future lazy # deferral (GH #145) relies on to avoid N× sympy in parallel fitting. m._libsbml_parse_sec = self._libsbml_parse_sec m._interpret_sec = self._interpret_sec m._jac_derive_sec = self._jac_derive_sec # GH #145: carry the once-only sentinel so a clone of a warmed parent does # NOT re-attempt the SymPy derivation. The C++ clone above already # re-compiles the parent's functional_jac into the clone's evaluator with # no SymPy, so a derived parent → cheap, already-warm clones; copying the # sentinel keeps the ODE-solve trigger a no-op on those clones (a clone of # an un-warmed parent inherits _jac_attempted=False and derives on first # solve — hence warm-before-clone for parallel fitting, GH #145 §3). m._jac_attempted = self._jac_attempted m._ssa_issues = list(self._ssa_issues) m._ar_report_map = dict(self._ar_report_map) m._varvol_conc_map = dict(self._varvol_conc_map) m._varvol_amount_map = dict(self._varvol_amount_map) m._varvol_ar_conc_map = dict(self._varvol_ar_conc_map) m._varvol_ar_amount_map = dict(self._varvol_ar_amount_map) m._varvol_event_resize_map = dict(self._varvol_event_resize_map) m._periodic_disc_max_step = self._periodic_disc_max_step return m
# ─── SSA validation ───────────────────────────────────────────────────
[docs] def validate_for_ssa(self) -> list: """Return SSA-compatibility issues detected by the SBML loader. Returns ------- list of :class:`bngsim.SsaIssue` One entry per detected construct; empty for SSA-clean models and for models loaded outside the SBML path (``Model.from_net``, builder). See Also -------- bngsim.validate_for_ssa : module-level function with the same body. """ from bngsim._ssa_validation import validate_for_ssa return validate_for_ssa(self)
# ─── Parameter access ─────────────────────────────────────────────────
[docs] def set_param(self, name: str, value: float) -> None: """Set a parameter value by name. Parameters ---------- name : str Parameter name (e.g. "kf", "Km"). value : float New value. Raises ------ ParameterError If the parameter name is not found. """ try: self._core.set_param(name, float(value)) except (KeyError, RuntimeError) as e: raise ParameterError(f"Parameter '{name}' not found in model") from e
[docs] def get_param(self, name: str) -> float: """Get a parameter value by name. Parameters ---------- name : str Parameter name. Returns ------- float Current value. Raises ------ ParameterError If the parameter name is not found. """ try: return self._core.get_param(name) except (KeyError, RuntimeError) as e: raise ParameterError(f"Parameter '{name}' not found in model") from e
[docs] def set_params(self, params: dict[str, float]) -> None: """Set multiple parameters from a dict. Parameters ---------- params : dict[str, float] Parameter name → value mapping. Raises ------ ParameterError If any parameter name is not found, or any value cannot be converted to float. Atomic: either all succeed or none do. Examples -------- >>> model.set_params({"kf": 0.5, "kr": 0.1}) """ # Phase 1: Validate all names known = set(self._core.param_names) unknown = set(params.keys()) - known if unknown: raise ParameterError( f"Unknown parameter(s): {', '.join(sorted(unknown))}. " f"Known: {', '.join(sorted(known))}" ) # Phase 2: Convert all values (catches "x", None, etc.) converted: dict[str, float] = {} for name, value in params.items(): try: converted[name] = float(value) except (TypeError, ValueError) as e: raise ParameterError(f"Invalid value for parameter '{name}': {value!r}") from e # Phase 3: Apply atomically (all validation passed) for name, value in converted.items(): self._core.set_param(name, value)
# ─── State management ─────────────────────────────────────────────────
[docs] def reset(self) -> None: """Reset all species to their initial concentrations. Parameter values are **not** reset — only species concentrations. """ self._core.reset()
[docs] def save_concentrations(self) -> None: """Snapshot current species concentrations as the new initial state. Subsequent :meth:`reset` calls will restore to this snapshot rather than the original initial conditions from the ``.net`` file. Implements BNG ``saveConcentrations()`` action. """ self._core.save_concentrations()
[docs] def set_concentration(self, name: str, value: float) -> None: """Set a single species concentration by name. Parameters ---------- name : str Species name (e.g. ``"A(b)"``). value : float New concentration value. Raises ------ ModelError If the species name is not found. Notes ----- Implements BNG ``setConcentration("name", value)`` action. """ try: self._core.set_concentration(name, float(value)) except (KeyError, RuntimeError) as e: raise ModelError(f"Species '{name}' not found in model") from e
[docs] def get_concentration(self, name: str) -> float: """Get a single species concentration by name. Parameters ---------- name : str Species name. Returns ------- float Current concentration. Raises ------ ModelError If the species name is not found. """ try: return self._core.get_concentration(name) except (KeyError, RuntimeError) as e: raise ModelError(f"Species '{name}' not found in model") from e
[docs] def get_state(self) -> np.ndarray: """Bulk-copy the full live species-concentration vector (GH #102). Returns a fresh ``float64`` array of length :attr:`n_species`, ordered like :attr:`species_names`. This is the low-overhead per-step state-exchange primitive for driving bngsim as a reaction kernel from an external orchestrator (e.g. a hybrid SSA/ODE splitting loop): one Python call marshals the entire state, so per-step exchange cost stays negligible next to the ODE solve even at ~100K species. See Also -------- set_state : the inverse bulk assignment. species_names : the ordering of the returned vector. """ return self._core.get_state()
[docs] def set_state(self, state: np.ndarray) -> None: """Bulk-assign the full live species-concentration vector (GH #102). Parameters ---------- state : array_like 1-D array of length :attr:`n_species`, ordered like :attr:`species_names`. Copied into the model's live concentrations; observables and other derived state are recomputed on the next RHS or observable evaluation. Raises ------ ValueError If ``state`` is not 1-D or its length differs from :attr:`n_species`. """ self._core.set_state(np.asarray(state, dtype=np.float64))
# ─── Properties ─────────────────────────────────────────────────────── @property def n_species(self) -> int: """Number of species in the model.""" return self._core.n_species @property def n_reactions(self) -> int: """Number of reactions in the model.""" return self._core.n_reactions @property def n_observables(self) -> int: """Number of observable groups in the model.""" return self._core.n_observables @property def n_parameters(self) -> int: """Number of parameters in the model.""" return self._core.n_parameters @property def n_functions(self) -> int: """Number of functions in the model.""" return self._core.n_functions @property def param_names(self) -> list[str]: """List of all parameter names.""" return self._core.param_names @property def param_is_expression(self) -> list[bool]: """Per-parameter ``is_expression`` flag, parallel to :attr:`param_names`. ``True`` for derived ``ConstantExpression`` parameters such as the ``_rateLaw{N}`` symbols BNG2.pl emits when a BNGL rate law is a compound expression (e.g. ``chi*kon``). These are not independent knobs — their values are computed from primary parameters and are re-evaluated automatically by :meth:`set_param`. """ return list(self._core.param_is_expression) @property def primary_param_names(self) -> list[str]: """List of parameter names that are *not* derived constant expressions. These are the genuine knobs of the model — primary rate constants, initial-condition parameters, etc. Use this when you want to expose the model to an external optimizer or sampler that should treat each parameter as an independent variable; varying a primary via :meth:`set_param` automatically propagates to derived parameters. """ names = self.param_names flags = self.param_is_expression return [n for n, f in zip(names, flags, strict=False) if not f] @property def species_names(self) -> list[str]: """List of all species names.""" return self._core.species_names @property def observable_names(self) -> list[str]: """List of all observable group names.""" return self._core.observable_names # ─── Table functions ──────────────────────────────────────────────────
[docs] def add_table_function( self, name: str, *, file: str | Path | None = None, times: list[float] | None = None, values: list[float] | None = None, index: str = "time", method: str = "linear", ) -> None: """Add a table function (piecewise-linear interpolation of data). The function is registered with the expression evaluator and can be referenced by name in rate law expressions. Parameters ---------- name : str Function name (e.g., ``"cumNcases"``). file : str or Path, optional Path to a ``.tfun`` file. Mutually exclusive with ``times``/``values``. times : list[float], optional X (index) values. Must be used with ``values``. values : list[float], optional Y (function) values. Must be used with ``times``. index : str Index variable name. Default ``"time"``. Can also be a parameter or observable name for non-time-indexed table functions. method : str Interpolation method: ``"linear"`` (default) or ``"step"``. Raises ------ ModelError If the file cannot be read or data is invalid. ValueError If arguments are inconsistent (e.g., both ``file`` and ``times``). Examples -------- >>> model.add_table_function("cumNcases", file="case_data.tfun") >>> model.add_table_function("response", file="dose.tfun", index="drug_conc") >>> model.add_table_function("drive", times=[0, 1, 2], values=[0, 5, 10]) """ if file is not None and times is not None: raise ValueError( "Cannot specify both 'file' and 'times'/'values'. Use one or the other." ) normalized_method = method.strip().lower() if normalized_method not in {"linear", "step"}: raise ValueError( "Invalid table function interpolation method. Expected 'linear' or 'step'." ) if file is not None: filepath = str(Path(file)) try: self._core.add_table_function_file(name, filepath, index, normalized_method) except (ValueError, RuntimeError) as e: raise ModelError(f"Failed to add table function '{name}': {e}") from e elif times is not None and values is not None: try: self._core.add_table_function_arrays( name, list(times), list(values), index, normalized_method ) except (ValueError, RuntimeError) as e: raise ModelError(f"Failed to add table function '{name}': {e}") from e else: raise ValueError("Must specify either 'file' or both 'times' and 'values'.")
@property def n_table_functions(self) -> int: """Number of registered table functions.""" return self._core.n_table_functions @property def table_function_names(self) -> list[str]: """Names of all registered table functions.""" return self._core.table_function_names # ─── Dunder methods ─────────────────────────────────────────────────── def __repr__(self) -> str: return ( f"Model(species={self.n_species}, reactions={self.n_reactions}, " f"observables={self.n_observables}, parameters={self.n_parameters})" )