From ef4b92830b34a18cdc2286f355d3f63cc3cc108f Mon Sep 17 00:00:00 2001 From: Bill Hlavacek Date: Tue, 26 May 2026 00:01:05 -0600 Subject: [PATCH 1/4] Add optional BNGsim integration bridge Optional in-process simulation backend, used iff `import bngsim` succeeds; otherwise the existing BNG2.pl / run_network / NFsim path is used unchanged, so behavior is identical when bngsim is absent. bngsim is never an install dependency (pure soft-import). Adds the bridge module, the BNG2.pl backend-hook helper, the in-process parameter_scan driver, CLI/runner routing (auto / --no-bngsim), version reporting, and tests. Touchpoints are additive; the non-bngsim path is left byte-for-byte upstream. Toward RuleWorld/PyBioNetGen#66. --- bionetgen/__init__.py | 3 + bionetgen/core/tools/__init__.py | 13 + bionetgen/core/tools/bngsim_backend_helper.py | 483 ++++ bionetgen/core/tools/bngsim_bridge.py | 1952 +++++++++++++++++ bionetgen/core/tools/bngsim_parameter_scan.py | 832 +++++++ bionetgen/core/tools/cli.py | 202 +- bionetgen/core/tools/info.py | 16 + bionetgen/main.py | 130 +- bionetgen/modelapi/runner.py | 156 +- tests/conftest.py | 34 + tests/test_bngsim_backend_hook.py | 836 +++++++ tests/test_bngsim_bridge.py | 347 +++ tests/test_bngsim_bridge_extended.py | 880 ++++++++ tests/test_bngsim_direct_job_executor.py | 240 ++ tests/test_bngsim_method_defaults.py | 181 ++ tests/test_bngsim_parameter_scan_driver.py | 744 +++++++ tests/test_bngsim_persistent_helper.py | 163 ++ tests/test_bngsim_routing_classifier.py | 643 ++++++ tests/test_bngsim_version_guard.py | 165 ++ 19 files changed, 7967 insertions(+), 53 deletions(-) create mode 100644 bionetgen/core/tools/bngsim_backend_helper.py create mode 100644 bionetgen/core/tools/bngsim_bridge.py create mode 100644 bionetgen/core/tools/bngsim_parameter_scan.py create mode 100644 tests/test_bngsim_backend_hook.py create mode 100644 tests/test_bngsim_bridge.py create mode 100644 tests/test_bngsim_bridge_extended.py create mode 100644 tests/test_bngsim_direct_job_executor.py create mode 100644 tests/test_bngsim_method_defaults.py create mode 100644 tests/test_bngsim_parameter_scan_driver.py create mode 100644 tests/test_bngsim_persistent_helper.py create mode 100644 tests/test_bngsim_routing_classifier.py create mode 100644 tests/test_bngsim_version_guard.py diff --git a/bionetgen/__init__.py b/bionetgen/__init__.py index c826ee75..6b311f92 100644 --- a/bionetgen/__init__.py +++ b/bionetgen/__init__.py @@ -1,4 +1,5 @@ from .core.defaults import defaults +from .core.tools.bngsim_bridge import BNGSIM_AVAILABLE, BNGSIM_VERSION from .modelapi import bngmodel from .modelapi.runner import run from .simulator import sim_getter @@ -8,6 +9,8 @@ __all__ = [ "defaults", + "BNGSIM_AVAILABLE", + "BNGSIM_VERSION", "bngmodel", "run", "sim_getter", diff --git a/bionetgen/core/tools/__init__.py b/bionetgen/core/tools/__init__.py index b90084e3..41102cc6 100644 --- a/bionetgen/core/tools/__init__.py +++ b/bionetgen/core/tools/__init__.py @@ -7,3 +7,16 @@ from .cli import BNGCLI from .visualize import BNGVisualize from .gdiff import BNGGdiff +from .bngsim_bridge import ( + BNGSIM_AVAILABLE, + BNGSIM_HAS_NFSIM, + BNGSIM_VERSION, + detect_input_format, + run_bngl_with_bngsim, + run_bngl_with_bngsim_backend_hook, + run_nfsim, + run_with_bngsim, +) +from .bngsim_backend_helper import ( + execute_backend_payload as execute_bngsim_backend_payload, +) diff --git a/bionetgen/core/tools/bngsim_backend_helper.py b/bionetgen/core/tools/bngsim_backend_helper.py new file mode 100644 index 00000000..b82fbf65 --- /dev/null +++ b/bionetgen/core/tools/bngsim_backend_helper.py @@ -0,0 +1,483 @@ +"""JSON helper for BNG2.pl-owned BNGsim backend jobs. + +This module is the process boundary for the Stage 4 BNG2.pl simulator +backend hook. BNG2.pl remains responsible for BNGL parsing, action +semantics, state, scans, and output naming; it passes one already-normalized +atomic simulation job here as JSON. + +Two invocation modes: + + * **one-shot** -- ``python -m ...bngsim_backend_helper JOB.json`` runs a + single job and exits. Simple, but pays Python interpreter startup and + ``import bngsim`` on every call; a parameter_scan invokes it once per + scan point. + * **serve** -- ``python -m ...bngsim_backend_helper --serve --socket PATH`` + runs a persistent Unix-domain-socket server: one process for a whole + BNG2.pl run, so the import cost is paid once. :class:`BNGCLI` spawns it + and advertises the socket; the BNG2.pl hook sends each job over it and + falls back to a one-shot ``system()`` spawn if the socket is absent. +""" + +from __future__ import annotations + +import json +import os +import socket +import sys +import traceback +from dataclasses import dataclass +from typing import Any, overload + +from bionetgen.core.exc import BNGSimError +from bionetgen.core.tools.bngsim_bridge import ( + BngsimDirectJob, + FORMAT_BNG_XML, + FORMAT_NET, + execute_bngsim_direct_job, +) + +NETWORK_METHOD_ALIASES = { + "cvode": "ode", + "ode": "ode", + "ssa": "ssa", + "psa": "psa", + "rm": "rm", +} + +NF_METHOD_ALIASES = { + "nf": "nf", + "nfsim": "nf", + "nf_reject": "nf", +} + +# Network-free methods (canonical names). BNG2.pl runs both ``nf`` and +# rm-rewritten-to-``nf`` BNGL through ``sub simulate_nf``, which reports +# timepoints as time elapsed since ``t_start`` (the output axis starts at +# 0). The network methods (ode/ssa/psa) instead honor ``t_start`` via +# ``run_network -i``. See ``direct_job_from_backend_job``. +NETWORK_FREE_METHODS = frozenset({"nf", "rm"}) + +ARTIFACT_FORMAT_ALIASES = { + "net": FORMAT_NET, + ".net": FORMAT_NET, + "bng-xml": FORMAT_BNG_XML, + "bng_xml": FORMAT_BNG_XML, + "xml": FORMAT_BNG_XML, + ".xml": FORMAT_BNG_XML, +} + + +@dataclass(frozen=True) +class BackendHelperJob: + """Machine-readable job supplied by a BNG2.pl simulator hook.""" + + artifact_path: str + artifact_format: str + method: str + simulation_options: dict[str, Any] + output_dir: str + output_root: str + backend_flags: dict[str, Any] + + +@overload +def _as_number(value: Any, default: float) -> float: ... +@overload +def _as_number(value: Any, default: None = None) -> float | None: ... +def _as_number(value: Any, default: float | None = None) -> float | None: + # When called with a concrete float default (e.g. t_start/t_end), the + # result is always a float — every return path yields the float `default` + # or float(value). The overloads encode that so callers don't see float|None. + if value is None: + return default + try: + return float(value) + except (TypeError, ValueError): + return default + + +def _as_int(value: Any, default: int | None = None) -> int | None: + number = _as_number(value) + if number is None: + return default + return int(number) + + +def _parse_nf_param_flags(param: Any) -> dict[str, Any]: + """Map a BNG2.pl NFsim ``param=>`` flag string to BNGsim named options. + + BNG2.pl forwards the ``param`` string verbatim to the NFsim binary's + command line. BNGsim has no raw-flag passthrough but exposes the same + capabilities as named options, so the common flags are translated: + + ``-ogf`` (output global functions) -> ``print_functions=1`` (the + global functions become extra .gdat columns, matching BNG2.pl) + ``-gml N`` (global molecule limit) -> ``gml=N`` + + Unrecognized flags are ignored (BNGsim manages e.g. complex bookkeeping + itself). Returns a dict of recognized options; callers apply it with + ``setdefault`` so an explicit ``print_functions=>``/``gml=>`` keyword on + the action still wins. + """ + if not isinstance(param, str): + return {} + toks = param.strip().strip('"').strip("'").split() + out: dict[str, Any] = {} + i = 0 + while i < len(toks): + tok = toks[i] + if tok in ("-ogf", "--ogf"): + out["print_functions"] = 1 + elif tok in ("-gml", "--gml", "-globalMoleculeLimit"): + if i + 1 < len(toks): + gml = _as_int(toks[i + 1]) + if gml is not None: + out["gml"] = gml + i += 1 + i += 1 + return out + + +def _normalize_method(method: Any) -> str: + method_name = str(method or "").strip().lower() + if method_name in NETWORK_METHOD_ALIASES: + return NETWORK_METHOD_ALIASES[method_name] + if method_name in NF_METHOD_ALIASES: + return NF_METHOD_ALIASES[method_name] + raise BNGSimError(f"Unsupported BNGsim backend method: {method!r}") + + +def _normalize_artifact_format(raw_format: Any, artifact_path: str) -> str: + if raw_format is not None: + fmt = ARTIFACT_FORMAT_ALIASES.get(str(raw_format).strip().lower()) + if fmt is None: + raise BNGSimError( + f"Unsupported BNGsim backend artifact format: {raw_format!r}" + ) + return fmt + + ext = os.path.splitext(artifact_path)[1].lower() + fmt = ARTIFACT_FORMAT_ALIASES.get(ext) + if fmt is None: + raise BNGSimError( + f"Could not infer BNGsim backend artifact format from {artifact_path!r}" + ) + return fmt + + +def _output_dir_and_root(payload: dict[str, Any]) -> tuple[str, str]: + output_prefix = payload.get("output_prefix") or payload.get("output_path_root") + if output_prefix: + output_prefix = os.path.abspath(str(output_prefix)) + output_dir = os.path.dirname(output_prefix) or os.getcwd() + output_root = os.path.basename(output_prefix) + return output_dir, output_root + + output_dir = os.path.abspath(str(payload.get("output_dir") or os.getcwd())) + output_root = payload.get("output_root") + if not output_root: + artifact_path = str( + payload.get("artifact_path") or payload.get("input_path") or "" + ) + output_root = os.path.splitext(os.path.basename(artifact_path))[0] + return output_dir, str(output_root) + + +def load_backend_job(payload: dict[str, Any]) -> BackendHelperJob: + """Validate and normalize one BNG2.pl backend job payload.""" + artifact_path = payload.get("artifact_path") or payload.get("input_path") + if not artifact_path: + raise BNGSimError("BNGsim backend job is missing artifact_path") + artifact_path = os.path.abspath(str(artifact_path)) + + method = _normalize_method(payload.get("method")) + # BNG2.pl has no ``rm`` method, so ``method=>"rm"`` BNGL is rewritten to + # ``nf`` before BNG2.pl runs and the real method is carried out of band + # in BIONETGEN_BNGSIM_BACKEND_METHOD. Restore it here. The override + # applies only to network-free jobs (the simulate_nf hook always sends + # ``nf``); network jobs (ode/ssa/psa) in the same run are left alone. + method_override = ( + os.environ.get("BIONETGEN_BNGSIM_BACKEND_METHOD", "").strip().lower() + ) + if method_override == "rm" and method == "nf": + method = "rm" + artifact_format = _normalize_artifact_format( + payload.get("artifact_format") or payload.get("input_format"), + artifact_path, + ) + output_dir, output_root = _output_dir_and_root(payload) + + sim_options = dict( + payload.get("simulation_options") or payload.get("options") or {} + ) + backend_flags = dict(payload.get("backend_flags") or {}) + + return BackendHelperJob( + artifact_path=artifact_path, + artifact_format=artifact_format, + method=method, + simulation_options=sim_options, + output_dir=output_dir, + output_root=output_root, + backend_flags=backend_flags, + ) + + +def direct_job_from_backend_job(job: BackendHelperJob) -> BngsimDirectJob: + """Convert a hook job into the Stage 2 direct BNGsim executor contract.""" + opts = dict(job.simulation_options) + t_start = _as_number(opts.pop("t_start", None), 0.0) + t_end = _as_number(opts.pop("t_end", None), 100.0) + + # Network-free methods follow NFsim's output convention: BNG2.pl's + # ``sub simulate_nf`` reports timepoints as elapsed time since + # ``t_start`` (axis starts at 0) and warns the user it does so. Rebase + # the span to start at 0 so the BNGsim run matches BNG2.pl output -- + # both the time column and any ``time()``-dependent rate laws, which + # would otherwise evaluate over the wrong interval. Network methods + # (ode/ssa/psa) keep ``t_start``; BNG2.pl honors it there. + if job.method in NETWORK_FREE_METHODS and t_start != 0.0: + t_end = t_end - t_start + t_start = 0.0 + + # Explicit output times. BNG2.pl's ``sub simulate`` honors a + # ``sample_times`` array; the backend hook forwards it. BNG2.pl emits + # the initial (t_start) state row followed by the explicit sample + # times, whereas BNGsim's ``sample_times`` yields exactly the listed + # times -- so prepend t_start for output parity. ``n_points`` is then + # the row count; BNGsim ignores it when ``sample_times`` is given. + sample_times = opts.pop("sample_times", None) + n_points = _as_int(opts.pop("n_points", None)) + n_steps = _as_int(opts.pop("n_steps", opts.pop("n_output_steps", None))) + if sample_times: + sample_times = [float(t_start)] + [float(t) for t in sample_times] + opts["sample_times"] = sample_times + n_points = len(sample_times) + elif n_points is None: + n_points = (n_steps if n_steps is not None else 100) + 1 + + # get_final_state=>1 (default for simulate_nf) drives a .species + # final-state writeback so BNG2.pl's readNFspecies can continue the + # trajectory across saveConcentrations/resetConcentrations segments. + get_final_state = bool(_as_int(opts.pop("get_final_state", 0), 0)) + + # Translate a raw NFsim flag string (e.g. param=>"-ogf -gml 500000") into + # the structured options BNGsim uses. BNG2.pl passes `param` verbatim to + # the NFsim binary; BNGsim has no raw-flag passthrough but exposes the + # same capabilities as named options. A param flag overrides the matching + # named option: BNG2.pl's hook always sends print_functions (defaulting to + # 0), so it is indistinguishable from an explicit keyword — and a model + # author writing param=>"-ogf" is explicitly requesting function output, + # which must win over that auto-sent default. + for flag_key, flag_val in _parse_nf_param_flags(opts.pop("param", None)).items(): + opts[flag_key] = flag_val + + result_options = {} + if "print_functions" in opts: + result_options["print_functions"] = bool( + _as_int(opts.pop("print_functions"), 0) + ) + # ``print_CDAT=>0`` keeps only the initial and final .cdat rows. + if "print_CDAT" in opts: + result_options["print_cdat"] = bool(_as_int(opts.pop("print_CDAT"), 1)) + # ``continue=>1`` segments append to the prior segment's output files + # (skipping the duplicated t_start row) instead of overwriting them. + if "continue" in opts: + result_options["append"] = bool(_as_int(opts.pop("continue"), 0)) + + bngsim_options = {} + for key in ( + "seed", + "poplevel", + "atol", + "rtol", + "gml", + "nf_params", + "param_overrides", + "conc_overrides", + "conc_deltas", + ): + if key in opts and opts[key] is not None: + bngsim_options[key] = opts.pop(key) + bngsim_options.update(opts) + + return BngsimDirectJob( + input_path=job.artifact_path, + input_format=job.artifact_format, + method=job.method, + t_span=(float(t_start), float(t_end)), + n_points=int(n_points), + output_dir=job.output_dir, + output_root=job.output_root, + bngsim_options=bngsim_options, + result_options=result_options, + get_final_state=get_final_state, + ) + + +def execute_backend_payload(payload: dict[str, Any]) -> dict[str, Any]: + """Execute one JSON payload and return a JSON-serializable status.""" + helper_job = load_backend_job(payload) + direct_job = direct_job_from_backend_job(helper_job) + result = execute_bngsim_direct_job(direct_job) + return { + "success": True, + "method": helper_job.method, + "artifact_path": helper_job.artifact_path, + "output_dir": helper_job.output_dir, + "output_root": helper_job.output_root, + "process_return": getattr(result, "process_return", 0), + } + + +# A request line on the serve socket equal to this string stops the server. +SHUTDOWN_REQUEST = "__SHUTDOWN__" +# Printed on stdout once the serve socket is bound and listening; BNGCLI +# waits for this line before launching BNG2.pl. +SERVE_READY_TOKEN = "READY" + + +def _run_job_file(job_path: str) -> dict[str, Any]: + """Load a JSON job file and execute it, returning a status dict. + + The job is run with the process cwd set to the job's output directory, + matching the one-shot ``system()`` helper (which inherited BNG2.pl's + cwd) so BNGsim writes artifacts in the same place either way. + """ + with open(job_path, "r", encoding="utf-8") as handle: + payload = json.load(handle) + out_dir, _root = _output_dir_and_root(payload) + prev_cwd = os.getcwd() + try: + if out_dir and os.path.isdir(out_dir): + os.chdir(out_dir) + return execute_backend_payload(payload) + finally: + os.chdir(prev_cwd) + + +def serve(socket_path: str) -> int: + """Run a persistent job server on a Unix-domain socket. + + One newline-terminated request per connection: either a job-file path or + the literal :data:`SHUTDOWN_REQUEST`. The reply is a single line -- + ``OK `` if the job succeeded, ``ERR `` otherwise. The loop + runs until a shutdown request, so a whole BNG2.pl run (e.g. every point + of a parameter_scan) is served by one process and ``import bngsim`` is + paid once. Each job is dispatched through the same code path as the + one-shot mode; a job that raises is reported, not fatal to the server. + """ + if os.path.exists(socket_path): + os.unlink(socket_path) + # AF_UNIX is POSIX-only and serve() is never reached on Windows (BNGCLI + # guards it); getattr keeps mypy happy on win32, where the attribute is + # absent, without an ignore that warn_unused_ignores would flag on POSIX. + af_unix = getattr(socket, "AF_UNIX") + srv = socket.socket(af_unix, socket.SOCK_STREAM) + try: + srv.bind(socket_path) + # A readiness probe connects-then-closes before serve accepts it, + # leaving that connection occupying a backlog slot until accept(). With + # a backlog of 1 the next client can hit a full queue and get + # ECONNREFUSED (flaky on loaded runners); a generous backlog avoids it + # and costs nothing. + srv.listen(socket.SOMAXCONN) + # Handshake: BNGCLI blocks on this line before launching BNG2.pl. + print(SERVE_READY_TOKEN, flush=True) + while True: + conn, _addr = srv.accept() + try: + request = conn.makefile("r", encoding="utf-8").readline().strip() + if request == SHUTDOWN_REQUEST: + break + if not request: + # Client connected without sending a request (e.g. a + # readiness probe). Harmless -- never a shutdown signal. + continue + try: + status = _run_job_file(request) + ok = bool(status.get("success")) + except Exception as exc: + ok = False + status = { + "success": False, + "error": str(exc), + "traceback": traceback.format_exc(), + } + reply = ("OK " if ok else "ERR ") + json.dumps(status, sort_keys=True) + conn.sendall((reply + "\n").encode("utf-8")) + finally: + conn.close() + return 0 + finally: + srv.close() + if os.path.exists(socket_path): + os.unlink(socket_path) + + +def _run_one_shot(job_path: str) -> int: + try: + with open(job_path, "r", encoding="utf-8") as handle: + payload = json.load(handle) + status = execute_backend_payload(payload) + print(json.dumps(status, sort_keys=True)) + return 0 + except Exception as exc: + status = { + "success": False, + "error": str(exc), + "traceback": traceback.format_exc(), + } + print(json.dumps(status, sort_keys=True), file=sys.stderr) + return 1 + + +def main(argv: list[str] | None = None) -> int: + argv = list(sys.argv[1:] if argv is None else argv) + + if argv and argv[0] == "--serve": + rest = argv[1:] + socket_path = rest[1] if len(rest) == 2 and rest[0] == "--socket" else None + if not socket_path: + print( + json.dumps( + { + "success": False, + "error": "usage: python -m bionetgen.core.tools." + "bngsim_backend_helper --serve --socket PATH", + } + ), + file=sys.stderr, + ) + return 2 + try: + return serve(socket_path) + except Exception as exc: + print( + json.dumps( + { + "success": False, + "error": str(exc), + "traceback": traceback.format_exc(), + } + ), + file=sys.stderr, + ) + return 1 + + if len(argv) != 1: + print( + json.dumps( + { + "success": False, + "error": "usage: python -m bionetgen.core.tools.bngsim_backend_helper JOB.json", + } + ), + file=sys.stderr, + ) + return 2 + return _run_one_shot(argv[0]) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bionetgen/core/tools/bngsim_bridge.py b/bionetgen/core/tools/bngsim_bridge.py new file mode 100644 index 00000000..4552fe7b --- /dev/null +++ b/bionetgen/core/tools/bngsim_bridge.py @@ -0,0 +1,1952 @@ +"""Bridge module for optional BNGsim integration. + +BNGsim is a high-performance C++ simulation engine with Python bindings +that can replace run_network and NFsim for in-process simulation. +This module handles availability detection, input format detection, +and routing simulation requests to BNGsim when available. +""" + +from __future__ import annotations + +import inspect +import logging +import os +import re +import shutil +import tempfile +from dataclasses import dataclass + +from bionetgen.core.exc import BNGFormatError, BNGSimError + +logger = logging.getLogger("bionetgen.bngsim_bridge") + +# ─── Availability detection ──────────────────────────────────────── +# +# BNGsim is an *optional* dependency by design: if it's not installed, +# PyBioNetGen routes every simulation through subprocess BNG2.pl / +# run_network / NFsim (the documented fallback). When BNGsim *is* +# installed, however, PyBioNetGen depends on specific behaviour that +# only the modern releases provide — most recently 0.6.0's NFsim +# global-function support (closes bngsim #40), without which the +# network-free corpus produces incomplete output. So the contract is +# "optional, but if present must be recent." +# +# Implementation: +# * Not installed → BNGSIM_AVAILABLE=False, reason "not installed", +# no warning (this is the documented optional path). +# * Installed but __version__ < MINIMUM_BNGSIM_VERSION → +# BNGSIM_AVAILABLE=False with a "too old" reason that the explicit +# --simulator bngsim error path surfaces. A one-time WARNING fires +# for the auto-route fall-through so the silent "we couldn't use +# bngsim" outcome is at least visible. +# * Installed and recent → BNGSIM_AVAILABLE=True; the same code paths +# as before. +# +# The single `BNGSIM_AVAILABLE` boolean is the canonical gate — by +# downgrading it to False for "too old", every existing +# `if not BNGSIM_AVAILABLE` site (≈8 places) naturally falls back without +# needing per-site version checks. + +MINIMUM_BNGSIM_VERSION = "0.6.0" +BNGSIM_UNAVAILABLE_REASON: str | None = None +_VERSION_FALLBACK_WARNED = False + +try: + if os.environ.get("BIONETGEN_NO_BNGSIM"): + raise ImportError("BIONETGEN_NO_BNGSIM is set") + import bngsim + + BNGSIM_AVAILABLE = True +except ImportError as _bngsim_import_err: + bngsim = None + BNGSIM_AVAILABLE = False + BNGSIM_UNAVAILABLE_REASON = ( + "bngsim is not installed" + if "BIONETGEN_NO_BNGSIM" not in str(_bngsim_import_err) + else "BIONETGEN_NO_BNGSIM is set" + ) + +BNGSIM_VERSION: str | None = None +if BNGSIM_AVAILABLE: + BNGSIM_VERSION = getattr(bngsim, "__version__", "unknown") + try: + from packaging.version import Version + + if Version(BNGSIM_VERSION) < Version(MINIMUM_BNGSIM_VERSION): + BNGSIM_AVAILABLE = False + BNGSIM_UNAVAILABLE_REASON = ( + f"bngsim {BNGSIM_VERSION} is older than the required " + f"{MINIMUM_BNGSIM_VERSION}; upgrade with " + f"`pip install -U bngsim` or rebuild the editable install" + ) + except Exception: + # If `packaging` isn't available or __version__ is unparseable, + # err on the side of "available" — the worst that happens is the + # user gets a downstream BNGsim-API error with concrete cause, + # which is better than silently disabling a possibly-fine install. + pass + + +def is_bngsim_available() -> bool: + """Public predicate: is bngsim importable AND >= MINIMUM_BNGSIM_VERSION?""" + return BNGSIM_AVAILABLE + + +def get_bngsim_unavailable_reason() -> str | None: + """Why bngsim isn't usable, or None if it is. Suitable for embedding + in CLI error messages.""" + return BNGSIM_UNAVAILABLE_REASON + + +def _warn_version_fallback_once() -> None: + """Emit a one-time WARNING when auto-route falls back due to too-old + bngsim. Silent for "not installed" (documented optional path).""" + global _VERSION_FALLBACK_WARNED + if _VERSION_FALLBACK_WARNED: + return + if BNGSIM_VERSION is None: + return # not installed — silent fallback per the optional contract + if BNGSIM_AVAILABLE: + return # nothing to warn about + logger.warning( + "bngsim %s is older than required %s; falling back to subprocess " + "BNG2.pl. Upgrade bngsim to use the in-process engine. (%s)", + BNGSIM_VERSION, + MINIMUM_BNGSIM_VERSION, + BNGSIM_UNAVAILABLE_REASON, + ) + _VERSION_FALLBACK_WARNED = True + + +BNGSIM_HAS_NFSIM = False +if BNGSIM_AVAILABLE: + try: + from bngsim import HAS_NFSIM + + BNGSIM_HAS_NFSIM = bool(HAS_NFSIM) + except (ImportError, AttributeError): + BNGSIM_HAS_NFSIM = False + +BNGSIM_HAS_RULEMONKEY = False +if BNGSIM_AVAILABLE: + try: + from bngsim import HAS_RULEMONKEY + + BNGSIM_HAS_RULEMONKEY = bool(HAS_RULEMONKEY) + except (ImportError, AttributeError): + BNGSIM_HAS_RULEMONKEY = bool(getattr(bngsim, "RuleMonkeySession", None)) + + +# ─── Format constants ────────────────────────────────────────────── + +FORMAT_BNGL = "bngl" +FORMAT_NET = "net" +FORMAT_SBML = "sbml" +FORMAT_BNG_XML = "bng-xml" +FORMAT_ANTIMONY = "antimony" + +VALID_FORMATS = {FORMAT_BNGL, FORMAT_NET, FORMAT_SBML, FORMAT_BNG_XML, FORMAT_ANTIMONY} + +# Formats that require BNGsim (no subprocess fallback) +BNGSIM_REQUIRED_FORMATS = {FORMAT_SBML, FORMAT_ANTIMONY} + +# Formats that have subprocess fallbacks +FALLBACK_FORMATS = {FORMAT_BNGL, FORMAT_NET, FORMAT_BNG_XML} + +ROUTE_DIRECT_BNGSIM = "direct-bngsim" +ROUTE_BNGL_BNGSIM = "bngl-bngsim" +ROUTE_SUBPROCESS = "subprocess" +ROUTE_ERROR = "error" + + +@dataclass(frozen=True) +class BngsimRouteDecision: + """Conservative routing decision for optional BNGsim use.""" + + route: str + reason: str + method: str | None = None + + +@dataclass(frozen=True) +class BngsimDirectJob: + """Normalized direct BNGsim job for non-BNGL artifacts.""" + + input_path: str + input_format: str + method: str + t_span: tuple[float, float] + n_points: int + output_dir: str + output_root: str + bngsim_options: dict | None = None + result_options: dict | None = None + get_final_state: bool = False + + +# ─── Format detection ────────────────────────────────────────────── + + +def _sniff_xml_format(file_path): + """Sniff an XML file to determine if it is SBML or BioNetGen XML. + + Reads the first ~4KB of the file and looks for distinguishing markers. + + Returns + ------- + str or None + FORMAT_SBML, FORMAT_BNG_XML, or None if ambiguous. + """ + try: + with open(file_path, "r", errors="replace") as f: + head = f.read(4096) + except OSError as e: + raise BNGFormatError( + file_path, f"Could not read file for format detection: {e}" + ) from e + + head_lower = head.lower() + + is_sbml = ""ssa"`` to PSA when ``poplevel`` is + defined. BNGsim also supports ``method=>"psa"`` directly. This + function handles both conventions. + + Returns + ------- + (method, poplevel) : (str, float or None) + """ + method = method.strip().lower() + + # BNG2.pl compat: ssa + poplevel → psa + if method == "ssa" and poplevel is not None: + return "psa", poplevel + + # Direct psa: default poplevel to 100 if not specified (BNG2.pl default) + if method == "psa": + if poplevel is None or poplevel <= 1.0: + poplevel = 100.0 + return "psa", poplevel + + return method, poplevel + + +def _write_bng_dat(path, time, data_2d, col_names): + """Write a BNG-format data file (space-separated with # header). + + Parameters + ---------- + path : str + Output file path. + time : numpy.ndarray + 1D array of time values. + data_2d : numpy.ndarray + 2D array (n_times x n_cols). + col_names : list of str + Column names (excluding 'time'). + """ + + headers = ["time"] + list(col_names) + with open(path, "w") as f: + f.write("# " + " ".join(f"{h:>18s}" for h in headers) + "\n") + for i in range(len(time)): + vals = [time[i]] + [data_2d[i, j] for j in range(data_2d.shape[1])] + f.write(" ".join(f"{v:22.12e}" for v in vals) + "\n") + + +def _append_bng_dat_rows(path, time, data_2d, skip_first=True): + """Append data rows to an existing .gdat/.cdat file (no header). + + Used for ``continue=>1`` to extend a prior segment's output. The first + row of *time* is normally the previous segment's t_end (a duplicate), + so it is skipped by default — matching BNG2.pl's run_network ``-x``. + """ + start = 1 if (skip_first and len(time) > 0) else 0 + with open(path, "a") as f: + for i in range(start, len(time)): + vals = [time[i]] + [data_2d[i, j] for j in range(data_2d.shape[1])] + f.write(" ".join(f"{v:22.12e}" for v in vals) + "\n") + + +def _append_cdat_rows(cdat_path, result): + """Append rows from a fresh BNGsim Result to an existing .cdat file.""" + import numpy as np + + species = np.asarray(result.species) + time = np.asarray(result.time) + if species.ndim != 2 or species.shape[0] == 0: + return + _append_bng_dat_rows(cdat_path, time, species, skip_first=True) + + +def _truncate_cdat_to_endpoints(cdat_path): + """Reduce a .cdat to its comment header plus first and last data rows. + + Matches BNG2.pl's ``print_CDAT=>0`` behavior: BNG2.pl still emits a + .cdat (the final row carries the end-state used for concentration + write-back) but only the initial and final concentration rows, not + the full trajectory. + """ + with open(cdat_path) as handle: + lines = handle.readlines() + header = [ln for ln in lines if ln.lstrip().startswith("#")] + data = [ln for ln in lines if ln.strip() and not ln.lstrip().startswith("#")] + if len(data) <= 2: + return + with open(cdat_path, "w") as handle: + handle.writelines(header + [data[0], data[-1]]) + + +def _write_bngsim_results( + result, + output_dir, + model_name, + print_functions=False, + append=False, + print_cdat=True, +): + """Write BNGsim Result to .gdat and .cdat files. + + Parameters + ---------- + result : bngsim.Result + The simulation result. + output_dir : str + Directory to write output files. + model_name : str + Base name for output files (without extension). + print_functions : bool + If True, include BNGL functions (BNGsim "expressions") in .gdat + output. Matches BNG2.pl's ``print_functions=>1`` behavior. + Default False, matching BNG2.pl's default. As of bngsim's + single-format schema (issue #58), function columns are written bare + with the synthetic ``_rateLawN`` intermediates already excluded, for + every method — ``Result.expression_names``/``expressions`` carry + exactly that, so there is no per-method header/column massaging here. + append : bool + If True and the target files already exist, append rows from + *result* (skipping its first row, which duplicates the prior + segment's t_end). Used for ``continue=>1``. If the files do not + yet exist, falls back to a fresh write so the first segment of + a continuation chain still produces complete output. + print_cdat : bool + If False, the .cdat is reduced to its initial and final rows, + matching BNG2.pl's ``print_CDAT=>0`` behavior. Default True + (full trajectory), matching BNG2.pl's default. + Function columns are written only when BNGsim supplies them in + ``Result.expressions``. BNGL-owned function semantics are handled by + BNG2.pl before invoking the backend helper. + """ + import numpy as np + + os.makedirs(output_dir, exist_ok=True) + gdat_path = os.path.join(output_dir, f"{model_name}.gdat") + cdat_path = os.path.join(output_dir, f"{model_name}.cdat") + + do_append = append and os.path.exists(gdat_path) and os.path.exists(cdat_path) + + # Build the optional functions block once for both write/append paths + obs_names = list(result.observable_names) + obs_array = ( + np.asarray(result.observables) + if result.n_observables > 0 + else np.empty((result.n_times, 0)) + ) + + func_names = [] + func_array = np.empty((result.n_times, 0)) + has_funcs = False + if print_functions: + # Single, method-independent schema (issue #58): bare function + # headers, synthetic _rateLawN already excluded. expression_names / + # expressions carry exactly that for every method, so write them + # as-is — no () injection or _rateLaw filtering. (The parity differ + # reconciles BNG2.pl's per-method () headers and _rateLaw columns by + # normalizing both sides; see scripts/parity_diff.py.) + func_names = list(result.expression_names) + bngsim_func_array = np.asarray(result.expressions) + if ( + func_names + and bngsim_func_array.ndim == 2 + and bngsim_func_array.shape[1] == len(func_names) + ): + func_array = bngsim_func_array + has_funcs = True + + if has_funcs: + combined = np.hstack([obs_array, func_array]) + combined_names = obs_names + func_names + else: + combined = obs_array + combined_names = obs_names + + if do_append: + _append_cdat_rows(cdat_path, result) + if result.n_observables > 0 or has_funcs: + _append_bng_dat_rows(gdat_path, result.time, combined, skip_first=True) + if not print_cdat: + _truncate_cdat_to_endpoints(cdat_path) + return + + # Fresh write (default and first-segment-of-continuation path) + result.to_cdat(cdat_path) + if not print_cdat: + _truncate_cdat_to_endpoints(cdat_path) + if result.n_observables > 0 or has_funcs: + _write_bng_dat(gdat_path, result.time, combined, combined_names) + + +def _write_nf_final_state_species(session, output_dir, output_root): + """Write the network-free final particle state to ``.species``. + + Mirrors NFsim's ``-ss .species`` output so BNG2.pl's + ``readNFspecies`` can read the equilibrated state back into the model + for ``get_final_state=>1`` trajectory continuation (the + saveConcentrations/resetConcentrations carry-over across simulate + segments). Both NfsimSession and RuleMonkeySession (bngsim >= 0.9.2) + expose ``save_species``; if a session somehow lacks it, the writeback + is skipped and the Perl hook warns (no file). The full complex state — + not just molecule-type counts — flows this way. + """ + save = getattr(session, "save_species", None) + if not callable(save): + logger.warning( + "get_final_state requested but this BNGsim session has no " + "save_species; final-state writeback skipped." + ) + return + species_path = os.path.join(output_dir, f"{output_root}.species") + try: + save(species_path) + except Exception as exc: + logger.warning( + "get_final_state: save_species failed (%s); final-state writeback skipped.", + exc, + ) + + +def _make_bng_result(output_dir, method): + """Load a BNGResult from an output directory.""" + from bionetgen.core.tools.result import BNGResult + + bng_result = BNGResult(path=output_dir) + bng_result.process_return = 0 + bng_result.output = [f"BNGsim simulation completed: method={method}"] + return bng_result + + +def _collapse_nfsim_concentration_changes( + conc_overrides=None, + conc_deltas=None, +): + """Collapse concentration changes to NFsim's molecule-type granularity.""" + collapsed_overrides = {} + collapsed_deltas = {} + + if conc_overrides: + for species_pattern, target_count in conc_overrides.items(): + mol_type = str(species_pattern).split("(", 1)[0] + try: + collapsed_overrides[mol_type] = collapsed_overrides.get( + mol_type, 0 + ) + int(target_count) + except Exception as e: + logger.warning( + "NFsim: conc override for %s failed: %s", + species_pattern, + e, + ) + + if conc_deltas: + for species_pattern, delta_count in conc_deltas.items(): + mol_type = str(species_pattern).split("(", 1)[0] + try: + collapsed_deltas[mol_type] = collapsed_deltas.get(mol_type, 0) + int( + delta_count + ) + except Exception as e: + logger.warning( + "NFsim: conc delta for %s failed: %s", + species_pattern, + e, + ) + + return collapsed_overrides, collapsed_deltas + + +def _apply_nfsim_concentration_changes( + nfsim, + conc_overrides=None, + conc_deltas=None, +): + """Apply recorded concentration changes to a fresh NFsim session.""" + if ( + callable(getattr(nfsim, "set_species_count", None)) + and callable(getattr(nfsim, "add_species", None)) + and callable(getattr(nfsim, "remove_species", None)) + ): + remaining_deltas = { + str(species_pattern): delta + for species_pattern, delta in (conc_deltas or {}).items() + } + + if conc_overrides: + for species_pattern, target_count in conc_overrides.items(): + try: + pattern = str(species_pattern) + desired_count = int(target_count) + int( + remaining_deltas.pop(pattern, 0) + ) + nfsim.set_species_count(pattern, desired_count) + except Exception as e: + logger.warning( + "NFsim: concentration replay for %s failed: %s", + species_pattern, + e, + ) + + for species_pattern, delta in remaining_deltas.items(): + try: + pattern = str(species_pattern) + delta = int(delta) + if delta > 0: + nfsim.add_species(pattern, delta) + elif delta < 0: + nfsim.remove_species(pattern, -delta) + except Exception as e: + logger.warning( + "NFsim: concentration replay for %s failed: %s", + species_pattern, + e, + ) + return + + collapsed_overrides, collapsed_deltas = _collapse_nfsim_concentration_changes( + conc_overrides=conc_overrides, + conc_deltas=conc_deltas, + ) + + for mol_type, target_count in collapsed_overrides.items(): + try: + desired_count = target_count + collapsed_deltas.pop(mol_type, 0) + current = nfsim.get_molecule_count(mol_type) + to_add = desired_count - current + if to_add > 0: + nfsim.add_molecules(mol_type, to_add) + elif to_add < 0: + logger.warning( + "NFsim: cannot decrease %s from %d to %d; leaving count unchanged", + mol_type, + current, + desired_count, + ) + except Exception as e: + logger.warning( + "NFsim: concentration replay for %s failed: %s", + mol_type, + e, + ) + + for mol_type, delta in collapsed_deltas.items(): + try: + if delta > 0: + nfsim.add_molecules(mol_type, delta) + elif delta < 0: + logger.warning( + "NFsim: cannot decrease %s by %d; leaving count unchanged", + mol_type, + -delta, + ) + except Exception as e: + logger.warning( + "NFsim: concentration replay for %s failed: %s", + mol_type, + e, + ) + + +def _load_direct_bngsim_model(input_path, fmt): + """Load a direct network-backed artifact into a BNGsim Model.""" + if fmt == FORMAT_NET: + return bngsim.Model.from_net(input_path) + if fmt == FORMAT_SBML: + return bngsim.Model.from_sbml(input_path) + if fmt == FORMAT_ANTIMONY: + return bngsim.Model.from_antimony(input_path) + raise BNGSimError(f"Unsupported format for BNGsim: '{fmt}'") + + +# Options the BNG2.pl backend hook / direct job may carry, split by which +# BNGsim entry point consumes them. ``bngsim.Simulator.__init__`` takes +# model-construction options; ``bngsim.Simulator.run`` takes per-run +# integration options. Anything else (e.g. ``print_CDAT``, an output-format +# flag BNG2.pl always emits) is not a BNGsim argument and is dropped here. +_SIMULATOR_INIT_OPTIONS = frozenset( + { + "poplevel", + "gml", + "connectivity", + "nfsim_v1143_compat", + "block_same_complex_binding", + "traversal_limit", + "jacobian", + "codegen", + "net_path", + "strict_ssa", + } +) +_SIMULATOR_RUN_OPTIONS = frozenset( + { + "seed", + "rtol", + "atol", + "max_steps", + "sample_times", + } +) + + +def _ss_truthy(value): + """Interpret a BNGL action value (str/int/bool) as a boolean flag.""" + if isinstance(value, str): + text = value.strip().strip('"').strip("'").lower() + if text in ("", "0", "false", "no", "off"): + return False + try: + return float(text) != 0.0 + except ValueError: + return text in ("1", "true", "yes", "on") + return bool(value) + + +def _partition_simulator_options(sim_options): + """Split direct-job options into Simulator __init__ vs run kwargs. + + Returns ``(init_kwargs, run_kwargs)``. ``print_CDAT`` is an output flag + (the .cdat is always written for network models) and ``sparse`` is not + part of the BNGsim API, so those are dropped. + + ``steady_state=>1`` is forwarded to ``run(steady_state=True)`` — the + BNG2.pl ``simulate({steady_state=>1})`` / ``run_network -c`` early-stop + (integrate until ``||f||_2/n_species`` falls below the tolerance, which + defaults to ``atol``). ``steady_state_tol`` overrides that tolerance. + ``ss_method`` selects the steady-state solver for parameter *scans*; a + direct ``simulate`` writes a single trajectory and therefore always uses + the parity integrator, so a Newton/kinsol request here is logged and the + parity path is used. + """ + init_kwargs = {} + run_kwargs = {} + dropped = [] + handled = {"steady_state", "steady_state_tol", "ss_method"} + for key, value in sim_options.items(): + if value is None: + continue + if key in handled: + continue + if key in _SIMULATOR_INIT_OPTIONS: + init_kwargs[key] = value + elif key in _SIMULATOR_RUN_OPTIONS: + run_kwargs[key] = value + else: + dropped.append(key) + + ss_method = sim_options.get("ss_method") + if _ss_truthy(sim_options.get("steady_state")): + run_kwargs["steady_state"] = True + tol = sim_options.get("steady_state_tol") + if tol is not None: + try: + run_kwargs["steady_state_tol"] = float(tol) + except (TypeError, ValueError): + logger.warning( + "Direct BNGsim job: ignoring non-numeric steady_state_tol=%r", tol + ) + if ss_method is not None: + normalized = str(ss_method).strip().strip('"').strip("'").lower() + if normalized in ("newton", "kinsol"): + logger.info( + "Direct BNGsim simulate({steady_state=>1}) integrates to the " + "parity steady state (run_network -c); ss_method=>%r (the " + "Newton accelerator) applies to parameter scans, not a single " + "time course, so the parity integrator is used here.", + ss_method, + ) + elif ss_method is not None: + # ss_method without steady_state=>1 has nothing to act on. + dropped.append("ss_method") + + if dropped: + logger.debug( + "Direct BNGsim job: ignoring non-Simulator options %s", sorted(dropped) + ) + return init_kwargs, run_kwargs + + +def _run_rulemonkey_job(job, input_path, output_dir, sim_options, result_options): + """Execute a network-free RuleMonkey job from a BioNetGen XML artifact. + + BNG2.pl has no ``rm`` method, so ``method=>"rm"`` BNGL is rewritten to + ``nf`` before BNG2.pl runs (see :func:`_rewrite_rm_method_to_nf`); the + ``simulate_nf`` backend hook fires and the helper restores ``rm`` from + ``BIONETGEN_BNGSIM_BACKEND_METHOD``. This adapter drives BNGsim's + ``RuleMonkeySession`` instead of ``NfsimSession``. + """ + if not BNGSIM_HAS_RULEMONKEY: + raise BNGSimError("BNGsim RuleMonkey support is not available in this build.") + + seed = sim_options.pop("seed", None) + if seed is None: + seed = 42 + gml = sim_options.pop("gml", None) + param_overrides = sim_options.pop("param_overrides", None) + # setConcentration/addConcentration replay from multi-segment workflows. + # RuleMonkeySession (bngsim >= 0.9.2) exposes set_species_count / + # add_species / remove_species, so these are applied by the same + # engine-agnostic helper used for NFsim (no longer dropped). + conc_overrides = sim_options.pop("conc_overrides", None) + conc_deltas = sim_options.pop("conc_deltas", None) + # NFsim CLI-only flags have no RuleMonkeySession analogue. + if sim_options.pop("nf_params", None): + logger.debug("RuleMonkey job: 'nf_params' has no RuleMonkey analogue; ignored") + + with bngsim.RuleMonkeySession(input_path, molecule_limit=gml) as rm_session: + if param_overrides: + for pname, pval in param_overrides.items(): + try: + rm_session.set_param(pname, float(pval)) + except Exception as exc: + logger.debug( + "RuleMonkey: set_param(%s, %s) skipped: %s", pname, pval, exc + ) + rm_session.initialize(seed) + _apply_nfsim_concentration_changes( + rm_session, + conc_overrides=conc_overrides, + conc_deltas=conc_deltas, + ) + result = rm_session.simulate(job.t_span[0], job.t_span[1], job.n_points) + # get_final_state=>1 writeback: RuleMonkeySession.save_species (bngsim + # >= 0.9.2) writes a BNG .species file that BNG2.pl's readNFspecies + # reads to continue the trajectory across saveConcentrations / + # resetConcentrations segments — the same path the NFsim job uses. + if job.get_final_state: + _write_nf_final_state_species(rm_session, output_dir, job.output_root) + + _write_bngsim_results(result, output_dir, job.output_root, **result_options) + return _make_bng_result(output_dir, method=job.method) + + +def execute_bngsim_direct_job(job): + """Execute a normalized direct BNGsim job and write BNG-compatible files. + + The caller owns BNGL parsing/action semantics and supplies a fully + normalized artifact job. This adapter only loads the direct artifact, + dispatches to BNGsim, and writes the direct-run result files. + """ + if not BNGSIM_AVAILABLE: + raise BNGSimError( + f"BNGsim is required for format '{job.input_format}' but is not " + f"usable: {BNGSIM_UNAVAILABLE_REASON or 'unknown reason'}." + ) + + input_path = os.path.abspath(job.input_path) + output_dir = os.path.abspath(job.output_dir) + sim_options = dict(job.bngsim_options or {}) + result_options = dict(job.result_options or {}) + + if job.input_format == FORMAT_BNG_XML: + if job.method == "rm": + return _run_rulemonkey_job( + job, input_path, output_dir, sim_options, result_options + ) + if not _is_nf_method(job.method): + raise BNGSimError( + f"BioNetGen XML files are for network-free simulation, " + f"but method='{job.method}' was requested. " + f"Use method='nf' or provide a .net file for ODE/SSA/PSA." + ) + if not BNGSIM_HAS_NFSIM: + raise BNGSimError( + "BNGsim NFsim support is not available in this build. " + "Rebuild bngsim with -DBNGSIM_BUILD_NFSIM=ON." + ) + + seed = sim_options.pop("seed", None) + if seed is None: + seed = 42 + gml = sim_options.pop("gml", None) + nf_params = sim_options.pop("nf_params", None) + param_overrides = sim_options.pop("param_overrides", None) + conc_overrides = sim_options.pop("conc_overrides", None) + conc_deltas = sim_options.pop("conc_deltas", None) + + nf_kwargs = _nfsim_session_kwargs(nf_params) + with bngsim.NfsimSession(input_path, molecule_limit=gml, **nf_kwargs) as nfsim: + if param_overrides: + for pname, pval in param_overrides.items(): + try: + nfsim.set_param(pname, float(pval)) + except Exception as exc: + logger.debug( + "NFsim: set_param(%s, %s) skipped: %s", pname, pval, exc + ) + + nfsim.initialize(seed) + _apply_nfsim_concentration_changes( + nfsim, + conc_overrides=conc_overrides, + conc_deltas=conc_deltas, + ) + result = nfsim.simulate(job.t_span[0], job.t_span[1], job.n_points) + if job.get_final_state: + _write_nf_final_state_species(nfsim, output_dir, job.output_root) + + _write_bngsim_results( + result, + output_dir, + job.output_root, + **result_options, + ) + return _make_bng_result(output_dir, method=job.method) + + if _is_nf_method(job.method): + raise BNGSimError( + f"Network-free method '{job.method}' requires a BioNetGen XML file. " + "Provide a .xml file or use method='ode'/'ssa'/'psa' with a .net file." + ) + + model = _load_direct_bngsim_model(input_path, job.input_format) + init_kwargs, run_kwargs = _partition_simulator_options(sim_options) + sim = bngsim.Simulator(model, method=job.method, **init_kwargs) + result = sim.run(t_span=job.t_span, n_points=job.n_points, **run_kwargs) + + _write_bngsim_results(result, output_dir, job.output_root, **result_options) + return _make_bng_result(output_dir, method=job.method) + + +def run_nfsim( + xml_path, + output_dir, + t_span=None, + n_points=None, + seed=None, + gml=None, + model_name=None, + param_overrides=None, + conc_overrides=None, + conc_deltas=None, + print_functions=False, + nf_params=None, +): + """Run a network-free simulation using BNGsim's NfsimSession. + + Uses the public NfsimSession API with a BioNetGen XML file. + No .net file or Model object is needed. + + Parameters + ---------- + xml_path : str + Path to BioNetGen XML file. + output_dir : str + Directory for output files. + t_span : tuple of (float, float) or None + Time span (t_start, t_end). Defaults to (0, 100). + n_points : int or None + Number of output time points. Defaults to 101. + seed : int or None + Random seed. Defaults to 42. + gml : int or None + Global molecule limit. + model_name : str or None + Base name for output files. Derived from xml_path if None. + param_overrides : dict or None + Parameter name → value overrides to apply via + ``NfsimSession.set_param()`` before initialization. + Used to propagate ``setParameter`` calls to NFsim. + conc_overrides : dict or None + Species pattern → absolute molecule count overrides to apply + after initialization via ``NfsimSession.set_species_count()`` when + available, with a molecule-type fallback for older bngsim builds. + Used to propagate ``setConcentration``/``addConcentration`` + calls to NFsim. + conc_deltas : dict or None + Species pattern → relative molecule count deltas to apply after + initialization. Used for ``addConcentration`` replay when no + generated network model is available. + + Returns + ------- + BNGResult + """ + if not BNGSIM_AVAILABLE: + raise BNGSimError( + f"BNGsim is required for NFsim but is not usable: " + f"{BNGSIM_UNAVAILABLE_REASON or 'unknown reason'}." + ) + if not BNGSIM_HAS_NFSIM: + raise BNGSimError( + "BNGsim NFsim support is not available in this build. " + "Rebuild bngsim with -DBNGSIM_BUILD_NFSIM=ON." + ) + + if t_span is None: + t_span = (0.0, 100.0) + if n_points is None: + n_points = 101 + + xml_path = os.path.abspath(xml_path) + output_dir = os.path.abspath(output_dir) + if model_name is None: + model_name = os.path.splitext(os.path.basename(xml_path))[0] + + try: + job = BngsimDirectJob( + input_path=xml_path, + input_format=FORMAT_BNG_XML, + method="nf", + t_span=t_span, + n_points=n_points, + output_dir=output_dir, + output_root=model_name, + bngsim_options={ + "seed": seed, + "gml": gml, + "nf_params": nf_params, + "param_overrides": param_overrides, + "conc_overrides": conc_overrides, + "conc_deltas": conc_deltas, + }, + result_options={ + "print_functions": print_functions, + }, + ) + return execute_bngsim_direct_job(job) + + except Exception as e: + if isinstance(e, (BNGSimError, BNGFormatError)): + raise + raise BNGSimError(f"BNGsim NFsim simulation failed: {e}") from e + + +def run_with_bngsim( + input_path, + output_dir, + fmt=None, + method=None, + t_span=None, + n_points=None, + **sim_kwargs, +): + """Run a simulation using BNGsim. + + This handles .net, SBML .xml, BNG .xml, and .ant files directly. + For .bngl files, use run_bngl_with_bngsim() instead. + + Parameters + ---------- + input_path : str + Path to the input file. + output_dir : str + Directory for output files. + fmt : str + Detected format (one of FORMAT_* constants). + method : str or None + Simulation method ('ode', 'ssa', 'psa', 'nf', etc.). If None, + direct non-BNG-XML inputs default to ``'ode'`` while direct + BioNetGen XML inputs default to ``'nf'``. + t_span : tuple of (float, float) or None + Time span (t_start, t_end). If None, defaults to (0, 100). + n_points : int or None + Number of output time points. If None, defaults to 101. + **sim_kwargs + Additional keyword arguments passed to bngsim.Simulator + (e.g. poplevel for PSA). + + Returns + ------- + BNGResult + Result loaded from the written .gdat/.cdat files. + + Raises + ------ + BNGSimError + If BNGsim is not available or simulation fails. + """ + if not BNGSIM_AVAILABLE: + raise BNGSimError( + f"BNGsim is required for format '{fmt}' but is not usable: " + f"{BNGSIM_UNAVAILABLE_REASON or 'unknown reason'}." + ) + + input_path = os.path.abspath(input_path) + output_dir = os.path.abspath(output_dir) + model_name = os.path.splitext(os.path.basename(input_path))[0] + + # BNG XML → NFsim path (no Model needed) + if fmt == FORMAT_BNG_XML: + if method is None: + method = "nf" + if not _is_nf_method(method): + raise BNGSimError( + f"BioNetGen XML files are for network-free simulation, " + f"but method='{method}' was requested. " + f"Use method='nf' or provide a .net file for ODE/SSA/PSA." + ) + return run_nfsim( + input_path, + output_dir, + t_span=t_span, + n_points=n_points, + seed=sim_kwargs.pop("seed", None), + gml=sim_kwargs.pop("gml", None), + model_name=model_name, + ) + + # BNGL handling lives in run_bngl_with_bngsim(); for other direct + # inputs, preserve historical behavior by defaulting to ODE when no + # explicit method override was provided. + if method is None: + method = "ode" + + # Network-based methods: .net, SBML, Antimony + if _is_nf_method(method): + # NF with a .net file requires an xml_path kwarg + xml_path = sim_kwargs.pop("xml_path", None) + if xml_path: + return run_nfsim( + xml_path, + output_dir, + t_span=t_span, + n_points=n_points, + seed=sim_kwargs.pop("seed", None), + gml=sim_kwargs.pop("gml", None), + model_name=model_name, + ) + raise BNGSimError( + f"Network-free method '{method}' requires a BioNetGen XML file. " + "Provide a .xml file or use method='ode'/'ssa'/'psa' with a .net file." + ) + + if t_span is None: + t_span = (0.0, 100.0) + if n_points is None: + n_points = 101 + + try: + job = BngsimDirectJob( + input_path=input_path, + input_format=fmt, + method=method, + t_span=t_span, + n_points=n_points, + output_dir=output_dir, + output_root=model_name, + bngsim_options=sim_kwargs, + ) + return execute_bngsim_direct_job(job) + + except Exception as e: + if isinstance(e, (BNGSimError, BNGFormatError)): + raise + raise BNGSimError(f"BNGsim simulation failed: {e}") from e + + +# ─── BNGL routing helpers ────────────────────────────────────────── + +_SIMULATE_METHOD_MAP = { + "simulate": "ode", + "simulate_ode": "ode", + "simulate_ssa": "ssa", + "simulate_psa": "psa", + "simulate_nf": "nf", + "simulate_pla": "pla", +} + + +def _strip_quotes(s): + """Strip surrounding single or double quotes from a string.""" + if s and len(s) >= 2 and s[0] == s[-1] and s[0] in ('"', "'"): + return s[1:-1] + return s + + +def _nfsim_session_kwargs(nf_params): + """Translate parsed param=> flags into NfsimSession kwargs, dropping any + keys the installed BNGsim build doesn't accept so older wheels keep + working. + """ + if not nf_params: + return {} + try: + accepted = set(inspect.signature(bngsim.NfsimSession.__init__).parameters) + except (TypeError, ValueError): + accepted = set(nf_params) + return {k: v for k, v in nf_params.items() if k in accepted} + + +_DIRECT_BNGSIM_FORMATS = { + FORMAT_NET, + FORMAT_SBML, + FORMAT_BNG_XML, + FORMAT_ANTIMONY, +} + +_BNGSIM_NETWORK_METHODS = frozenset({"ode", "ssa", "psa", "rm"}) + +_BNGL_ROUTING_COMPLEX_ACTIONS = frozenset( + { + "parameter_scan", + "bifurcate", + "setParameter", + "setConcentration", + "addConcentration", + "saveConcentrations", + "resetConcentrations", + "saveParameters", + "resetParameters", + "writeXML", + "writeSBML", + "writeModel", + "writeNetwork", + "writeFile", + "writeMfile", + "writeCPYfile", + "writeMexfile", + "writeMDL", + "readFile", + "visualize", + "setModelName", + } +) + +_BNGL_ROUTING_PASSTHROUGH_ACTIONS = frozenset( + { + "generate_network", + "generate_hybrid_model", + } +) + + +def _method_supported_by_bngsim_for_routing(method, bngsim_has_nfsim=None): + """Return True if a normalized method can be handed to BNGsim.""" + if method in _BNGSIM_NETWORK_METHODS: + return True + if _is_nf_method(method): + if bngsim_has_nfsim is None: + bngsim_has_nfsim = BNGSIM_HAS_NFSIM + return bool(bngsim_has_nfsim) + return False + + +def _bngl_action_method_for_routing(action): + """Extract only the method hint needed for conservative routing. + + This deliberately avoids evaluating BNGL expressions. For legacy + ``ssa`` plus ``poplevel`` syntax, the classifier reports the effective + backend method as ``psa`` so BNGsim is never requested as + ``ssa`` with a PSA-only option. + """ + method = _SIMULATE_METHOD_MAP.get(action.type) + if method is None: + return None + args = action.args or {} + if action.type == "simulate" and "method" in args: + method = _strip_quotes(str(args["method"]).strip()).lower() + else: + method = method.lower() + if method == "ssa" and "poplevel" in args: + return "psa" + return method + + +def _bngl_workflow_method_for_routing(action): + """Return the backend method implied by a BNG2.pl-owned workflow action.""" + atype = getattr(action, "type", None) + args = getattr(action, "args", None) or {} + if atype not in {"parameter_scan", "bifurcate"}: + return None + method = args.get("method") + if method is None: + return None + method = _strip_quotes(str(method).strip()).lower() + if method == "ode": + return "ode" + if method == "ssa" and "poplevel" in args: + return "psa" + return method + + +def _bngl_has_protocol_block(bngl_path): + """Return True when a BNGL file declares a protocol block.""" + try: + with open(bngl_path, "r", errors="replace") as f: + for raw_line in f: + clean = raw_line.split("#", 1)[0].strip() + if re.match(r"begin\s+protocol\b", clean): + return True + except OSError as exc: + logger.debug("could not inspect BNGL protocol blocks (%s): %s", bngl_path, exc) + return False + + +# Routing re-asks for a BNGL's action list several times per +# ``bionetgen.run`` — the route classifier (twice: once from +# ``runner.run`` and once inside ``run_bngl_with_bngsim``), the +# in-process-scan detector, and the network-free-method probe. Each parse +# builds a throwaway ``bngmodel`` that shells out to BNG2.pl for XML +# generation; serial timing measured ~1.9 s of redundant pre-flight per +# run from this alone, making the BNGsim route slower than plain +# subprocess on every model. The action list is a pure function of the +# file, so memoize it on ``(abspath, mtime_ns, size)``: repeated routing +# queries within one run reuse the parse, while an edited file re-parses +# on its next run (matters for long-lived consumers like the VS Code +# extension). +_CACHE_MISS = object() +# key: (abspath, st_mtime_ns, st_size); value: parsed actions list (or None). +_ROUTING_ACTIONS_CACHE: dict[tuple[str, int, int], list | None] = {} +_ROUTING_ACTIONS_CACHE_MAX = 128 + + +def _clear_routing_actions_cache(): + """Drop the routing action-list cache. Test-only; production code + + never needs this because the cache key invalidates on file change. + """ + _ROUTING_ACTIONS_CACHE.clear() + + +def _load_bngl_actions_for_routing(bngl_path): + """Parse BNGL actions for routing only — memoized per file identity. + + Returns the parsed action items (treat the list as read-only — routing + callers never mutate it) or ``None`` when the file cannot be parsed. + Parse failures fall back to BNG2.pl rather than blocking the legacy + path, and the ``None`` is cached too so a failing parse is not retried + several times per run. + """ + try: + st = os.stat(bngl_path) + key = (os.path.abspath(bngl_path), st.st_mtime_ns, st.st_size) + except OSError: + # Can't establish a stable identity — parse without caching. + return _parse_bngl_actions_for_routing(bngl_path) + cached = _ROUTING_ACTIONS_CACHE.get(key, _CACHE_MISS) + if cached is not _CACHE_MISS: + return cached + actions = _parse_bngl_actions_for_routing(bngl_path) + if len(_ROUTING_ACTIONS_CACHE) >= _ROUTING_ACTIONS_CACHE_MAX: + # FIFO eviction — drop the oldest entry (dicts keep insertion order). + _ROUTING_ACTIONS_CACHE.pop(next(iter(_ROUTING_ACTIONS_CACHE)), None) + _ROUTING_ACTIONS_CACHE[key] = actions + return actions + + +def _parse_bngl_actions_for_routing(bngl_path): + """Parse a BNGL file's action items via a throwaway ``bngmodel``. + + Uncached — :func:`_load_bngl_actions_for_routing` is the memoized + entry point callers should use. + """ + try: + import bionetgen.modelapi.model as mdl + + model = mdl.bngmodel(bngl_path) + except Exception as exc: + logger.debug("could not parse BNGL for BNGsim routing (%s): %s", bngl_path, exc) + return None + try: + return list(model.actions.items) + except Exception as exc: + logger.debug( + "could not read BNGL actions for BNGsim routing (%s): %s", bngl_path, exc + ) + return None + + +def _classify_bngl_actions_for_bngsim( + actions_items, + method=None, + has_protocol=False, + bngsim_has_nfsim=None, +): + """Classify whether BNGL can use the BNG2.pl-owned BNGsim backend hook. + + This routing pass only reads action names and method hints. It does not + evaluate BNGL expressions or replay any action semantics in Python. + """ + if actions_items is None: + return BngsimRouteDecision( + ROUTE_SUBPROCESS, + "BNGL actions could not be inspected safely", + ) + + sim_actions = [] + has_backend_hook_workflow = bool(has_protocol) + workflow_methods = [] + for action in actions_items: + atype = getattr(action, "type", None) + args = getattr(action, "args", None) or {} + + if atype in _BNGL_ROUTING_PASSTHROUGH_ACTIONS: + continue + + if atype in _BNGL_ROUTING_COMPLEX_ACTIONS: + has_backend_hook_workflow = True + workflow_method = _bngl_workflow_method_for_routing(action) + if workflow_method is not None: + workflow_methods.append(workflow_method) + continue + + if atype in _SIMULATE_METHOD_MAP: + if ( + args.get("prefix") is not None + or args.get("suffix") is not None + or args.get("continue") is not None + ): + has_backend_hook_workflow = True + sim_actions.append(action) + continue + + if atype is not None: + return BngsimRouteDecision( + ROUTE_SUBPROCESS, + f"BNGL action '{atype}' is not a conservative BNGsim route", + ) + + if len(sim_actions) > 1: + has_backend_hook_workflow = True + + if any(workflow_method == "pla" for workflow_method in workflow_methods): + return BngsimRouteDecision( + ROUTE_SUBPROCESS, + "BNGL PLA is not supported by BNGsim", + method="pla", + ) + + if method is not None: + method_name = _strip_quotes(str(method).strip()).lower() + if sim_actions: + if any( + _bngl_action_method_for_routing(action) == "pla" + for action in sim_actions + ): + return BngsimRouteDecision( + ROUTE_SUBPROCESS, + "BNGL PLA is not supported by BNGsim", + method="pla", + ) + action_method = _bngl_action_method_for_routing(sim_actions[0]) + if action_method == "pla": + return BngsimRouteDecision( + ROUTE_SUBPROCESS, + "BNGL PLA is not supported by BNGsim", + method="pla", + ) + if method_name == "ssa" and "poplevel" in (sim_actions[0].args or {}): + method_name = "psa" + if method_name == "pla": + return BngsimRouteDecision( + ROUTE_SUBPROCESS, + "BNGL PLA is not supported by BNGsim", + method="pla", + ) + if _method_supported_by_bngsim_for_routing(method_name, bngsim_has_nfsim): + return BngsimRouteDecision( + ROUTE_BNGL_BNGSIM, + "BNGL method override is a BNGsim-supported simulation", + method=method_name, + ) + return BngsimRouteDecision( + ROUTE_SUBPROCESS, + f"BNGL method '{method_name}' is not supported by the BNGsim route", + method=method_name, + ) + + candidate_methods = [] + for action in sim_actions: + method_name = _bngl_action_method_for_routing(action) + if method_name == "pla": + return BngsimRouteDecision( + ROUTE_SUBPROCESS, + "BNGL PLA is not supported by BNGsim", + method="pla", + ) + candidate_methods.append(method_name) + + for method_name in workflow_methods: + if method_name == "pla": + return BngsimRouteDecision( + ROUTE_SUBPROCESS, + "BNGL PLA is not supported by BNGsim", + method="pla", + ) + if method_name != "protocol": + candidate_methods.append(method_name) + + if not candidate_methods and not has_backend_hook_workflow: + return BngsimRouteDecision( + ROUTE_SUBPROCESS, + "BNGL has no simulation action that needs BNGsim", + ) + + for method_name in candidate_methods: + if not _method_supported_by_bngsim_for_routing(method_name, bngsim_has_nfsim): + return BngsimRouteDecision( + ROUTE_SUBPROCESS, + f"BNGL method '{method_name}' is not supported by the BNGsim route", + method=method_name, + ) + + if has_backend_hook_workflow: + return BngsimRouteDecision( + ROUTE_BNGL_BNGSIM, + "BNGL workflow is owned by BNG2.pl with BNGsim backend jobs", + method=candidate_methods[0] if candidate_methods else None, + ) + + method_name = candidate_methods[0] + return BngsimRouteDecision( + ROUTE_BNGL_BNGSIM, + "BNGL action is an atomic BNGsim-supported simulation", + method=method_name, + ) + + +def classify_bngsim_route( + input_path, + fmt, + simulator="auto", + method=None, + bngsim_available=None, + bngsim_has_nfsim=None, + bngl_actions=None, + has_protocol=None, +): + """Choose the conservative Stage 1 route for a simulation request.""" + if bngsim_available is None: + bngsim_available = BNGSIM_AVAILABLE + if bngsim_has_nfsim is None: + bngsim_has_nfsim = BNGSIM_HAS_NFSIM + + if simulator not in {"auto", "bngsim", "subprocess"}: + raise ValueError( + f"Unknown simulator '{simulator}'. Valid options: 'auto', 'bngsim', 'subprocess'." + ) + + if simulator == "bngsim" and not bngsim_available: + return BngsimRouteDecision( + ROUTE_ERROR, + f"simulator='bngsim' was requested but BNGsim is not usable: " + f"{BNGSIM_UNAVAILABLE_REASON or 'unknown reason'}.", + ) + + if simulator == "subprocess": + if fmt in BNGSIM_REQUIRED_FORMATS: + return BngsimRouteDecision( + ROUTE_ERROR, + f"Format '{fmt}' requires BNGsim but subprocess was requested. " + "Install BNGsim and omit --no-bngsim for this format.", + ) + return BngsimRouteDecision( + ROUTE_SUBPROCESS, + "subprocess simulator was requested", + ) + + if not bngsim_available: + if fmt in BNGSIM_REQUIRED_FORMATS: + return BngsimRouteDecision( + ROUTE_ERROR, + f"Format '{fmt}' requires BNGsim but it is not available: " + f"{BNGSIM_UNAVAILABLE_REASON or 'unknown reason'}.", + ) + # auto-mode fallback: warn once if the cause is "too old version" + # (silent for the documented "not installed" path). + _warn_version_fallback_once() + return BngsimRouteDecision( + ROUTE_SUBPROCESS, + "BNGsim is unavailable; using legacy subprocess route", + ) + + if fmt in _DIRECT_BNGSIM_FORMATS: + if fmt == FORMAT_BNG_XML: + method_name = _strip_quotes(str(method).strip()).lower() if method else "nf" + if not _is_nf_method(method_name): + return BngsimRouteDecision( + ROUTE_ERROR, + f"BioNetGen XML files require method='nf', got '{method_name}'", + method=method_name, + ) + if not bngsim_has_nfsim: + if simulator == "bngsim": + return BngsimRouteDecision( + ROUTE_ERROR, + "BioNetGen XML direct routing requires BNGsim NFsim support", + method=method_name, + ) + return BngsimRouteDecision( + ROUTE_SUBPROCESS, + "BNGsim NFsim support is unavailable; using legacy subprocess route", + method=method_name, + ) + return BngsimRouteDecision( + ROUTE_DIRECT_BNGSIM, + "BioNetGen XML routes directly to BNGsim NFsim", + method=method_name, + ) + method_name = _strip_quotes(str(method).strip()).lower() if method else None + if method_name == "pla": + if fmt in FALLBACK_FORMATS: + return BngsimRouteDecision( + ROUTE_SUBPROCESS, + "PLA is not supported by the direct BNGsim route", + method=method_name, + ) + return BngsimRouteDecision( + ROUTE_ERROR, + f"Format '{fmt}' requires BNGsim but method='pla' is not supported", + method=method_name, + ) + return BngsimRouteDecision( + ROUTE_DIRECT_BNGSIM, + f"Format '{fmt}' routes directly to BNGsim", + method=method_name, + ) + + if fmt != FORMAT_BNGL: + return BngsimRouteDecision( + ROUTE_ERROR, + f"No simulation backend available for format '{fmt}'", + ) + + if has_protocol is None: + has_protocol = _bngl_has_protocol_block(input_path) + if bngl_actions is None: + bngl_actions = _load_bngl_actions_for_routing(input_path) + return _classify_bngl_actions_for_bngsim( + bngl_actions, + method=method, + has_protocol=has_protocol, + bngsim_has_nfsim=bngsim_has_nfsim, + ) + + +# ─── Codegen helpers ─────────────────────────────────────────────── + + +def _net_has_tfun(net_path): + """Return True if the .net file's function block uses tfun(...). + + Codegen+tfun is fragile in current BNGsim (the compiled .so calls a + callback that segfaults if the table function dispatch is not wired + up exactly right at runtime). The interpreted RHS handles tfun + correctly, so we route models containing tfun there until BNGsim's + codegen path stabilizes. + """ + try: + with open(net_path, "r", errors="replace") as f: + in_functions = False + for line in f: + s = line.strip() + if s.startswith("begin functions"): + in_functions = True + continue + if s.startswith("end functions"): + return False + if in_functions and "tfun(" in s.lower(): + return True + except OSError as exc: + logger.debug("could not scan .net for tfun (%s): %s", net_path, exc) + return False + + +def _try_prepare_codegen(net_path): + """Attempt to compile a code-generated RHS for ODE simulation. + + Returns the path to the compiled shared library, or "" if codegen + is unavailable, disabled via BIONETGEN_NO_CODEGEN env var, or skipped + because the model uses ``tfun(...)`` (BNGsim codegen+tfun is unstable; + the interpreted RHS handles tfun correctly). + """ + if os.environ.get("BIONETGEN_NO_CODEGEN"): + return "" + if _net_has_tfun(net_path): + logger.info( + "Codegen disabled for model with tfun() function; " + "using interpreted ODE RHS (codegen+tfun is currently unstable in BNGsim)" + ) + return "" + try: + from bngsim import prepare_codegen + + so_path = str(prepare_codegen(net_path)) + logger.debug("Codegen compiled: %s", so_path) + return so_path + except Exception as e: + logger.warning( + "Codegen compilation failed (%s); falling back to interpreted ODE RHS (slower)", + e, + ) + return "" + + +def _run_bngl_subprocess( + bngl_path, + output_dir, + bngpath, + suppress=False, + log_file=None, + timeout=None, + app=None, +): + """Run the original BNGL through the legacy BNG2.pl subprocess stack.""" + from bionetgen.core.tools.cli import BNGCLI + + cli = BNGCLI( + bngl_path, + output_dir, + bngpath, + suppress=suppress, + log_file=log_file, + timeout=timeout, + app=app, + ) + cli.run() + if cli.result is None: + raise BNGSimError("BNG2.pl failed.") + return cli.result + + +_RM_QUOTED_RE = re.compile(r'(method\s*=>\s*)(["\'])rm\2', re.IGNORECASE) +_RM_BARE_RE = re.compile(r"(method\s*=>\s*)rm(?=[\s,)}\]])", re.IGNORECASE) + + +def _bngl_network_free_methods(actions_items): + """Return the set of network-free methods (``nf``/``rm``) a BNGL uses.""" + methods = set() + for action in actions_items or []: + method = _bngl_action_method_for_routing(action) + if method == "rm": + methods.add("rm") + elif _is_nf_method(method): + methods.add("nf") + return methods + + +def _rewrite_rm_method_to_nf(bngl_path): + """Write a temp BNGL copy with ``method=>"rm"`` rewritten to ``"nf"``. + + BNG2.pl has no ``rm`` method, so rewriting to ``nf`` makes its + ``simulate_nf`` path (and the BNGsim backend hook) fire; the helper + restores ``rm`` from ``BIONETGEN_BNGSIM_BACKEND_METHOD``. The copy keeps + the original basename so BNG2.pl's output-file naming is unchanged. + + Returns ``(run_path, temp_dir)``; the caller removes ``temp_dir``. + """ + with open(bngl_path, "r", errors="replace") as f: + text = f.read() + rewritten = _RM_QUOTED_RE.sub(r"\1\2nf\2", text) + rewritten = _RM_BARE_RE.sub(r'\1"nf"', rewritten) + temp_dir = tempfile.mkdtemp(prefix="bngsim_rm_") + run_path = os.path.join(temp_dir, os.path.basename(bngl_path)) + with open(run_path, "w") as f: + f.write(rewritten) + return run_path, temp_dir + + +def run_bngl_with_bngsim_backend_hook( + bngl_path, + output_dir, + bngpath, + suppress=False, + log_file=None, + timeout=None, + app=None, + bngsim_backend_helper=None, + backend_method=None, +): + """Run BNGL through BNG2.pl with the BNGsim backend helper enabled. + + This Stage 4 path keeps BNG2.pl as the BNGL action driver. A hook-capable + BNG2.pl may delegate atomic simulation jobs to the helper advertised in + the environment by :class:`BNGCLI`. ``backend_method`` carries an + out-of-band method override (currently only ``rm``) to the helper. + """ + from bionetgen.core.tools.cli import BNGCLI + + cli = BNGCLI( + bngl_path, + output_dir, + bngpath, + suppress=suppress, + log_file=log_file, + timeout=timeout, + app=app, + bngsim_backend=True, + bngsim_backend_helper=bngsim_backend_helper, + bngsim_backend_method=backend_method, + ) + cli.run() + if cli.result is None: + raise BNGSimError("BNG2.pl failed.") + return cli.result + + +def run_bngl_with_bngsim( + bngl_path, + output_dir, + bngpath, + method=None, + t_span=None, + n_points=None, + suppress=False, + log_file=None, + timeout=None, + app=None, + **sim_kwargs, +): + """Run a BNGL file through the Stage 6 BNGsim route. + + BNG2.pl owns BNGL parsing, action semantics, workflows, protocol blocks, + scans, bifurcations, and output naming. Supported BNGL requests use the + BNG2.pl backend hook so only normalized direct BNGsim jobs cross into + Python. Unsupported BNGL requests keep the legacy subprocess route. + """ + if not BNGSIM_AVAILABLE: + raise BNGSimError( + f"BNGsim is not usable: {BNGSIM_UNAVAILABLE_REASON or 'unknown reason'}." + ) + + output_dir = os.path.abspath(output_dir) + os.makedirs(output_dir, exist_ok=True) + + decision = classify_bngsim_route( + bngl_path, + FORMAT_BNGL, + simulator="auto", + method=method, + bngsim_available=BNGSIM_AVAILABLE, + bngsim_has_nfsim=BNGSIM_HAS_NFSIM, + ) + if decision.route != ROUTE_BNGL_BNGSIM: + logger.info("%s; using subprocess route.", decision.reason) + return _run_bngl_subprocess( + bngl_path, + output_dir, + bngpath, + suppress=suppress, + log_file=log_file, + timeout=timeout, + app=app, + ) + + if t_span is not None or n_points is not None: + logger.info( + "BNGL time-span and point-count overrides are interpreted by BNG2.pl " + "on the backend-hook route." + ) + + # Fast path: a generate_network + single parameter_scan/bifurcate can + # be driven in-process by BNGsim (build the model once, vary the + # scanned parameter, re-integrate), avoiding the N process/socket/JSON + # boundary crossings the backend-hook route pays per scan point. This + # is a pure optimization — any decline or failure falls back to the + # backend hook. + from bionetgen.core.tools.bngsim_parameter_scan import ( + detect_inprocess_scan, + run_inprocess_scan, + ) + + try: + with open(bngl_path, "r", errors="replace") as _fh: + _bngl_text = _fh.read() + except OSError: + _bngl_text = None + scan_request = detect_inprocess_scan( + _load_bngl_actions_for_routing(bngl_path), + bngl_text=_bngl_text, + ) + if scan_request is not None: + model_name = os.path.splitext(os.path.basename(bngl_path))[0] + try: + return run_inprocess_scan( + bngl_path, + output_dir, + bngpath, + scan_request, + model_name, + suppress=suppress, + log_file=log_file, + timeout=timeout, + app=app, + ) + except Exception as exc: + logger.warning( + "%s in-process fast path failed (%s); falling back " + "to the BNG2.pl backend-hook route.", + scan_request.action, + exc, + ) + + # ``rm`` (RuleMonkey) has no BNG2.pl method. Rewrite ``method=>"rm"`` to + # ``"nf"`` on a temp copy so the simulate_nf hook fires, and tell the + # helper the real method out of band. + run_path = bngl_path + backend_method = None + rm_temp_dir = None + nf_methods = _bngl_network_free_methods(_load_bngl_actions_for_routing(bngl_path)) + if "rm" in nf_methods: + if not BNGSIM_HAS_RULEMONKEY: + logger.info( + 'BNGL uses method=>"rm" but BNGsim RuleMonkey is unavailable; ' + "using subprocess route." + ) + return _run_bngl_subprocess( + bngl_path, + output_dir, + bngpath, + suppress=suppress, + log_file=log_file, + timeout=timeout, + app=app, + ) + if "nf" in nf_methods: + logger.info( + "BNGL mixes nf and rm methods, which the single-method backend " + "override cannot disambiguate; using subprocess route." + ) + return _run_bngl_subprocess( + bngl_path, + output_dir, + bngpath, + suppress=suppress, + log_file=log_file, + timeout=timeout, + app=app, + ) + run_path, rm_temp_dir = _rewrite_rm_method_to_nf(bngl_path) + backend_method = "rm" + + logger.info("%s; using BNG2.pl-owned BNGsim backend hook.", decision.reason) + try: + return run_bngl_with_bngsim_backend_hook( + run_path, + output_dir, + bngpath, + suppress=suppress, + log_file=log_file, + timeout=timeout, + app=app, + bngsim_backend_helper=sim_kwargs.get("bngsim_backend_helper"), + backend_method=backend_method, + ) + finally: + if rm_temp_dir and os.path.isdir(rm_temp_dir): + shutil.rmtree(rm_temp_dir, ignore_errors=True) diff --git a/bionetgen/core/tools/bngsim_parameter_scan.py b/bionetgen/core/tools/bngsim_parameter_scan.py new file mode 100644 index 00000000..fe9ffa1f --- /dev/null +++ b/bionetgen/core/tools/bngsim_parameter_scan.py @@ -0,0 +1,832 @@ +"""In-process BNGsim driver for the ``parameter_scan`` and ``bifurcate`` actions. + +A fast-path optimization layered over the BNG2.pl backend-hook route. +For a ``generate_network`` followed by a single ``parameter_scan`` (or +``bifurcate``), BNG2.pl generates the network once and this driver then +owns the scan loop, driving BNGsim *in-process*: build the model once, +vary the scanned parameter, and re-integrate. That avoids the N process +/ socket / JSON boundary crossings the backend-hook route pays per scan +point. + +Correctness never depends on this path. :func:`detect_inprocess_scan` +returns ``None`` for anything it cannot handle conservatively, and +``run_bngl_with_bngsim`` falls back to the backend-hook route on a +``None`` decision or on any exception raised here. + +Scope: + +* ``parameter_scan`` — ``ode``/``cvode`` or ``ssa`` method, a + ``par_min``/``par_max``/``n_scan_pts`` value range, ``reset_conc`` + either ``1`` (reset each point) or ``0`` (carry the prior point's end + state). +* ``bifurcate`` — two ``parameter_scan`` passes (ascending then + descending, ``reset_conc`` forced to ``0``) merged per-observable into + ``_bifurcation_.scan`` files. + +``print_functions=>1`` is honored: BNGL functions (BNGsim "expressions") +are appended after the observable columns in both the per-point +``.gdat`` and the merged ``.scan``, exactly as BNG2.pl does — and using +the same ``Result.expressions`` the backend-hook route already trusts +for network jobs. + +The action sequence must be trivial: ``generate_network`` plus a single +trailing ``parameter_scan``/``bifurcate``, optionally preceded by +``setParameter``. ``nf``/``rm``/``pla`` methods, ``par_scan_vals``, +``sample_times``, ``steady_state`` and non-trivial sequences all fall +back. + +For ``ssa`` the driver rounds param-linked initial concentrations to the +nearest integer (matching BNG2.pl's ``run_network``, which simulates +integer molecule counts); see the Phase 2 spike findings. +""" + +from __future__ import annotations + +import logging +import math +import os +import re +import shutil +import tempfile +from dataclasses import dataclass + +logger = logging.getLogger("bionetgen.bngsim_bridge") + +# Scan/bifurcate arg keys this driver knows how to honor in-process. +_SCAN_SUPPORTED_KEYS = frozenset( + { + "parameter", + "par_min", + "par_max", + "n_scan_pts", + "log_scale", + "method", + "t_start", + "t_end", + "n_steps", + "suffix", + "prefix", + "reset_conc", + "atol", + "rtol", + "print_CDAT", + "print_functions", + "seed", + } +) +# Keys that are recognized but not handled in-process: their presence +# (when meaningful) forces a fallback rather than a silent wrong answer. +_SCAN_FALLBACK_KEYS = frozenset( + { + "par_scan_vals", + "sample_times", + "steady_state", + "continue", + } +) +# Keys that are recognized and safe to ignore (output is unaffected). +_SCAN_IGNORED_KEYS = frozenset( + { + "parallel", + "num_cores", + "verbose", + "get_final_state", + } +) + +# Action types allowed in a fast-path scan sequence. +_SCAN_ALLOWED_ACTIONS = frozenset( + { + "generate_network", + "parameter_scan", + "bifurcate", + "setParameter", + } +) +# The two workflow actions the driver can drive in-process. +_WORKFLOW_ACTIONS = frozenset({"parameter_scan", "bifurcate"}) + +_ODE_METHODS = frozenset({"ode", "cvode"}) +_SSA_METHODS = frozenset({"ssa"}) +_SUPPORTED_METHODS = _ODE_METHODS | _SSA_METHODS + +# A backslash that is NOT a clean end-of-line continuation. BNGL line +# continuation is ``\`` at end of line; a ``\`` followed by anything else +# (e.g. ``101,\log_scale=>1``) is malformed. PyBioNetGen's action parser +# silently absorbs such a stray ``\`` while BNG2.pl treats the token after +# it as a differently-named (unrecognized) key -- so the two parsers +# disagree on the action's meaning. The fast path defers to BNG2.pl by +# declining whenever the scan action text carries one. +_BAD_BACKSLASH_RE = re.compile(r"\\(?![ \t]*\r?\n)") + +_WORKFLOW_ACTION_RE = re.compile( + r"\b(?:parameter_scan|bifurcate)\s*\(\s*\{[^}]*\}\s*\)", + re.DOTALL | re.IGNORECASE, +) +_COMMENT_RE = re.compile(r"#.*") + + +def _scan_action_text_is_clean(bngl_text): + """True if the scan/bifurcate action text has no parser ambiguity. + + Returns ``False`` when the action carries a stray (non-line-continuation) + backslash, which PyBioNetGen and BNG2.pl parse differently — the fast + path then declines so BNG2.pl's interpretation governs. + """ + text = _COMMENT_RE.sub("", bngl_text) + match = _WORKFLOW_ACTION_RE.search(text) + if match is None: + return False + return _BAD_BACKSLASH_RE.search(match.group(0)) is None + + +@dataclass(frozen=True) +class ScanRequest: + """A parameter_scan/bifurcate reduced to the in-process driver's inputs.""" + + action: str # "parameter_scan" or "bifurcate" + parameter: str + par_min: float + par_max: float + n_scan_pts: int + log_scale: bool + method: str # "ode" or "ssa" (normalized; "cvode" -> "ode") + t_start: float + t_end: float + n_steps: int + suffix: str | None + prefix: str | None + reset_conc: bool + seed: int | None + atol: float | None + rtol: float | None + print_cdat: bool + print_functions: bool + + +def _unquote(value): + """Strip one layer of matching single/double quotes and whitespace.""" + s = str(value).strip() + if len(s) >= 2 and s[0] == s[-1] and s[0] in "\"'": + return s[1:-1] + return s + + +def _as_float(value): + """Parse a BNGL numeric action argument as a float (raises on failure).""" + return float(_unquote(value)) + + +def _as_truthy(value): + """Parse a BNGL boolean-ish action argument (``0``/``1``).""" + return _as_float(value) != 0.0 + + +def detect_inprocess_scan(actions_items, bngl_text=None): + """Classify whether a BNGL action list is an in-process-scan fast path. + + Returns a :class:`ScanRequest` when the action sequence is exactly a + ``generate_network`` plus a single trailing ``parameter_scan`` or + ``bifurcate`` (an optional ``setParameter`` preamble is allowed), the + method is ``ode``/``cvode``/``ssa``, and the action uses only options + this driver honors. Returns ``None`` for anything else — the caller + then uses the backend-hook route, which stays correct. + + When ``bngl_text`` (the raw BNGL source) is supplied, the action text + is also checked for parser ambiguity (a stray backslash); an ambiguous + action declines so BNG2.pl's reading governs. + """ + if not actions_items: + return None + + if bngl_text is not None and not _scan_action_text_is_clean(bngl_text): + logger.debug("scan fast path declined: ambiguous backslash in action") + return None + + types = [getattr(a, "type", None) for a in actions_items] + if any(t not in _SCAN_ALLOWED_ACTIONS for t in types): + return None + if sum(1 for t in types if t in _WORKFLOW_ACTIONS) != 1: + return None + if types[-1] not in _WORKFLOW_ACTIONS: + return None + if "generate_network" not in types: + return None + # The workflow action is types[-1] (checked above), so a present + # generate_network necessarily precedes it. + + scan_action = actions_items[-1] + action_type = types[-1] + args = {k: v for k, v in (getattr(scan_action, "args", None) or {}).items()} + + for key in args: + if ( + key not in _SCAN_SUPPORTED_KEYS + and key not in _SCAN_FALLBACK_KEYS + and key not in _SCAN_IGNORED_KEYS + ): + logger.debug("scan fast path declined: unknown option %r", key) + return None + + try: + # Options that, when present and meaningful, are out of scope. + if "par_scan_vals" in args: + return None + if "sample_times" in args: + return None + if "continue" in args and _as_truthy(args["continue"]): + return None + if "steady_state" in args and _as_truthy(args["steady_state"]): + return None + + method = _unquote(args.get("method", "ode")).lower() + if method not in _SUPPORTED_METHODS: + return None + method = "ode" if method in _ODE_METHODS else "ssa" + + # bifurcate always carries each point from the prior end state; + # for parameter_scan reset_conc defaults to 1 and may be 0. + if action_type == "bifurcate": + reset_conc = False + else: + reset_conc = True + if "reset_conc" in args: + reset_conc = _as_truthy(args["reset_conc"]) + + for required in ( + "parameter", + "par_min", + "par_max", + "n_scan_pts", + "t_end", + "n_steps", + ): + if required not in args: + return None + + parameter = _unquote(args["parameter"]) + if not parameter: + return None + par_min = _as_float(args["par_min"]) + par_max = _as_float(args["par_max"]) + n_scan_pts = int(_as_float(args["n_scan_pts"])) + log_scale = _as_truthy(args["log_scale"]) if "log_scale" in args else False + t_start = _as_float(args["t_start"]) if "t_start" in args else 0.0 + t_end = _as_float(args["t_end"]) + n_steps = int(_as_float(args["n_steps"])) + suffix = _unquote(args["suffix"]) if "suffix" in args else None + prefix = _unquote(args["prefix"]) if "prefix" in args else None + seed = int(_as_float(args["seed"])) if "seed" in args else None + atol = _as_float(args["atol"]) if "atol" in args else None + rtol = _as_float(args["rtol"]) if "rtol" in args else None + print_cdat = True + if "print_CDAT" in args: + print_cdat = _as_truthy(args["print_CDAT"]) + print_functions = False + if "print_functions" in args: + print_functions = _as_truthy(args["print_functions"]) + except (ValueError, TypeError) as exc: + logger.debug("scan fast path declined: unparseable option (%s)", exc) + return None + + # Range sanity — mirror BNG2.pl's parameter_scan checks. + if n_scan_pts < 1: + return None + if par_max != par_min and n_scan_pts <= 1: + return None + if log_scale and (par_min <= 0.0 or par_max <= 0.0): + return None + if n_steps < 1 or t_end <= t_start: + return None + + return ScanRequest( + action=action_type, + parameter=parameter, + par_min=par_min, + par_max=par_max, + n_scan_pts=n_scan_pts, + log_scale=log_scale, + method=method, + t_start=t_start, + t_end=t_end, + n_steps=n_steps, + suffix=suffix, + prefix=prefix, + reset_conc=reset_conc, + seed=seed, + atol=atol, + rtol=rtol, + print_cdat=print_cdat, + print_functions=print_functions, + ) + + +def scan_values(request): + """Compute the N scanned parameter values (ascending par_min→par_max). + + Matches BNG2.pl's ``parameter_scan``: linear spacing, or geometric + spacing (uniform in ``log``) when ``log_scale`` is set. Endpoints are + inclusive. + """ + n = request.n_scan_pts + lo, hi = request.par_min, request.par_max + if request.log_scale: + lo, hi = math.log(lo), math.log(hi) + if n == 1: + return [math.exp(lo) if request.log_scale else lo] + delta = (hi - lo) / (n - 1) + out = [] + for k in range(n): + v = lo + k * delta + out.append(math.exp(v) if request.log_scale else v) + return out + + +def _make_network_gen_bngl(bngl_path, model_name, work_dir): + """Write a temp BNGL whose actions stop at ``generate_network``. + + BNGL comments are stripped (harmless for network generation) and the + trailing ``parameter_scan``/``bifurcate`` action is removed, leaving + the model definition plus its ``generate_network`` (and any + ``setParameter`` preamble). The copy keeps the model basename so + BNG2.pl emits ``.net``. + """ + with open(bngl_path, "r", errors="replace") as fh: + text = fh.read() + text = _COMMENT_RE.sub("", text) + text, n_sub = _WORKFLOW_ACTION_RE.subn("", text) + if n_sub != 1: + raise ValueError( + f"expected exactly one parameter_scan/bifurcate action, found {n_sub}" + ) + gen_path = os.path.join(work_dir, f"{model_name}.bngl") + with open(gen_path, "w") as fh: + fh.write(text) + return gen_path + + +def _parse_net_initial_concentrations(net_path): + """Return the ``.net`` species block as ordered ``(pattern, init_token)``. + + Each ``.net`` species line is `` `` where ``init`` + is either a literal number or a single parameter name (BNG2.pl emits + synthesized ``_InitialConcN`` params for compound expressions). The + list is ordered to match BNGsim's ``species_names`` index for index. + """ + rows = [] + in_species = False + with open(net_path, "r", errors="replace") as fh: + for line in fh: + s = line.strip() + if not s: + continue + if s == "begin species": + in_species = True + continue + if s == "end species": + break + if in_species: + parts = s.split() + if len(parts) >= 3: + rows.append((parts[1], parts[2])) + return rows + + +def _is_literal_number(token): + try: + float(token) + return True + except ValueError: + return False + + +def _write_scan_file(scan_path, parameter, column_names, rows): + """Write a BNG2.pl-format ``.scan`` file. + + Mirrors BNGAction.pm's ``parameter_scan`` writer: a ``# + ...`` header followed by one ``%16.8e``-formatted row per scan point + (the parameter value, then each column at ``t_end``). ``column_names`` + is the observables, plus the BNGL functions when ``print_functions`` + is set — matching the per-point ``.gdat`` column set. + """ + with open(scan_path, "w") as fh: + header = "# " + f"{parameter:>14}" + for name in column_names: + header += " " + f"{name:>16}" + fh.write(header + "\n") + for par_value, cols in rows: + line = f"{par_value:16.8e}" + for x in cols: + line += " " + f"{x:16.8e}" + fh.write(line + "\n") + + +def _write_bifurcation_file(scan_path, parameter, col_name, fwd_col, bwd_col): + """Write one BNG2.pl-format ``_bifurcation_.scan`` file. + + Mirrors BNGAction.pm's ``bifurcate`` merge writer: a 3-column file + ``# _fwd _bwd`` whose rows pair the forward column + with the backward column reversed onto the same ascending parameter + axis (``backward[N-1-i]``). ``col_name`` is an observable, or a BNGL + function when ``print_functions`` is set. + """ + n = len(fwd_col) + with open(scan_path, "w") as fh: + fh.write( + "# " + + f"{parameter:>14}" + + " " + + f"{col_name + '_fwd':>16}" + + " " + + f"{col_name + '_bwd':>16}" + + "\n" + ) + for i in range(n): + par_value, fwd = fwd_col[i] + bwd = bwd_col[n - 1 - i][1] + fh.write(f"{par_value:16.8e} {fwd:16.8e} {bwd:16.8e}\n") + + +def _build_model_and_metadata(net_path, request): + """Load the BNGsim model from ``.net`` and gather scan metadata. + + Returns ``(model, species_names, observable_names, param_linked)``. + ``param_linked`` is the list of ``(species_name, init_param_token)`` + pairs whose initial concentration is a parameter expression — BNGsim + freezes init concentrations as literals at load time and ``reset()`` + does not re-derive them, so the driver must re-apply those per scan + point from the (updated) parameter value. + """ + import bngsim + + init_tokens = _parse_net_initial_concentrations(net_path) + model = bngsim.Model.from_net(net_path) + species_names = list(model.species_names) + if len(init_tokens) != len(species_names): + raise ValueError( + "species count mismatch between .net " + f"({len(init_tokens)}) and BNGsim model ({len(species_names)})" + ) + param_linked = [ + (species_names[i], token) + for i, (_pat, token) in enumerate(init_tokens) + if not _is_literal_number(token) + ] + if request.parameter not in model.param_names: + raise ValueError( + f"scanned parameter {request.parameter!r} is not a " + "model parameter in the generated network" + ) + return model, species_names, list(model.observable_names), param_linked + + +def _run_scan_loop( + model, + sim, + request, + values, + species_names, + param_linked, + work_dir, + basename, + write_results, +): + """Drive one in-process scan pass. + + For each scanned ``value``: set the parameter; either reset to initial + concentrations and re-derive param-linked initial concentrations + (``reset_conc=>1``), or carry the prior point's end state + (``reset_conc=>0`` / ``bifurcate``); integrate; write the per-point + ``.gdat``/``.cdat``; collect each output column at ``t_end``. + + Returns ``(scan_rows, expression_names)``. Each ``scan_rows`` entry is + ``(parameter_value, columns)`` where ``columns`` is the observables at + ``t_end``, followed by the BNGL functions when ``print_functions`` is + set. ``expression_names`` is the matching function-column name list + (empty unless ``print_functions`` and the model has functions). + + ``write_results`` is :func:`bngsim_bridge._write_bngsim_results`, + passed in to avoid a circular import. + """ + is_ssa = request.method == "ssa" + run_kwargs = {} + if request.method == "ode": + if request.atol is not None: + run_kwargs["atol"] = request.atol + if request.rtol is not None: + run_kwargs["rtol"] = request.rtol + if is_ssa and request.seed is not None: + run_kwargs["seed"] = request.seed + + os.makedirs(work_dir, exist_ok=True) + scan_rows = [] + expression_names = [] + for k, value in enumerate(values): + model.set_param(request.parameter, value) + if request.reset_conc: + model.reset() + for sp_name, token in param_linked: + conc = model.get_param(token) + if is_ssa: + # BNG2.pl's run_network simulates integer molecule + # counts; round so bngsim SSA gets the same input + # (a fractional count rides the whole trajectory — + # bngsim issue #43). + conc = math.floor(conc + 0.5) + model.set_concentration(sp_name, conc) + # else: species hold the prior scan point's end state, which + # sim.run already left in the model; the explicit carry-over + # below keeps that contract robust against future API changes. + result = sim.run( + t_span=(request.t_start, request.t_end), + n_points=request.n_steps + 1, + **run_kwargs, + ) + point_name = f"{basename}_{k + 1:05d}" + write_results( + result, + work_dir, + point_name, + print_functions=request.print_functions, + print_cdat=request.print_cdat, + ) + if not request.reset_conc: + for i, sp_name in enumerate(species_names): + model.set_concentration(sp_name, result.species[-1, i]) + # The .scan columns mirror the per-point .gdat: observables at + # t_end, then the BNGL functions when print_functions is set. The + # function set is fixed across scan points — capture it once. + if k == 0 and request.print_functions: + expression_names = list(result.expression_names) + columns = list(result.observables[-1, :]) + if expression_names: + columns += list(result.expressions[-1, :]) + scan_rows.append((value, columns)) + return scan_rows, expression_names + + +def _generate_network( + bngl_path, + model_name, + bngpath, + gen_dir, + run_subprocess, + suppress, + log_file, + timeout, + app, +): + """Run BNG2.pl once to emit ``.net``; return its path.""" + gen_bngl = _make_network_gen_bngl(bngl_path, model_name, gen_dir) + run_subprocess( + gen_bngl, + gen_dir, + bngpath, + suppress=suppress, + log_file=log_file, + timeout=timeout, + app=app, + ) + net_path = os.path.join(gen_dir, f"{model_name}.net") + if not os.path.isfile(net_path): + raise FileNotFoundError(f"network generation produced no {model_name}.net") + return net_path + + +def _new_simulator(model, request): + """Construct a BNGsim ``Simulator`` for the request's method.""" + import bngsim + + if request.method == "ssa": + return bngsim.Simulator(model, method="ssa") + return bngsim.Simulator(model) + + +def run_inprocess_scan( + bngl_path, + output_dir, + bngpath, + request, + model_name, + suppress=False, + log_file=None, + timeout=None, + app=None, +): + """Dispatch an in-process ``parameter_scan`` or ``bifurcate`` run. + + Raises on any failure so the caller can fall back to the backend hook. + """ + if request.action == "bifurcate": + return run_bifurcate_with_bngsim( + bngl_path, + output_dir, + bngpath, + request, + model_name, + suppress=suppress, + log_file=log_file, + timeout=timeout, + app=app, + ) + return run_parameter_scan_with_bngsim( + bngl_path, + output_dir, + bngpath, + request, + model_name, + suppress=suppress, + log_file=log_file, + timeout=timeout, + app=app, + ) + + +def run_parameter_scan_with_bngsim( + bngl_path, + output_dir, + bngpath, + request, + model_name, + suppress=False, + log_file=None, + timeout=None, + app=None, +): + """Run a ``parameter_scan`` in-process through BNGsim. + + BNG2.pl generates the reaction network once; this driver then loops + over the scan values in-process — building the BNGsim model once and + re-integrating per point — and writes BNG2.pl-compatible output + (``.scan`` plus per-point ``.gdat``/``.cdat`` files under + ``/``). + + Raises on any failure so the caller can fall back to the backend hook. + """ + from bionetgen.core.tools.bngsim_bridge import ( + _run_bngl_subprocess, + _write_bngsim_results, + _make_bng_result, + ) + + output_dir = os.path.abspath(output_dir) + os.makedirs(output_dir, exist_ok=True) + + basename = request.prefix or model_name + basename += "_" + (request.suffix or request.parameter) + work_dir = os.path.join(output_dir, basename) + scan_path = os.path.join(output_dir, basename + ".scan") + + gen_dir = tempfile.mkdtemp(prefix="bngsim_scan_gen_") + try: + net_path = _generate_network( + bngl_path, + model_name, + bngpath, + gen_dir, + _run_bngl_subprocess, + suppress, + log_file, + timeout, + app, + ) + model, species_names, observable_names, param_linked = ( + _build_model_and_metadata(net_path, request) + ) + sim = _new_simulator(model, request) + + values = scan_values(request) + scan_rows, expression_names = _run_scan_loop( + model, + sim, + request, + values, + species_names, + param_linked, + work_dir, + basename, + _write_bngsim_results, + ) + column_names = observable_names + expression_names + _write_scan_file(scan_path, request.parameter, column_names, scan_rows) + logger.info( + "parameter_scan fast path: %d points for %r via in-process BNGsim (%s)", + len(values), + request.parameter, + request.method, + ) + return _make_bng_result(output_dir, request.method) + finally: + shutil.rmtree(gen_dir, ignore_errors=True) + + +def run_bifurcate_with_bngsim( + bngl_path, + output_dir, + bngpath, + request, + model_name, + suppress=False, + log_file=None, + timeout=None, + app=None, +): + """Run a ``bifurcate`` in-process through BNGsim. + + A ``bifurcate`` is two ``parameter_scan`` passes (``reset_conc`` forced + to ``0``): a forward pass ``par_min→par_max`` then a backward pass + ``par_max→par_min``, both carrying state across every point *and* + across the forward→backward boundary (one continuous ``Simulator``). + The two passes are merged per observable into + ``_bifurcation_.scan`` files; per-point ``.gdat``/``.cdat`` + artifacts are written under ``_forward/`` and + ``_backward/`` (BNG2.pl keeps those, deleting only the + intermediate ``.scan`` files — which this driver simply never writes). + + Raises on any failure so the caller can fall back to the backend hook. + """ + from bionetgen.core.tools.bngsim_bridge import ( + _run_bngl_subprocess, + _write_bngsim_results, + _make_bng_result, + ) + + output_dir = os.path.abspath(output_dir) + os.makedirs(output_dir, exist_ok=True) + + prefix = request.prefix or model_name + if request.suffix: + prefix += "_" + request.suffix + fwd_base = prefix + "_forward" + bwd_base = prefix + "_backward" + + gen_dir = tempfile.mkdtemp(prefix="bngsim_bifurcate_gen_") + try: + net_path = _generate_network( + bngl_path, + model_name, + bngpath, + gen_dir, + _run_bngl_subprocess, + suppress, + log_file, + timeout, + app, + ) + model, species_names, observable_names, param_linked = ( + _build_model_and_metadata(net_path, request) + ) + # One Simulator drives both passes so concentrations carry across + # every point and across the forward→backward boundary. + sim = _new_simulator(model, request) + + fwd_values = scan_values(request) + bwd_values = list(reversed(fwd_values)) + + fwd_rows, expression_names = _run_scan_loop( + model, + sim, + request, + fwd_values, + species_names, + param_linked, + os.path.join(output_dir, fwd_base), + fwd_base, + _write_bngsim_results, + ) + bwd_rows, _ = _run_scan_loop( + model, + sim, + request, + bwd_values, + species_names, + param_linked, + os.path.join(output_dir, bwd_base), + bwd_base, + _write_bngsim_results, + ) + + # Merge: one file per output column (observables, then BNGL + # functions when print_functions is set), backward column reversed + # onto the ascending parameter axis (BNGAction.pm sub bifurcate). + column_names = observable_names + expression_names + for j, col_name in enumerate(column_names): + fwd_col = [(par, cols[j]) for par, cols in fwd_rows] + bwd_col = [(par, cols[j]) for par, cols in bwd_rows] + out_path = os.path.join(output_dir, f"{prefix}_bifurcation_{col_name}.scan") + _write_bifurcation_file( + out_path, + request.parameter, + col_name, + fwd_col, + bwd_col, + ) + logger.info( + "bifurcate fast path: %d points for %r via in-process BNGsim (%s)", + len(fwd_values), + request.parameter, + request.method, + ) + return _make_bng_result(output_dir, request.method) + finally: + shutil.rmtree(gen_dir, ignore_errors=True) diff --git a/bionetgen/core/tools/cli.py b/bionetgen/core/tools/cli.py index c900106b..fe9f1d8b 100644 --- a/bionetgen/core/tools/cli.py +++ b/bionetgen/core/tools/cli.py @@ -1,4 +1,6 @@ import os, subprocess +import sys + from bionetgen.core.exc import BNGRunError from bionetgen.core.utils.logging import BNGLogger @@ -34,6 +36,9 @@ def __init__( log_file=None, timeout=None, app=None, + bngsim_backend=False, + bngsim_backend_helper=None, + bngsim_backend_method=None, ): self.app = app self.logger = BNGLogger(app=self.app) @@ -84,6 +89,176 @@ def __init__( self.stderr = "STDOUT" self.suppress = suppress self.timeout = timeout + self.bngsim_backend = bngsim_backend + self.bngsim_backend_helper = bngsim_backend_helper + self.bngsim_backend_method = bngsim_backend_method + self._old_bngsim_backend_env = {} + # Persistent BNGsim backend helper (see _start_persistent_helper). + self._helper_proc = None + self._helper_socket = None + self._helper_dir = None + + def _install_bngsim_backend_env(self): + """Expose the BNGsim backend helper contract to hook-capable BNG2.pl.""" + keys = ( + "BIONETGEN_BNGSIM_BACKEND", + "BIONETGEN_BNGSIM_BACKEND_HELPER", + "BIONETGEN_BNGSIM_BACKEND_HELPER_PYTHON", + "BIONETGEN_BNGSIM_BACKEND_HELPER_MODULE", + "BIONETGEN_BNGSIM_BACKEND_HELPER_SOCKET", + "BIONETGEN_BNGSIM_BACKEND_METHOD", + ) + self._old_bngsim_backend_env = {key: os.environ.get(key) for key in keys} + if not self.bngsim_backend and self.bngsim_backend_helper is None: + return + + helper = self.bngsim_backend_helper + os.environ["BIONETGEN_BNGSIM_BACKEND"] = "1" + if helper is not None: + os.environ["BIONETGEN_BNGSIM_BACKEND_HELPER"] = helper + else: + os.environ.pop("BIONETGEN_BNGSIM_BACKEND_HELPER", None) + os.environ["BIONETGEN_BNGSIM_BACKEND_HELPER_PYTHON"] = sys.executable + os.environ["BIONETGEN_BNGSIM_BACKEND_HELPER_MODULE"] = ( + "bionetgen.core.tools.bngsim_backend_helper" + ) + # ``rm`` BNGL is rewritten to ``nf`` so BNG2.pl's simulate_nf hook + # fires; this carries the real method to the helper out of band. + if self.bngsim_backend_method: + os.environ["BIONETGEN_BNGSIM_BACKEND_METHOD"] = self.bngsim_backend_method + else: + os.environ.pop("BIONETGEN_BNGSIM_BACKEND_METHOD", None) + + def _restore_bngsim_backend_env(self): + for key, value in self._old_bngsim_backend_env.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + def _start_persistent_helper(self): + """Spawn one long-lived BNGsim backend helper for this BNG2.pl run. + + The backend hook otherwise spawns a fresh Python process per atomic + job, paying interpreter startup + ``import bngsim`` (~0.5 s) every + time -- which dominates a parameter_scan (one job per scan point). + A persistent helper amortizes that to once. The hook talks to it + over a Unix-domain socket advertised in + ``BIONETGEN_BNGSIM_BACKEND_HELPER_SOCKET``; if anything here fails + the env var is left unset and the hook falls back to its per-job + ``system()`` spawn, so this is a pure optimization. + + Only used for the default module helper (``bngsim_backend_helper`` + is None) on POSIX -- a caller-supplied helper or Windows keeps the + per-job path. + """ + if not self.bngsim_backend or self.bngsim_backend_helper is not None: + return + if os.name != "posix" or self.bng_exec is None: + return + + import subprocess + import tempfile + + try: + base = "/tmp" if os.path.isdir("/tmp") else None + # Unix socket paths are length-limited (~104 chars); keep short. + self._helper_dir = tempfile.mkdtemp(prefix="bngsh-", dir=base) + socket_path = os.path.join(self._helper_dir, "h.sock") + if len(socket_path) >= 100: + raise OSError(f"socket path too long: {socket_path}") + proc = subprocess.Popen( + [ + sys.executable, + "-m", + "bionetgen.core.tools.bngsim_backend_helper", + "--serve", + "--socket", + socket_path, + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + ready = self._await_helper_ready(proc, timeout=120) + if not ready: + raise RuntimeError("persistent helper did not become ready") + except Exception as exc: + self.logger.warning( + f"Could not start persistent BNGsim helper ({exc}); " + "falling back to a per-job helper process.", + loc=f"{__file__} : BNGCLI._start_persistent_helper()", + ) + self._stop_persistent_helper() + return + + self._helper_proc = proc + self._helper_socket = socket_path + os.environ["BIONETGEN_BNGSIM_BACKEND_HELPER_SOCKET"] = socket_path + + @staticmethod + def _await_helper_ready(proc, timeout): + """Block until the serve process prints its READY token, or fail.""" + import select + + from bionetgen.core.tools.bngsim_backend_helper import SERVE_READY_TOKEN + + import time as _time + + deadline = _time.monotonic() + timeout + while _time.monotonic() < deadline: + if proc.poll() is not None: + return False + rlist, _, _ = select.select([proc.stdout], [], [], 0.5) + if not rlist: + continue + line = proc.stdout.readline() + if line == "": + return False + if line.strip() == SERVE_READY_TOKEN: + return True + return False + + def _stop_persistent_helper(self): + """Shut down the persistent helper and remove its socket. Idempotent.""" + import shutil + + proc = self._helper_proc + if proc is not None and proc.poll() is None: + try: + import socket as _socket + + with _socket.socket(_socket.AF_UNIX, _socket.SOCK_STREAM) as sock: + sock.settimeout(5) + sock.connect(self._helper_socket) + from bionetgen.core.tools.bngsim_backend_helper import ( + SHUTDOWN_REQUEST, + ) + + sock.sendall((SHUTDOWN_REQUEST + "\n").encode("utf-8")) + except Exception: + pass + try: + proc.wait(timeout=10) + except Exception: + proc.terminate() + try: + proc.wait(timeout=5) + except Exception: + proc.kill() + if proc is not None: + for stream in (proc.stdout, proc.stderr): + try: + if stream is not None: + stream.close() + except Exception: + pass + if self._helper_dir is not None: + shutil.rmtree(self._helper_dir, ignore_errors=True) + os.environ.pop("BIONETGEN_BNGSIM_BACKEND_HELPER_SOCKET", None) + self._helper_proc = None + self._helper_socket = None + self._helper_dir = None def _set_output(self, output): self.logger.debug( @@ -96,6 +271,14 @@ def _set_output(self, output): def run(self): self.logger.debug("Running", loc=f"{__file__} : BNGCLI.run()") + self._install_bngsim_backend_env() + try: + self._start_persistent_helper() + self._run_impl() + finally: + self._stop_persistent_helper() + + def _run_impl(self): # If BNG2.pl is not available, fall back to an empty result so that # library users can still instantiate and inspect models without a # full BioNetGen install. @@ -110,6 +293,7 @@ def run(self): else: if "BNGPATH" in os.environ: del os.environ["BNGPATH"] + self._restore_bngsim_backend_env() return from bionetgen.core.utils.utils import run_command @@ -192,6 +376,7 @@ def run(self): else: if "BNGPATH" in os.environ: del os.environ["BNGPATH"] + self._restore_bngsim_backend_env() else: self.logger.error("Command failed to run", loc=f"{__file__} : BNGCLI.run()") self.result = None @@ -201,14 +386,11 @@ def run(self): else: if "BNGPATH" in os.environ: del os.environ["BNGPATH"] - if hasattr(out, "stdout"): - if out.stdout is not None: - stdout_str = out.stdout.decode("utf-8") - else: - stdout_str = None - if hasattr(out, "stderr"): - if out.stderr is not None: - stderr_str = out.stderr.decode("utf-8") - else: - stderr_str = None + self._restore_bngsim_backend_env() + stdout_str = None + stderr_str = None + if getattr(out, "stdout", None) is not None: + stdout_str = out.stdout.decode("utf-8") + if getattr(out, "stderr", None) is not None: + stderr_str = out.stderr.decode("utf-8") raise BNGRunError(command, stdout=stdout_str, stderr=stderr_str) diff --git a/bionetgen/core/tools/info.py b/bionetgen/core/tools/info.py index dd071058..9f446a2d 100644 --- a/bionetgen/core/tools/info.py +++ b/bionetgen/core/tools/info.py @@ -96,6 +96,22 @@ def gatherInfo(self): text = roadrunner.getVersionStr() self.info["libRoadRunner version"] = text[0:5] + # BNGsim availability + from bionetgen.core.tools.bngsim_bridge import ( + BNGSIM_AVAILABLE, + BNGSIM_HAS_NFSIM, + BNGSIM_VERSION, + ) + + self.info[ + "\nThe following are optional high-performance simulation backends" + ] = "" + if BNGSIM_AVAILABLE: + self.info["BNGsim version"] = f"{BNGSIM_VERSION} (installed)" + self.info["BNGsim NFsim support"] = "yes" if BNGSIM_HAS_NFSIM else "no" + else: + self.info["BNGsim"] = "not installed (pip install bngsim)" + return self.info def messageGeneration(self): diff --git a/bionetgen/main.py b/bionetgen/main.py index 51c58f07..ec007089 100644 --- a/bionetgen/main.py +++ b/bionetgen/main.py @@ -89,12 +89,12 @@ class Meta: # This overwrites the default behavior and runs the CLI object from core/main # which in turn just calls BNG2.pl with the supplied options @cement.ex( - help="Runs a given model using BNG2.pl", + help="Runs a given model using BNG2.pl or BNGsim", arguments=[ ( ["-i", "--input"], { - "help": "Path to BNGL file (required)", + "help": "Path to input file (.bngl, .net, .xml, or .ant)", "default": None, "type": str, "required": True, @@ -127,19 +127,133 @@ class Meta: "dest": "traceback_depth", }, ), + ( + ["--format"], + { + "help": "Explicit input format: bngl, net, sbml, bng-xml, antimony. " + "If omitted, auto-detected from file extension and content.", + "default": None, + "type": str, + "dest": "format", + }, + ), + ( + ["--no-bngsim"], + { + "help": "Force subprocess path (BNG2.pl/run_network/NFsim) " + "even when BNGsim is available.", + "default": False, + "action": "store_true", + "dest": "no_bngsim", + }, + ), + ( + ["--method"], + { + "help": "Optional simulation method override: ode, ssa, psa, nf. " + "For BNGL inputs, omit this to preserve the model's " + "simulate_* actions when routing through BNGsim. " + "For direct BioNetGen XML inputs, only nf is supported.", + "default": None, + "type": str, + "dest": "method", + }, + ), + ( + ["--timeout"], + { + "help": "Optional timeout in seconds for the BNG2.pl preprocessing " + "or subprocess execution step. Applies to BNGL inputs, including " + "the hybrid BNGsim path.", + "default": None, + "type": int, + "dest": "timeout", + }, + ), ], ) def run(self): """ This is the main run functionality of the CLI. - It uses a convenience function defined in core/main - to run BNG2.pl using subprocess, given the set of arguments - in the command line and the configuraions set by the defaults - as well as the end-user. + Supports BNGL, .net, SBML (.xml), BioNetGen XML, and Antimony (.ant) + files. When BNGsim is installed, it is used for fast in-process + simulation. Use --no-bngsim to force the traditional BNG2.pl path. + For BNGL inputs, ``--method`` is an explicit override; if omitted, + the model's declared ``simulate_*`` actions are preserved. For + direct BioNetGen XML inputs, ``--method`` defaults to ``nf`` and + network-based methods are rejected. """ - test_perl(app=self.app) - runCLI(self.app) + from bionetgen.core.tools.bngsim_bridge import ( + FORMAT_BNG_XML, + FORMAT_BNGL, + FORMAT_NET, + ROUTE_BNGL_BNGSIM, + ROUTE_DIRECT_BNGSIM, + ROUTE_ERROR, + ROUTE_SUBPROCESS, + classify_bngsim_route, + detect_input_format, + run_bngl_with_bngsim, + run_with_bngsim, + ) + + args = self.app.pargs + sys.tracebacklimit = args.traceback_depth + + # Detect format + fmt = detect_input_format(args.input, explicit_format=args.format) + + route = classify_bngsim_route( + args.input, + fmt, + simulator="subprocess" if args.no_bngsim else "auto", + method=args.method, + ) + if route.route == ROUTE_ERROR: + from bionetgen.core.exc import BNGSimError + + raise BNGSimError(route.reason) + + if route.route == ROUTE_BNGL_BNGSIM and fmt == FORMAT_BNGL: + # BNGL path: BNG2.pl owns workflow semantics and delegates + # normalized simulation jobs through the BNGsim backend hook. + test_perl(app=self.app) + config_bngpath = self.app.config.get("bionetgen", "bngpath") + run_bngl_with_bngsim( + args.input, + args.output, + config_bngpath, + method=args.method, + suppress=False, + log_file=args.log_file, + timeout=args.timeout, + app=self.app, + ) + elif route.route == ROUTE_DIRECT_BNGSIM: + # Direct BNGsim path for non-BNGL formats + run_with_bngsim( + args.input, + args.output, + fmt=fmt, + method=args.method, + ) + elif route.route == ROUTE_SUBPROCESS and fmt == FORMAT_BNGL: + # Traditional subprocess path for BNGL + test_perl(app=self.app) + runCLI(self.app) + elif route.route == ROUTE_SUBPROCESS and fmt in (FORMAT_NET, FORMAT_BNG_XML): + # Subprocess fallback for .net and BNG XML + # These can be run via BNG2.pl or run_network directly + test_perl(app=self.app) + runCLI(self.app) + else: + from bionetgen.core.exc import BNGSimError + + raise BNGSimError( + f"No simulation backend available for format '{fmt}'. " + "Install BNGsim with: pip install bngsim" + ) @cement.ex( help="Starts a Jupyter notebook to help run and analyze \ diff --git a/bionetgen/modelapi/runner.py b/bionetgen/modelapi/runner.py index 60511bd5..ea4a0ce5 100644 --- a/bionetgen/modelapi/runner.py +++ b/bionetgen/modelapi/runner.py @@ -1,51 +1,137 @@ import os from tempfile import TemporaryDirectory -from bionetgen.main import BioNetGen -from bionetgen.core.tools import BNGCLI -# This allows access to the CLIs config setup -app = BioNetGen() -app.setup() -conf = app.config["bionetgen"] +from bionetgen.core.tools import BNGCLI +from bionetgen.main import get_conf -def run(inp, out=None, suppress=False, timeout=None): +def run( + inp, + out=None, + suppress=False, + timeout=None, + simulator="auto", + format=None, + method=None, + t_span=None, + n_points=None, +): """ - Convenience function to run BNG2.pl as a library + Convenience function to run a simulation as a library. + + Supports BNGL, .net, SBML (.xml), BioNetGen XML, and Antimony (.ant) + files. When BNGsim is available in the environment, it is used for + in-process simulation. Otherwise, falls back to BNG2.pl subprocess. Usage: run(path_to_input_file, output_folder) Arguments --------- - path_to_input_file : str - this has to point to a BNGL file - output_folder : str - (optional) this points to a folder to put the results - into. If it doesn't exist, it will be created. + inp : str + Path to an input file (.bngl, .net, .xml, or .ant). + out : str, optional + Output folder for results. If None, a temp directory is used. + suppress : bool + Suppress output from BNG2.pl. + timeout : int, optional + Timeout in seconds for BNG2.pl subprocess. + simulator : str + Simulation backend: 'auto' (use BNGsim if available, else subprocess), + 'bngsim' (require BNGsim, error if missing), or 'subprocess' (force + BNG2.pl/run_network path). + format : str, optional + Explicit input format hint: 'bngl', 'net', 'sbml', 'bng-xml', 'antimony'. + If None, auto-detected from file extension and content. + method : str, optional + Optional simulation method override: 'ode', 'ssa', 'psa', 'nf', etc. + For BNGL inputs, if omitted, the model's existing ``simulate_*`` + actions are preserved when routing through BNGsim. For direct + BioNetGen XML inputs, if omitted, the method defaults to ``nf`` + and network-based methods are rejected. + t_span : tuple of (float, float), optional + Time span (t_start, t_end). If None, defaults to (0, 100). + n_points : int, optional + Number of output time points. If None, defaults to 101. + + Returns + ------- + BNGResult + Simulation results. """ - # if out is None we make a temp directory + from bionetgen.core.tools.bngsim_bridge import ( + FORMAT_BNGL, + ROUTE_BNGL_BNGSIM, + ROUTE_DIRECT_BNGSIM, + ROUTE_ERROR, + ROUTE_SUBPROCESS, + classify_bngsim_route, + detect_input_format, + run_bngl_with_bngsim, + run_with_bngsim, + ) + + # Detect input format + fmt = detect_input_format(inp, explicit_format=format) + + route = classify_bngsim_route( + inp, + fmt, + simulator=simulator, + method=method, + ) + if route.route == ROUTE_ERROR: + from bionetgen.core.exc import BNGSimError + + raise BNGSimError(route.reason) + cur_dir = os.getcwd() + + def _run_with_output_dir(output_dir): + try: + if route.route == ROUTE_BNGL_BNGSIM and fmt == FORMAT_BNGL: + conf = get_conf() + result = run_bngl_with_bngsim( + inp, + output_dir, + conf["bngpath"], + method=method, + t_span=t_span, + n_points=n_points, + suppress=suppress, + log_file=None, + timeout=timeout, + ) + elif route.route == ROUTE_DIRECT_BNGSIM: + result = run_with_bngsim( + inp, + output_dir, + fmt=fmt, + method=method, + t_span=t_span, + n_points=n_points, + ) + elif route.route == ROUTE_SUBPROCESS: + conf = get_conf() + # Subprocess path — only for .bngl, .net, .bng-xml + cli = BNGCLI( + inp, + output_dir, + conf["bngpath"], + suppress=suppress, + timeout=timeout, + ) + cli.run() + result = cli.result + else: + from bionetgen.core.exc import BNGSimError + + raise BNGSimError(route.reason) + return result + finally: + os.chdir(cur_dir) + if out is None: with TemporaryDirectory() as out: - # instantiate a CLI object with the info - cli = BNGCLI(inp, out, conf["bngpath"], suppress=suppress, timeout=timeout) - try: - cli.run() - os.chdir(cur_dir) - except Exception as e: - os.chdir(cur_dir) - # TODO: Better error reporting - print("Couldn't run the simulation, see error") - raise e + return _run_with_output_dir(out) else: - # instantiate a CLI object with the info - cli = BNGCLI(inp, out, conf["bngpath"], suppress=suppress, timeout=timeout) - try: - cli.run() - os.chdir(cur_dir) - except Exception as e: - os.chdir(cur_dir) - # TODO: Better error reporting - print("Couldn't run the simulation, see error") - raise e - return cli.result + return _run_with_output_dir(out) diff --git a/tests/conftest.py b/tests/conftest.py index 729a47bc..13574168 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,9 +2,27 @@ PyTest Fixtures. """ +import importlib.util +import os + import pytest from cement import fs +from bionetgen.core.defaults import BNGDefaults +from bionetgen.core.utils.utils import find_BNG_path + +_ATOMIZER_MODULES = ("libsbml", "lxml", "networkx") + + +def _module_available(module_name): + return importlib.util.find_spec(module_name) is not None + + +def _has_bng2(): + search_path = os.environ.get("BNGPATH") or BNGDefaults().bng_path + _, bngexec = find_BNG_path(search_path) + return bngexec is not None + @pytest.fixture(scope="function") def tmp(request): @@ -15,3 +33,19 @@ def tmp(request): t = fs.Tmp() yield t t.remove() + + +@pytest.fixture(scope="session") +def require_atomizer(): + missing = [name for name in _ATOMIZER_MODULES if not _module_available(name)] + if missing: + pytest.skip( + "requires optional atomizer dependencies; install with " + f"`bionetgen[atomizer]` (missing: {', '.join(missing)})" + ) + + +@pytest.fixture(scope="session") +def require_bng2(): + if not _has_bng2(): + pytest.skip("requires BNG2.pl via a vendored bundle, `BNGPATH`, or `PATH`") diff --git a/tests/test_bngsim_backend_hook.py b/tests/test_bngsim_backend_hook.py new file mode 100644 index 00000000..cb5791f9 --- /dev/null +++ b/tests/test_bngsim_backend_hook.py @@ -0,0 +1,836 @@ +import json +import os +import shutil +import stat +import textwrap + +import pytest + +from bionetgen.core.exc import BNGRunError +from bionetgen.core.defaults import BNGDefaults +from bionetgen.core.tools.bngsim_backend_helper import ( + direct_job_from_backend_job, + load_backend_job, +) +from bionetgen.core.tools.bngsim_bridge import ( + BNGSIM_AVAILABLE, + run_bngl_with_bngsim_backend_hook, +) +from bionetgen.core.utils.utils import find_BNG_path + + +def _write_executable(path, text): + path.write_text(textwrap.dedent(text), encoding="utf-8") + path.chmod(path.stat().st_mode | stat.S_IXUSR) + return path + + +def _write_capture_helper(tmp_path): + capture_path = tmp_path / "helper-jobs.jsonl" + helper = _write_executable( + tmp_path / "fake_backend_helper.py", + f"""\ + #!/usr/bin/env python3 + import json + import os + import sys + + job_path = sys.argv[1] + with open(job_path, "r", encoding="utf-8") as handle: + job = json.load(handle) + with open({str(capture_path)!r}, "a", encoding="utf-8") as handle: + handle.write(json.dumps(job, sort_keys=True) + "\\n") + prefix = job.get("output_prefix") + if prefix: + with open(prefix + ".gdat", "w", encoding="utf-8") as handle: + handle.write("# time A\\n0 1\\n1 2\\n") + with open(prefix + ".cdat", "w", encoding="utf-8") as handle: + handle.write("# time S\\n0 10\\n1 9\\n") + if os.environ.get("FAKE_BACKEND_FAIL"): + print(json.dumps({{"success": False, "error": "forced helper failure"}})) + sys.exit(5) + print(json.dumps({{"success": True}})) + """, + ) + return helper, capture_path + + +def _patch_real_bng_action(bng_root): + action_path = bng_root / "Perl2" / "BNGAction.pm" + source = action_path.read_text(encoding="utf-8") + if "use JSON::PP;" not in source: + source = source.replace("use warnings;\n", "use warnings;\nuse JSON::PP;\n", 1) + + hook = r""" + # PyBioNetGen/BNGsim backend hook. BNG2.pl has already normalized the + # model state, artifact path, method, options, and output prefix. + if ($ENV{'BIONETGEN_BNGSIM_BACKEND'} && $method =~ /^(cvode|ssa|psa)$/) + { + my @helper_command; + if ($ENV{'BIONETGEN_BNGSIM_BACKEND_HELPER'}) + { + # The test helper is a Python script. Run it through the interpreter + # (BNGCLI always advertises BIONETGEN_BNGSIM_BACKEND_HELPER_PYTHON) + # rather than exec'ing the path directly: a chmod'd shebang script + # is not executable on Windows, where system($script, ...) fails. + if ($ENV{'BIONETGEN_BNGSIM_BACKEND_HELPER_PYTHON'}) + { + @helper_command = ( + $ENV{'BIONETGEN_BNGSIM_BACKEND_HELPER_PYTHON'}, + $ENV{'BIONETGEN_BNGSIM_BACKEND_HELPER'}, + ); + } + else + { + @helper_command = ($ENV{'BIONETGEN_BNGSIM_BACKEND_HELPER'}); + } + } + elsif ($ENV{'BIONETGEN_BNGSIM_BACKEND_HELPER_PYTHON'} && $ENV{'BIONETGEN_BNGSIM_BACKEND_HELPER_MODULE'}) + { + @helper_command = ( + $ENV{'BIONETGEN_BNGSIM_BACKEND_HELPER_PYTHON'}, + '-m', + $ENV{'BIONETGEN_BNGSIM_BACKEND_HELPER_MODULE'}, + ); + } + else + { + return "BIONETGEN_BNGSIM_BACKEND_HELPER is not set."; + } + + if ($method eq 'pla') + { + return ''; + } + + my $backend_method = ($method eq 'cvode') ? 'ode' : $method; + my %sim_options = ( + t_start => $t_start, + t_end => $t_end, + n_steps => $n_steps, + seed => $seed, + print_CDAT => $print_cdat, + print_functions => $print_fdat, + ); + if ($method eq 'cvode') + { + $sim_options{atol} = $atol; + $sim_options{rtol} = $rtol; + $sim_options{sparse} = $sparse; + $sim_options{steady_state} = $steady_state; + } + if ($method eq 'psa') + { + $sim_options{poplevel} = $params->{poplevel}; + } + + my %job = ( + artifact_path => File::Spec->rel2abs($netfile), + artifact_format => 'net', + method => $backend_method, + simulation_options => \%sim_options, + output_prefix => File::Spec->rel2abs($prefix), + backend_flags => { + interpreted_by => 'BNG2.pl', + command => \@command, + }, + ); + my $job_file = "$prefix.bngsim-job.json"; + open(my $job_fh, '>', $job_file) + or return "Could not open BNGsim backend job file $job_file: $!"; + print $job_fh encode_json(\%job); + close($job_fh); + + print "Running BNGsim backend helper: @helper_command $job_file\n"; + my $rc = system(@helper_command, $job_file); + if ($rc != 0) + { + return sprintf("BNGsim backend helper failed with status %s.", $rc); + } + + if ( $model->RxnList and -e "$prefix.cdat" ) + { + print "Updating species concentrations from $prefix.cdat\n"; + open CDAT, '<', "$prefix.cdat"; + my $last_line = ''; + while (my $line = ) { $last_line = $line; } + close CDAT; + my $conc; + ($t_end, @$conc) = split ' ', $last_line; + my $species = $model->SpeciesList->Array; + unless ( $#$conc == $#$species ) + { + return sprintf "Number of species in model (%d) and CDAT file (%d) differ", scalar @$species, scalar @$conc; + } + $model->Concentrations( $conc ); + $model->UpdateNet(1); + } + elsif ( $model->RxnList ) + { + return "CDAT file is missing"; + } + $model->Time($t_end); + return ''; + } + +""" + needle = " # Determine index of last rule iteration\n" + if "PyBioNetGen/BNGsim backend hook" not in source: + source = source.replace(needle, hook + needle, 1) + action_path.write_text(source, encoding="utf-8") + + +def _resolve_test_bng_root(): + override = os.environ.get("PYBNG_TEST_BNG_ROOT") + if override: + bng_dir, _ = find_BNG_path(os.path.expanduser(override)) + return bng_dir + + search_path = os.environ.get("BNGPATH") or BNGDefaults().bng_path + bng_dir, _ = find_BNG_path(search_path) + return bng_dir + + +@pytest.fixture +def real_bng_backend_runtime(tmp_path): + source_root = _resolve_test_bng_root() + if source_root is None: + pytest.skip( + "requires BNG2.pl via PYBNG_TEST_BNG_ROOT, BNGPATH, configured bngpath, or PATH" + ) + + bng_dir = tmp_path / "BioNetGen-hooked" + shutil.copytree(source_root, bng_dir) + _patch_real_bng_action(bng_dir) + helper, capture_path = _write_capture_helper(tmp_path) + + return { + "bng_dir": str(bng_dir), + "helper": str(helper), + "capture": capture_path, + } + + +def _write_model(tmp_path, marker, action_text, protocol_text=""): + bngl_path = tmp_path / f"{marker.lower()}.bngl" + bngl_path.write_text( + textwrap.dedent(f"""\ + begin model + begin parameters + k 1 + A0 10 + end parameters + begin molecule types + A() + end molecule types + begin seed species + A() A0 + end seed species + begin observables + Molecules A A() + end observables + begin reaction rules + decay: A() -> 0 k + end reaction rules + end model + + {protocol_text} + + {action_text} + """), + encoding="utf-8", + ) + return bngl_path + + +def _run_real_hook(tmp_path, runtime, marker, action_text, protocol_text=""): + bngl_path = _write_model(tmp_path, marker, action_text, protocol_text=protocol_text) + out_dir = tmp_path / f"out-{marker.lower()}" + result = run_bngl_with_bngsim_backend_hook( + str(bngl_path), + str(out_dir), + runtime["bng_dir"], + suppress=True, + bngsim_backend_helper=runtime["helper"], + ) + return out_dir, result + + +def _captured_jobs(path): + if not path.exists(): + return [] + return [ + json.loads(line) + for line in path.read_text(encoding="utf-8").splitlines() + if line.strip() + ] + + +def test_helper_contract_normalizes_single_ode_job(): + job = load_backend_job( + { + "artifact_path": "/tmp/model.net", + "artifact_format": "net", + "method": "cvode", + "simulation_options": { + "t_start": "0", + "t_end": "5", + "n_steps": "5", + "atol": "1e-8", + "print_functions": "1", + }, + "output_prefix": "/tmp/out/model", + "backend_flags": {"command": ["run_network"]}, + } + ) + direct = direct_job_from_backend_job(job) + + assert direct.method == "ode" + assert direct.t_span == (0.0, 5.0) + assert direct.n_points == 6 + # output_prefix is abspath'd then split, so derive the expected dir the + # same way rather than hardcoding a POSIX path (Windows: D:\tmp\out). + assert direct.output_dir == os.path.dirname(os.path.abspath("/tmp/out/model")) + assert direct.output_root == "model" + assert direct.bngsim_options["atol"] == "1e-8" + assert direct.result_options["print_functions"] is True + + +def test_helper_translates_nf_param_flags(): + """param=>"-ogf -gml N" maps to print_functions + gml (jobs_ground). + + BNG2.pl passes the raw NFsim flag string to the binary; the BNGsim helper + has no raw-flag passthrough but translates the common flags to named + options so global-function .gdat columns are emitted like BNG2.pl's. + """ + job = load_backend_job( + { + "artifact_path": "/tmp/model.xml", + "artifact_format": "bng-xml", + "method": "nf", + "simulation_options": { + "t_start": "0", + "t_end": "50", + "n_steps": "50", + "param": "-ogf -gml 500000", + }, + "output_prefix": "/tmp/out/model", + } + ) + direct = direct_job_from_backend_job(job) + assert direct.result_options["print_functions"] is True + assert direct.bngsim_options["gml"] == 500000 + # the raw param string is consumed, not passed through verbatim + assert "param" not in direct.bngsim_options + + +def test_helper_param_ogf_overrides_default_print_functions(): + # BNG2.pl's hook always sends print_functions (defaulting to 0), so it is + # indistinguishable from a keyword; param=>"-ogf" is an explicit request + # for function output and must win over that auto-sent default. This is the + # jobs_ground scenario: the payload carries print_functions=0 AND param. + job = load_backend_job( + { + "artifact_path": "/tmp/model.xml", + "artifact_format": "bng-xml", + "method": "nf", + "simulation_options": { + "t_start": "0", + "t_end": "50", + "n_steps": "50", + "param": "-ogf", + "print_functions": "0", + }, + "output_prefix": "/tmp/out/model", + } + ) + direct = direct_job_from_backend_job(job) + assert direct.result_options["print_functions"] is True + + # A param string with no -ogf leaves an explicit print_functions=>1 intact. + job2 = load_backend_job( + { + "artifact_path": "/tmp/model.xml", + "artifact_format": "bng-xml", + "method": "nf", + "simulation_options": { + "t_start": "0", + "t_end": "50", + "n_steps": "50", + "param": "-gml 1000", + "print_functions": "1", + }, + "output_prefix": "/tmp/out/model", + } + ) + direct2 = direct_job_from_backend_job(job2) + assert direct2.result_options["print_functions"] is True + assert direct2.bngsim_options["gml"] == 1000 + + +def test_helper_method_override_restores_rm_from_env(monkeypatch): + """BIONETGEN_BNGSIM_BACKEND_METHOD=rm flips an nf job back to rm. + + BNG2.pl has no ``rm`` method, so ``rm`` BNGL is rewritten to ``nf`` and + the ``simulate_nf`` hook always sends ``method='nf'``; the env var + carries the real method to the helper. + """ + monkeypatch.setenv("BIONETGEN_BNGSIM_BACKEND_METHOD", "rm") + job = load_backend_job( + { + "artifact_path": "/tmp/model.xml", + "artifact_format": "bng-xml", + "method": "nf", + "simulation_options": {"t_start": "0", "t_end": "5", "n_steps": "5"}, + "output_prefix": "/tmp/out/model", + } + ) + assert job.method == "rm" + assert direct_job_from_backend_job(job).method == "rm" + + +def test_helper_method_override_leaves_network_jobs_alone(monkeypatch): + """The rm override only applies to network-free jobs; ode stays ode.""" + monkeypatch.setenv("BIONETGEN_BNGSIM_BACKEND_METHOD", "rm") + job = load_backend_job( + { + "artifact_path": "/tmp/model.net", + "artifact_format": "net", + "method": "cvode", + "simulation_options": {"t_start": "0", "t_end": "5", "n_steps": "5"}, + "output_prefix": "/tmp/out/model", + } + ) + assert job.method == "ode" + + +def test_helper_without_method_override_keeps_nf(monkeypatch): + monkeypatch.delenv("BIONETGEN_BNGSIM_BACKEND_METHOD", raising=False) + job = load_backend_job( + { + "artifact_path": "/tmp/model.xml", + "artifact_format": "bng-xml", + "method": "nf", + "simulation_options": {"t_start": "0", "t_end": "5", "n_steps": "5"}, + "output_prefix": "/tmp/out/model", + } + ) + assert job.method == "nf" + + +def test_helper_rebases_network_free_segment_time_to_zero(): + """nf segments with t_start>0 are rebased to start at 0. + + BNG2.pl's ``sub simulate_nf`` reports NFsim timepoints as elapsed time + since ``t_start`` (the output axis starts at 0). A multi-segment nf + protocol whose later segment carries ``t_start=>10800`` must run over + ``(0, duration)`` so the BNGsim output matches BNG2.pl -- both the + time column and any ``time()``-dependent rate laws. + """ + job = load_backend_job( + { + "artifact_path": "/tmp/model.xml", + "artifact_format": "bng-xml", + "method": "nf", + "simulation_options": { + "t_start": "10800", + "t_end": "12600", + "n_steps": "300", + }, + "output_prefix": "/tmp/out/model", + } + ) + direct = direct_job_from_backend_job(job) + + assert direct.method == "nf" + assert direct.t_span == (0.0, 1800.0) + assert direct.n_points == 301 + + +def test_helper_rebases_rm_segment_time_to_zero(monkeypatch): + """rm is network-free too: BNG2.pl runs rm-rewritten-to-nf BNGL + through ``simulate_nf``, so rm segments rebase the same way as nf.""" + monkeypatch.setenv("BIONETGEN_BNGSIM_BACKEND_METHOD", "rm") + job = load_backend_job( + { + "artifact_path": "/tmp/model.xml", + "artifact_format": "bng-xml", + "method": "nf", + "simulation_options": { + "t_start": "100", + "t_end": "250", + "n_steps": "150", + }, + "output_prefix": "/tmp/out/model", + } + ) + direct = direct_job_from_backend_job(job) + + assert direct.method == "rm" + assert direct.t_span == (0.0, 150.0) + + +def test_helper_keeps_t_start_for_network_methods(): + """ode/ssa/psa honor t_start (BNG2.pl passes ``run_network -i``).""" + job = load_backend_job( + { + "artifact_path": "/tmp/model.net", + "artifact_format": "net", + "method": "ssa", + "simulation_options": { + "t_start": "10800", + "t_end": "12600", + "n_steps": "300", + }, + "output_prefix": "/tmp/out/model", + } + ) + direct = direct_job_from_backend_job(job) + + assert direct.method == "ssa" + assert direct.t_span == (10800.0, 12600.0) + + +def test_helper_network_free_t_start_zero_is_noop(): + """A network-free segment already at t_start=0 is left unchanged.""" + job = load_backend_job( + { + "artifact_path": "/tmp/model.xml", + "artifact_format": "bng-xml", + "method": "nf", + "simulation_options": {"t_start": "0", "t_end": "1800", "n_steps": "300"}, + "output_prefix": "/tmp/out/model", + } + ) + direct = direct_job_from_backend_job(job) + + assert direct.t_span == (0.0, 1800.0) + + +def test_helper_forwards_sample_times_prepending_t_start(): + """An explicit sample_times array is forwarded to BNGsim with the + initial t_start row prepended: BNG2.pl emits the t_start state row + then the sample times, whereas BNGsim's sample_times yields exactly + the listed times.""" + job = load_backend_job( + { + "artifact_path": "/tmp/model.net", + "artifact_format": "net", + "method": "ode", + "simulation_options": { + "t_start": "0", + "t_end": "36000", + "sample_times": [1800, 3600, 7200], + }, + "output_prefix": "/tmp/out/model", + } + ) + direct = direct_job_from_backend_job(job) + + assert direct.bngsim_options["sample_times"] == [0.0, 1800.0, 3600.0, 7200.0] + assert direct.n_points == 4 + + +def test_helper_print_cdat_zero_sets_result_option(): + """print_CDAT=>0 maps to result_options print_cdat=False so the + .cdat is reduced to its initial and final rows (BNG2.pl behavior).""" + job = load_backend_job( + { + "artifact_path": "/tmp/model.net", + "artifact_format": "net", + "method": "ssa", + "simulation_options": { + "t_start": "0", + "t_end": "30", + "n_steps": "30", + "print_CDAT": 0, + }, + "output_prefix": "/tmp/out/model", + } + ) + direct = direct_job_from_backend_job(job) + + assert direct.result_options.get("print_cdat") is False + + +def test_helper_print_cdat_one_keeps_full_cdat(): + """print_CDAT=>1 (BNG2.pl's default) leaves print_cdat True.""" + job = load_backend_job( + { + "artifact_path": "/tmp/model.net", + "artifact_format": "net", + "method": "ode", + "simulation_options": { + "t_start": "0", + "t_end": "100", + "n_steps": "100", + "print_CDAT": 1, + }, + "output_prefix": "/tmp/out/model", + } + ) + direct = direct_job_from_backend_job(job) + + assert direct.result_options.get("print_cdat") is True + + +def test_rewrite_rm_method_to_nf_preserves_basename(tmp_path): + from bionetgen.core.tools.bngsim_bridge import _rewrite_rm_method_to_nf + + src = tmp_path / "m.bngl" + src.write_text( + 'begin actions\nsimulate({method=>"rm",t_start=>0,t_end=>5,n_steps=>5})\nend actions\n' + ) + run_path, temp_dir = _rewrite_rm_method_to_nf(str(src)) + try: + text = open(run_path).read() + assert 'method=>"nf"' in text + assert '"rm"' not in text + # basename is preserved so BNG2.pl output-file naming is unchanged + assert os.path.basename(run_path) == "m.bngl" + # the original file is untouched + assert '"rm"' in src.read_text() + finally: + shutil.rmtree(temp_dir, ignore_errors=True) + + +def test_bngl_network_free_methods_detects_rm(): + from types import SimpleNamespace + + from bionetgen.core.tools.bngsim_bridge import _bngl_network_free_methods + + rm = SimpleNamespace(type="simulate", args={"method": '"rm"'}) + nf = SimpleNamespace(type="simulate_nf", args={}) + ode = SimpleNamespace(type="simulate_ode", args={}) + assert _bngl_network_free_methods([rm]) == {"rm"} + assert _bngl_network_free_methods([rm, nf]) == {"rm", "nf"} + assert _bngl_network_free_methods([ode]) == set() + + +def test_fake_helper_receives_single_normalized_ode_job( + tmp_path, real_bng_backend_runtime +): + _run_real_hook( + tmp_path, + real_bng_backend_runtime, + "ODE", + "generate_network({overwrite=>1})\nsimulate_ode({t_end=>1,n_steps=>2})", + ) + + jobs = _captured_jobs(real_bng_backend_runtime["capture"]) + assert len(jobs) == 1 + assert jobs[0]["method"] == "ode" + assert jobs[0]["artifact_format"] == "net" + assert jobs[0]["simulation_options"]["n_steps"] == 2 + assert jobs[0]["simulation_options"]["atol"] == 1e-08 + assert jobs[0]["output_prefix"].endswith("ode") + + +def test_fake_helper_receives_psa_as_psa(tmp_path, real_bng_backend_runtime): + _run_real_hook( + tmp_path, + real_bng_backend_runtime, + "PSA", + "generate_network({overwrite=>1})\nsimulate_ssa({t_end=>1,n_steps=>2,poplevel=>100})", + ) + + jobs = _captured_jobs(real_bng_backend_runtime["capture"]) + assert len(jobs) == 1 + assert jobs[0]["method"] == "psa" + assert jobs[0]["simulation_options"]["poplevel"] == 100 + + +def test_pla_action_does_not_call_helper(tmp_path, real_bng_backend_runtime): + _run_real_hook( + tmp_path, + real_bng_backend_runtime, + "PLA", + "generate_network({overwrite=>1})\nsimulate_pla({t_end=>1,n_steps=>1})", + ) + + assert _captured_jobs(real_bng_backend_runtime["capture"]) == [] + + +@pytest.mark.parametrize( + ("marker", "action_text", "expected_count"), + [ + ( + "SET_PARAMETER", + 'setParameter("k",2)\n' + "generate_network({overwrite=>1})\n" + "simulate_ode({t_end=>1,n_steps=>2})", + 1, + ), + ( + "SET_CONCENTRATION", + 'setConcentration("A()",20)\n' + "generate_network({overwrite=>1})\n" + "simulate_ode({t_end=>1,n_steps=>2})", + 1, + ), + ( + "SAVE_RESET", + "saveParameters()\n" + 'setParameter("k",2)\n' + "resetParameters()\n" + "generate_network({overwrite=>1})\n" + "simulate_ode({t_end=>1,n_steps=>2})", + 1, + ), + ( + "CONTINUE", + "generate_network({overwrite=>1})\n" + 'simulate_ode({suffix=>"setup",t_end=>1,n_steps=>2})\n' + 'simulate_ode({suffix=>"setup",t_start=>1,t_end=>2,n_steps=>2,continue=>1})', + 2, + ), + ], +) +def test_stateful_bngl_workflows_are_owned_by_bng2pl_before_backend_jobs( + tmp_path, + real_bng_backend_runtime, + marker, + action_text, + expected_count, +): + _run_real_hook(tmp_path, real_bng_backend_runtime, marker, action_text) + + jobs = _captured_jobs(real_bng_backend_runtime["capture"]) + assert len(jobs) == expected_count + assert all(job["backend_flags"]["interpreted_by"] == "BNG2.pl" for job in jobs) + assert all(job["artifact_format"] == "net" for job in jobs) + assert all(job["method"] == "ode" for job in jobs) + + +def test_bngl_numeric_expressions_are_normalized_by_bng2pl_for_backend_job( + tmp_path, + real_bng_backend_runtime, +): + _run_real_hook( + tmp_path, + real_bng_backend_runtime, + "EXPR", + "generate_network({overwrite=>1})\nsimulate_ode({t_end=>1+1,n_steps=>2})", + ) + + jobs = _captured_jobs(real_bng_backend_runtime["capture"]) + assert len(jobs) == 1 + assert jobs[0]["simulation_options"]["t_end"] == 2 + assert jobs[0]["simulation_options"]["n_steps"] == 2 + + +@pytest.mark.parametrize( + ("marker", "expected_count", "final_artifact", "action_text", "protocol_text"), + [ + ( + "SCAN", + 2, + "scan_k.scan", + 'parameter_scan({method=>"ode",parameter=>"k",par_min=>0.1,par_max=>0.2,n_scan_pts=>2,t_end=>1,n_steps=>2})', + "", + ), + ( + "PROTOCOL", + 2, + "protocol_k.scan", + 'parameter_scan({method=>"protocol",parameter=>"k",par_min=>0.1,par_max=>0.2,n_scan_pts=>2})', + 'begin protocol\nsimulate({method=>"ode",t_end=>1,n_steps=>2})\nend protocol', + ), + ( + "BIFURCATE", + 4, + "bifurcate_bifurcation_A.scan", + 'bifurcate({method=>"ode",parameter=>"k",par_min=>0.1,par_max=>0.2,n_scan_pts=>2,t_end=>1,n_steps=>2})', + "", + ), + ], +) +def test_bng2_owned_workflows_delegate_atomic_jobs_and_write_final_artifacts( + tmp_path, + real_bng_backend_runtime, + marker, + expected_count, + final_artifact, + action_text, + protocol_text, +): + out_dir, _ = _run_real_hook( + tmp_path, + real_bng_backend_runtime, + marker, + action_text, + protocol_text=protocol_text, + ) + + jobs = _captured_jobs(real_bng_backend_runtime["capture"]) + assert len(jobs) == expected_count + assert all(job["method"] in {"ode", "ssa"} for job in jobs) + assert (out_dir / final_artifact).is_file() + + +@pytest.mark.skipif( + not BNGSIM_AVAILABLE, + reason="network-free scan points only dispatch to the backend hook when " + "bngsim is available (the routing classifier falls back otherwise), so " + "this asserts the bngsim path and can't run without it", +) +def test_nf_parameter_scan_clears_model_time_between_scan_points( + tmp_path, + real_bng_backend_runtime, +): + """The network-free hook must leave ``$model->Time`` cleared on exit, the + same as BNG2.pl's normal ``sub simulate_nf``: its trailing + ``$model->Time($t_end)`` runs OUTSIDE the inner ``if (defined n_steps)`` + block, where ``$t_end`` is an unassigned (``undef``) outer declaration -- + not the inner ``$t_end`` the hook builds. If the hook instead left Time + at this run's real ``t_end``, the next nf scan point would inherit it as + ``t_start`` and abort with "t_end must be greater than t_start" -- the + scaffold.bngl regression. + + A preceding network ``simulate`` leaves model Time set, so scan point 1 + legitimately inherits a nonzero ``t_start``; every later scan point must + fall back to ``t_start=0`` because the nf hook cleared Time. Without the + fix, scan point 2 inherits point 1's ``t_end`` and the run aborts. + """ + action = ( + "generate_network({overwrite=>1})\n" + 'simulate({method=>"ode",t_start=>0,t_end=>60,n_steps=>6})\n' + "saveConcentrations()\n" + "resetConcentrations()\n" + 'parameter_scan({suffix=>"suf",method=>"nf",t_end=>100,n_steps=>10,' + 'parameter=>"k",par_min=>1,par_max=>5,n_scan_pts=>3})' + ) + out_dir, _ = _run_real_hook(tmp_path, real_bng_backend_runtime, "NFSCAN", action) + + jobs = _captured_jobs(real_bng_backend_runtime["capture"]) + methods = [job["method"] for job in jobs] + assert methods == ["ode", "nf", "nf", "nf"] + + nf_t_starts = [ + float(job["simulation_options"]["t_start"]) + for job in jobs + if job["method"] == "nf" + ] + # Scan point 1 inherits Time from the preceding network simulate (proving + # Time inheritance is live); points 2 and 3 must default to 0 because the + # nf hook cleared Time on exit. (The exact point-1 value depends on the + # fake helper's .cdat, so only its nonzero-ness is asserted.) + assert nf_t_starts[0] > 0.0 + assert nf_t_starts[1:] == [0.0, 0.0] + assert (out_dir / "nfscan_suf.scan").is_file() + + +def test_helper_failure_propagates_as_bng_run_error( + tmp_path, + real_bng_backend_runtime, + monkeypatch, +): + monkeypatch.setenv("FAKE_BACKEND_FAIL", "1") + + with pytest.raises(BNGRunError): + _run_real_hook( + tmp_path, + real_bng_backend_runtime, + "ODE", + "generate_network({overwrite=>1})\nsimulate_ode({t_end=>1,n_steps=>2})", + ) diff --git a/tests/test_bngsim_bridge.py b/tests/test_bngsim_bridge.py new file mode 100644 index 00000000..d9e1ee71 --- /dev/null +++ b/tests/test_bngsim_bridge.py @@ -0,0 +1,347 @@ +"""Tests for the BNGsim bridge module. + +Unit tests for format detection run without BNGsim installed. +Integration tests are skipped if BNGsim is not available. +""" + +import os +import tempfile + +import pytest + +from bionetgen.core.exc import BNGFormatError, BNGSimError +from bionetgen.core.tools.bngsim_bridge import ( + BNGSIM_AVAILABLE, + BNGSIM_HAS_NFSIM, + BNGSIM_VERSION, + FORMAT_ANTIMONY, + FORMAT_BNG_XML, + FORMAT_BNGL, + FORMAT_NET, + FORMAT_SBML, + _normalize_method, + _sniff_xml_format, + detect_input_format, + run_with_bngsim, +) + +tfold = os.path.dirname(__file__) + + +@pytest.fixture(scope="module") +def generated_net_file(tmp_path_factory, require_bng2): + """Generate a .net fixture locally instead of depending on test order.""" + from bionetgen.core.defaults import BNGDefaults + from bionetgen.core.tools.cli import BNGCLI + + out_dir = tmp_path_factory.mktemp("bngsim_net_fixture") + cli = BNGCLI( + os.path.join(tfold, "test.bngl"), + str(out_dir), + BNGDefaults().bng_path, + suppress=True, + ) + cli.run() + assert cli.result.process_return == 0 + + net_file = out_dir / "test.net" + assert net_file.is_file() + return str(net_file) + + +# ─── Format detection: extension-based ───────────────────────────── + + +class TestFormatDetectionByExtension: + def test_bngl(self): + assert detect_input_format("model.bngl") == FORMAT_BNGL + + def test_net(self): + assert detect_input_format("model.net") == FORMAT_NET + + def test_antimony(self): + assert detect_input_format("model.ant") == FORMAT_ANTIMONY + + def test_bngl_with_path(self): + assert detect_input_format("/some/path/to/model.bngl") == FORMAT_BNGL + + def test_unknown_extension(self): + with pytest.raises(BNGFormatError, match="Unrecognized file extension"): + detect_input_format("model.txt") + + def test_no_extension(self): + with pytest.raises(BNGFormatError, match="Unrecognized file extension"): + detect_input_format("model") + + +# ─── Format detection: XML sniffing ──────────────────────────────── + + +class TestXMLSniffing: + def _write_xml(self, content): + f = tempfile.NamedTemporaryFile(suffix=".xml", mode="w", delete=False) + f.write(content) + f.close() + return f.name + + def test_sbml_detected(self): + path = self._write_xml( + '\n' + '\n' + ' \n' + "" + ) + try: + assert _sniff_xml_format(path) == FORMAT_SBML + assert detect_input_format(path) == FORMAT_SBML + finally: + os.unlink(path) + + def test_bng_xml_detected(self): + path = self._write_xml( + '\n' + "\n" + " \n" + " \n" + "" + ) + try: + assert _sniff_xml_format(path) == FORMAT_BNG_XML + assert detect_input_format(path) == FORMAT_BNG_XML + finally: + os.unlink(path) + + def test_bng_xml_with_sbml_wrapper(self): + """BNG XML that also has an tag should be detected as BNG XML.""" + path = self._write_xml( + '\n' + "\n" + " \n" + " \n" + " \n" + "" + ) + try: + assert _sniff_xml_format(path) == FORMAT_BNG_XML + finally: + os.unlink(path) + + def test_ambiguous_xml_raises(self): + path = self._write_xml('\n') + try: + with pytest.raises(BNGFormatError, match="Could not determine"): + detect_input_format(path) + finally: + os.unlink(path) + + def test_nonexistent_xml_raises(self): + with pytest.raises(BNGFormatError, match="Could not read file"): + detect_input_format("/nonexistent/path/model.xml") + + def test_bng_xml_with_observables(self): + path = self._write_xml( + '\n\n \n' + ) + try: + assert _sniff_xml_format(path) == FORMAT_BNG_XML + finally: + os.unlink(path) + + +# ─── Explicit format flag ────────────────────────────────────────── + + +class TestExplicitFormat: + def test_explicit_bngl(self): + assert detect_input_format("model.bngl", explicit_format="bngl") == FORMAT_BNGL + + def test_explicit_overrides_for_xml(self): + """Explicit format for XML skips sniffing when file doesn't exist, + but we test with a real SBML file.""" + f = tempfile.NamedTemporaryFile(suffix=".xml", mode="w", delete=False) + f.write('') + f.close() + try: + assert detect_input_format(f.name, explicit_format="sbml") == FORMAT_SBML + finally: + os.unlink(f.name) + + def test_explicit_conflicts_with_autodetect(self): + """Saying --format=bng-xml on an SBML file should raise.""" + f = tempfile.NamedTemporaryFile(suffix=".xml", mode="w", delete=False) + f.write('') + f.close() + try: + with pytest.raises(BNGFormatError, match="Format conflict"): + detect_input_format(f.name, explicit_format="bng-xml") + finally: + os.unlink(f.name) + + def test_explicit_conflicts_with_extension(self): + with pytest.raises(BNGFormatError, match="Format conflict"): + detect_input_format("model.bngl", explicit_format="sbml") + + def test_unknown_explicit_format(self): + with pytest.raises(BNGFormatError, match="Unknown format"): + detect_input_format("model.xml", explicit_format="foobar") + + def test_explicit_case_insensitive(self): + assert detect_input_format("model.bngl", explicit_format="BNGL") == FORMAT_BNGL + + +# ─── Availability flags ──────────────────────────────────────────── + + +class TestAvailabilityFlags: + def test_bngsim_available_is_bool(self): + assert isinstance(BNGSIM_AVAILABLE, bool) + + def test_bngsim_has_nfsim_is_bool(self): + assert isinstance(BNGSIM_HAS_NFSIM, bool) + + def test_version_matches_availability(self): + if BNGSIM_AVAILABLE: + assert BNGSIM_VERSION is not None + else: + assert BNGSIM_VERSION is None + + +# ─── Public API exposure ─────────────────────────────────────────── + + +class TestPublicAPI: + def test_available_in_bionetgen_namespace(self): + import bionetgen + + assert hasattr(bionetgen, "BNGSIM_AVAILABLE") + assert hasattr(bionetgen, "BNGSIM_VERSION") + + def test_run_signature(self): + import inspect + + import bionetgen + + sig = inspect.signature(bionetgen.run) + params = list(sig.parameters.keys()) + assert "simulator" in params + assert "format" in params + assert "method" in params + assert "t_span" in params + assert "n_points" in params + + +# ─── Routing logic (no BNGsim needed) ───────────────────────────── + + +class TestRoutingWithoutBngsim: + def test_sbml_without_bngsim_raises(self): + """SBML format should raise if BNGsim is not available.""" + import unittest.mock as mock + + f = tempfile.NamedTemporaryFile(suffix=".xml", mode="w", delete=False) + f.write('') + f.close() + try: + with mock.patch( + "bionetgen.core.tools.bngsim_bridge.BNGSIM_AVAILABLE", False + ): + with pytest.raises(BNGSimError, match="BNGsim is required"): + run_with_bngsim(f.name, "/tmp/out", fmt=FORMAT_SBML) + finally: + os.unlink(f.name) + + def test_antimony_without_bngsim_raises(self): + """Antimony format should raise if BNGsim is not available.""" + import unittest.mock as mock + + with mock.patch("bionetgen.core.tools.bngsim_bridge.BNGSIM_AVAILABLE", False): + with pytest.raises(BNGSimError, match="BNGsim is required"): + run_with_bngsim("model.ant", "/tmp/out", fmt=FORMAT_ANTIMONY) + + +# ─── Integration tests (require BNGsim) ─────────────────────────── + + +@pytest.mark.skipif( + not BNGSIM_AVAILABLE, + reason="requires bngsim package importable (e.g. editable install via PYBNG_DEV_BNGSIM_PATH)", +) +class TestBngsimIntegration: + def test_run_net_file(self, generated_net_file): + """Run a .net file through BNGsim and verify output files.""" + with tempfile.TemporaryDirectory() as out: + result = run_with_bngsim( + generated_net_file, + out, + fmt=FORMAT_NET, + method="ode", + t_span=(0, 10), + n_points=11, + ) + assert result is not None + assert result.process_return == 0 + # Check that output files were created + files = os.listdir(out) + assert any(f.endswith((".gdat", ".cdat")) for f in files) + + def test_run_via_library_api(self, generated_net_file): + """Run a .net file via bionetgen.run() with simulator='bngsim'.""" + import bionetgen + + with tempfile.TemporaryDirectory() as out: + result = bionetgen.run( + generated_net_file, + out=out, + simulator="bngsim", + format="net", + method="ode", + t_span=(0, 10), + n_points=11, + ) + assert result is not None + + def test_bngsim_version_reported(self): + """BNGsim version should be a non-empty string.""" + assert BNGSIM_VERSION is not None + assert len(BNGSIM_VERSION) > 0 + + +# ─── Method normalization (SSA/PSA) ────────────────────────────── + + +class TestNormalizeMethod: + def test_ode_unchanged(self): + assert _normalize_method("ode") == ("ode", None) + + def test_ssa_unchanged_without_poplevel(self): + assert _normalize_method("ssa") == ("ssa", None) + + def test_ssa_promoted_to_psa_with_poplevel(self): + """BNG2.pl compat: ssa + poplevel → psa.""" + method, poplevel = _normalize_method("ssa", poplevel=200.0) + assert method == "psa" + assert poplevel == 200.0 + + def test_psa_direct(self): + method, poplevel = _normalize_method("psa", poplevel=500.0) + assert method == "psa" + assert poplevel == 500.0 + + def test_psa_default_poplevel(self): + """PSA without poplevel should default to 100.""" + method, poplevel = _normalize_method("psa") + assert method == "psa" + assert poplevel == 100.0 + + def test_psa_low_poplevel_gets_default(self): + """PSA with poplevel <= 1.0 should default to 100.""" + method, poplevel = _normalize_method("psa", poplevel=0.5) + assert method == "psa" + assert poplevel == 100.0 + + def test_nf_unchanged(self): + assert _normalize_method("nf") == ("nf", None) + + def test_case_insensitive(self): + assert _normalize_method("SSA", poplevel=100.0) == ("psa", 100.0) + assert _normalize_method("ODE") == ("ode", None) diff --git a/tests/test_bngsim_bridge_extended.py b/tests/test_bngsim_bridge_extended.py new file mode 100644 index 00000000..77f667e5 --- /dev/null +++ b/tests/test_bngsim_bridge_extended.py @@ -0,0 +1,880 @@ +"""Extended tests for bngsim_bridge to increase coverage. + +These tests mock the bngsim library extensively so they run without +BNGsim installed. +""" + +import os +import tempfile +from unittest.mock import MagicMock, patch + +import numpy as np +import pytest + +from bionetgen.core.exc import BNGSimError + +# ─── Helpers ────────────────────────────────────────────────────────── + +BRIDGE = "bionetgen.core.tools.bngsim_bridge" + + +def _make_mock_result( + obs_names=None, + obs_data=None, + species_names=None, + concentrations=None, + n_times=10, + time=None, + func_names=None, + func_data=None, +): + """Build a mock bngsim.Result-like object.""" + if obs_names is None: + obs_names = ["obsA", "obsB"] + if obs_data is None: + obs_data = np.random.rand(n_times, len(obs_names)) + if species_names is None: + species_names = ["S1", "S2"] + if concentrations is None: + concentrations = [1.0, 2.0] + if time is None: + time = np.linspace(0, 100, n_times) + if func_names is None: + func_names = [] + if func_data is None: + func_data = np.empty((n_times, 0)) + + # bngsim's single-format schema (issue #58): gdat_expression_names is + # bare and identical to expression_names — no () suffix, no _rateLawN. + gdat_func_names = list(func_names) + + core = MagicMock() + core.expression_names = func_names + core.expression_data = func_data + core.gdat_expression_names = gdat_func_names + + result = MagicMock() + result.observable_names = obs_names + result.observables = obs_data + result.n_observables = len(obs_names) + result.n_times = n_times + result.time = time + result.expression_names = func_names + result.gdat_expression_names = gdat_func_names + result.expressions = func_data + result.species_names = species_names + result.concentrations = concentrations + result._core = core + result.to_cdat = MagicMock() + return result + + +def _make_mock_bngsim_with_nfsim_session(result=None): + """Build a mock bngsim module exposing the public NfsimSession API.""" + if result is None: + result = _make_mock_result() + + session = MagicMock() + session.simulate.return_value = result + + mock_bngsim = MagicMock() + mock_bngsim.NfsimSession.return_value.__enter__.return_value = session + return mock_bngsim, session + + +def _make_mock_model(param_names=None, params=None): + """Build a mock bngsim.Model-like object.""" + if param_names is None: + param_names = ["k1", "k2"] + if params is None: + params = {"k1": 0.1, "k2": 0.5} + + model = MagicMock() + model.param_names = param_names + model.get_param = MagicMock(side_effect=lambda n: params.get(n, 0.0)) + model.set_param = MagicMock() + model.set_concentration = MagicMock() + model.get_concentration = MagicMock(return_value=10.0) + model.save_concentrations = MagicMock() + model.reset = MagicMock() + model.clone = MagicMock( + return_value=MagicMock( + param_names=param_names, + get_param=MagicMock(side_effect=lambda n: params.get(n, 0.0)), + set_param=MagicMock(), + set_concentration=MagicMock(), + save_concentrations=MagicMock(), + reset=MagicMock(), + ) + ) + model.add_table_function = MagicMock() + return model + + +# ─── _write_bng_dat ────────────────────────────────────────────────── + + +class TestWriteBngDat: + def test_writes_header_and_data(self): + from bionetgen.core.tools.bngsim_bridge import _write_bng_dat + + with tempfile.NamedTemporaryFile(mode="w", suffix=".gdat", delete=False) as f: + path = f.name + + try: + time = np.array([0.0, 1.0, 2.0]) + data = np.array([[10.0, 20.0], [11.0, 21.0], [12.0, 22.0]]) + _write_bng_dat(path, time, data, ["obsA", "obsB"]) + + with open(path) as f: + lines = f.readlines() + + assert lines[0].startswith("# ") + assert "time" in lines[0] + assert "obsA" in lines[0] + assert "obsB" in lines[0] + assert len(lines) == 4 # header + 3 data rows + finally: + os.unlink(path) + + +# ─── _write_bngsim_results ─────────────────────────────────────────── + + +class TestWriteBngsimResults: + def test_writes_gdat_and_cdat(self): + from bionetgen.core.tools.bngsim_bridge import _write_bngsim_results + + result = _make_mock_result() + with tempfile.TemporaryDirectory() as tmpdir: + _write_bngsim_results(result, tmpdir, "test_model") + gdat = os.path.join(tmpdir, "test_model.gdat") + cdat = os.path.join(tmpdir, "test_model.cdat") + assert os.path.isfile(gdat) + result.to_cdat.assert_called_once_with(cdat) + + def test_with_print_functions(self): + from bionetgen.core.tools.bngsim_bridge import _write_bngsim_results + + func_data = np.random.rand(10, 2) + result = _make_mock_result(func_names=["f1", "f2"], func_data=func_data) + with tempfile.TemporaryDirectory() as tmpdir: + _write_bngsim_results(result, tmpdir, "test_model", print_functions=True) + gdat = os.path.join(tmpdir, "test_model.gdat") + with open(gdat) as f: + header = f.readline() + assert "f1" in header + assert "f2" in header + # network methods (run_network) write function columns bare + assert "f1()" not in header + assert "f2()" not in header + + def test_print_functions_network_free_writes_bare_names(self): + # bngsim's single-format schema (issue #58): every method, including + # network-free (NFsim/RuleMonkey), writes function columns bare — no + # () suffix. Regression guard for the write-path retirement: an + # earlier version stripped a presumed () off bngsim's already-bare + # gdat_expression_names, dropping every nf function column. + from bionetgen.core.tools.bngsim_bridge import _write_bngsim_results + + func_data = np.random.rand(10, 2) + result = _make_mock_result(func_names=["kf_BSA", "kr_BSA"], func_data=func_data) + with tempfile.TemporaryDirectory() as tmpdir: + _write_bngsim_results( + result, + tmpdir, + "nf_model", + print_functions=True, + ) + with open(os.path.join(tmpdir, "nf_model.gdat")) as f: + header = f.readline() + assert "kf_BSA" in header + assert "kr_BSA" in header + assert "kf_BSA()" not in header + assert "kr_BSA()" not in header + + def test_no_observables_no_funcs_skips_gdat(self): + from bionetgen.core.tools.bngsim_bridge import _write_bngsim_results + + result = _make_mock_result(obs_names=[], obs_data=np.empty((10, 0))) + result.n_observables = 0 + with tempfile.TemporaryDirectory() as tmpdir: + _write_bngsim_results(result, tmpdir, "test_model") + gdat = os.path.join(tmpdir, "test_model.gdat") + assert not os.path.isfile(gdat) + + def test_print_functions_without_bngsim_expressions_does_not_eval_bngl(self): + from bionetgen.core.tools.bngsim_bridge import _write_bngsim_results + + obs_names = ["Y1", "Y2x2"] + obs_data = np.array( + [ + [0.0, 0.0], + [1.0, 2.0], + [3.0, 0.0], + ], + dtype=float, + ) + result = _make_mock_result( + obs_names=obs_names, + obs_data=obs_data, + n_times=3, + ) + + with tempfile.TemporaryDirectory() as tmpdir: + _write_bngsim_results( + result, + tmpdir, + "alt", + print_functions=True, + ) + gdat = os.path.join(tmpdir, "alt.gdat") + with open(gdat) as f: + lines = f.readlines() + + assert "Y1" in lines[0] + assert "Y2x2" in lines[0] + assert "Y2()" not in lines[0] + assert "Sfree()" not in lines[0] + assert "Lfree()" not in lines[0] + + def test_nfsim_fallback_skipped_when_bngsim_returns_expressions(self): + # If BNGsim supplies a non-empty expression block, the writer + # includes those direct result columns. + from bionetgen.core.tools.bngsim_bridge import _write_bngsim_results + + func_data = np.array([[7.0], [8.0], [9.0]]) + result = _make_mock_result( + obs_names=["obsA"], + obs_data=np.zeros((3, 1)), + n_times=3, + func_names=["actually_from_bngsim"], + func_data=func_data, + ) + + with tempfile.TemporaryDirectory() as tmpdir: + _write_bngsim_results( + result, + tmpdir, + "ode", + print_functions=True, + ) + with open(os.path.join(tmpdir, "ode.gdat")) as f: + header = f.readline() + assert "actually_from_bngsim" in header + + +# ─── _make_bng_result ──────────────────────────────────────────────── + + +class TestMakeBngResult: + def test_returns_result(self): + from bionetgen.core.tools.bngsim_bridge import _make_bng_result + + with tempfile.TemporaryDirectory() as tmpdir: + result = _make_bng_result(tmpdir, method="ode") + assert result.process_return == 0 + assert "ode" in result.output[0] + + +# ─── run_nfsim ──────────────────────────────────────────────────────── + + +class TestRunNfsim: + def test_raises_when_bngsim_unavailable(self): + from bionetgen.core.tools.bngsim_bridge import run_nfsim + + with patch(f"{BRIDGE}.BNGSIM_AVAILABLE", False): + with pytest.raises(BNGSimError, match="not usable"): + run_nfsim("/dummy.xml", "/output") + + def test_raises_when_nfsim_unavailable(self): + from bionetgen.core.tools.bngsim_bridge import run_nfsim + + with ( + patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), + patch(f"{BRIDGE}.BNGSIM_HAS_NFSIM", False), + ): + with pytest.raises(BNGSimError, match="not available"): + run_nfsim("/dummy.xml", "/output") + + def test_happy_path(self): + from bionetgen.core.tools.bngsim_bridge import run_nfsim + + mock_bngsim, session = _make_mock_bngsim_with_nfsim_session() + + with ( + patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), + patch(f"{BRIDGE}.BNGSIM_HAS_NFSIM", True), + patch(f"{BRIDGE}.bngsim", mock_bngsim), + tempfile.TemporaryDirectory() as tmpdir, + ): + # Create a dummy xml file + xml_path = os.path.join(tmpdir, "model.xml") + with open(xml_path, "w") as f: + f.write("") + + result = run_nfsim(xml_path, tmpdir) + assert result.process_return == 0 + + mock_bngsim.NfsimSession.assert_called_once_with( + xml_path, molecule_limit=None + ) + session.initialize.assert_called_once_with(42) + session.simulate.assert_called_once_with(0.0, 100.0, 101) + + def test_param_overrides(self): + from bionetgen.core.tools.bngsim_bridge import run_nfsim + + mock_bngsim, session = _make_mock_bngsim_with_nfsim_session() + + with ( + patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), + patch(f"{BRIDGE}.BNGSIM_HAS_NFSIM", True), + patch(f"{BRIDGE}.bngsim", mock_bngsim), + tempfile.TemporaryDirectory() as tmpdir, + ): + xml_path = os.path.join(tmpdir, "model.xml") + with open(xml_path, "w") as f: + f.write("") + + run_nfsim(xml_path, tmpdir, param_overrides={"k1": 5.0}) + session.set_param.assert_called_with("k1", 5.0) + + def test_conc_overrides_set_exact_species_count(self): + """conc_overrides should call set_species_count when available.""" + from bionetgen.core.tools.bngsim_bridge import run_nfsim + + mock_bngsim, session = _make_mock_bngsim_with_nfsim_session() + + with ( + patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), + patch(f"{BRIDGE}.BNGSIM_HAS_NFSIM", True), + patch(f"{BRIDGE}.bngsim", mock_bngsim), + tempfile.TemporaryDirectory() as tmpdir, + ): + xml_path = os.path.join(tmpdir, "model.xml") + with open(xml_path, "w") as f: + f.write("") + + run_nfsim(xml_path, tmpdir, conc_overrides={"A(b)": 200}) + session.set_species_count.assert_called_with("A(b)", 200) + session.get_molecule_count.assert_not_called() + session.add_molecules.assert_not_called() + + def test_conc_overrides_can_decrease_exact_species_count(self): + """conc_overrides should allow decreases through set_species_count.""" + from bionetgen.core.tools.bngsim_bridge import run_nfsim + + mock_bngsim, session = _make_mock_bngsim_with_nfsim_session() + + with ( + patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), + patch(f"{BRIDGE}.BNGSIM_HAS_NFSIM", True), + patch(f"{BRIDGE}.bngsim", mock_bngsim), + tempfile.TemporaryDirectory() as tmpdir, + ): + xml_path = os.path.join(tmpdir, "model.xml") + with open(xml_path, "w") as f: + f.write("") + + run_nfsim(xml_path, tmpdir, conc_overrides={"A(b)": 50}) + session.set_species_count.assert_called_with("A(b)", 50) + session.get_molecule_count.assert_not_called() + session.add_molecules.assert_not_called() + + def test_conc_overrides_same_mol_type_stay_pattern_specific(self): + """Patterned overrides should no longer collapse by molecule type.""" + from bionetgen.core.tools.bngsim_bridge import run_nfsim + + mock_bngsim, session = _make_mock_bngsim_with_nfsim_session() + + with ( + patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), + patch(f"{BRIDGE}.BNGSIM_HAS_NFSIM", True), + patch(f"{BRIDGE}.bngsim", mock_bngsim), + tempfile.TemporaryDirectory() as tmpdir, + ): + xml_path = os.path.join(tmpdir, "model.xml") + with open(xml_path, "w") as f: + f.write("") + + run_nfsim( + xml_path, + tmpdir, + conc_overrides={"A(b~0)": 50, "A(b~1)": 150}, + ) + session.set_species_count.assert_any_call("A(b~0)", 50) + session.set_species_count.assert_any_call("A(b~1)", 150) + assert session.set_species_count.call_count == 2 + session.get_molecule_count.assert_not_called() + session.add_molecules.assert_not_called() + + def test_conc_overrides_and_deltas_remain_pattern_specific(self): + """Absolute and relative NF concentration changes should replay by pattern.""" + from bionetgen.core.tools.bngsim_bridge import run_nfsim + + mock_bngsim, session = _make_mock_bngsim_with_nfsim_session() + + with ( + patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), + patch(f"{BRIDGE}.BNGSIM_HAS_NFSIM", True), + patch(f"{BRIDGE}.bngsim", mock_bngsim), + tempfile.TemporaryDirectory() as tmpdir, + ): + xml_path = os.path.join(tmpdir, "model.xml") + with open(xml_path, "w") as f: + f.write("") + + run_nfsim( + xml_path, + tmpdir, + conc_overrides={"A(b)": 100}, + conc_deltas={"A(c)": 25}, + ) + session.set_species_count.assert_called_once_with("A(b)", 100) + session.add_species.assert_called_once_with("A(c)", 25) + session.get_molecule_count.assert_not_called() + session.add_molecules.assert_not_called() + + def test_conc_overrides_and_deltas_same_pattern_combine(self): + """An override and delta for the same exact pattern should combine.""" + from bionetgen.core.tools.bngsim_bridge import run_nfsim + + mock_bngsim, session = _make_mock_bngsim_with_nfsim_session() + + with ( + patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), + patch(f"{BRIDGE}.BNGSIM_HAS_NFSIM", True), + patch(f"{BRIDGE}.bngsim", mock_bngsim), + tempfile.TemporaryDirectory() as tmpdir, + ): + xml_path = os.path.join(tmpdir, "model.xml") + with open(xml_path, "w") as f: + f.write("") + + run_nfsim( + xml_path, + tmpdir, + conc_overrides={"A(b)": 100}, + conc_deltas={"A(b)": 25}, + ) + session.set_species_count.assert_called_once_with("A(b)", 125) + session.add_species.assert_not_called() + + def test_conc_deltas_can_decrease_exact_species_count(self): + """Negative deltas should call remove_species when available.""" + from bionetgen.core.tools.bngsim_bridge import run_nfsim + + mock_bngsim, session = _make_mock_bngsim_with_nfsim_session() + + with ( + patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), + patch(f"{BRIDGE}.BNGSIM_HAS_NFSIM", True), + patch(f"{BRIDGE}.bngsim", mock_bngsim), + tempfile.TemporaryDirectory() as tmpdir, + ): + xml_path = os.path.join(tmpdir, "model.xml") + with open(xml_path, "w") as f: + f.write("") + + run_nfsim(xml_path, tmpdir, conc_deltas={"A(b)": -25}) + + session.remove_species.assert_called_once_with("A(b)", 25) + session.get_molecule_count.assert_not_called() + session.add_molecules.assert_not_called() + + def test_legacy_patterned_conc_changes_are_molecule_type_granular(self): + """Legacy fallback still collapses patterns by molecule type.""" + from bionetgen.core.tools.bngsim_bridge import ( + _collapse_nfsim_concentration_changes, + ) + + collapsed_overrides, collapsed_deltas = _collapse_nfsim_concentration_changes( + conc_overrides={"A(b~0)": 50, "A(b~1)": 150}, + conc_deltas={"A(b~0)": -5, "A(b~1)": 20}, + ) + + assert collapsed_overrides == {"A": 200} + assert collapsed_deltas == {"A": 15} + + def test_conc_replay_falls_back_for_legacy_nfsim_session(self): + """Older bngsim builds without species APIs keep the molecule-type path.""" + from bionetgen.core.tools.bngsim_bridge import ( + _apply_nfsim_concentration_changes, + ) + + class LegacyNfsimSession: + def __init__(self): + self.get_molecule_count = MagicMock(return_value=50) + self.add_molecules = MagicMock() + + session = LegacyNfsimSession() + _apply_nfsim_concentration_changes( + session, + conc_overrides={"A(b)": 200}, + ) + + session.get_molecule_count.assert_called_once_with("A") + session.add_molecules.assert_called_once_with("A", 150) + + def test_defaults(self): + """Test default t_span, n_points, seed.""" + from bionetgen.core.tools.bngsim_bridge import run_nfsim + + mock_bngsim, session = _make_mock_bngsim_with_nfsim_session() + + with ( + patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), + patch(f"{BRIDGE}.BNGSIM_HAS_NFSIM", True), + patch(f"{BRIDGE}.bngsim", mock_bngsim), + tempfile.TemporaryDirectory() as tmpdir, + ): + xml_path = os.path.join(tmpdir, "model.xml") + with open(xml_path, "w") as f: + f.write("") + + run_nfsim(xml_path, tmpdir) + # Default: simulate(0.0, 100.0, 101) + session.simulate.assert_called_once_with(0.0, 100.0, 101) + session.initialize.assert_called_once_with(42) + + def test_simulation_failure_wraps_exception(self): + from bionetgen.core.tools.bngsim_bridge import run_nfsim + + mock_bngsim = MagicMock() + mock_bngsim.NfsimSession.side_effect = RuntimeError("boom") + + with ( + patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), + patch(f"{BRIDGE}.BNGSIM_HAS_NFSIM", True), + patch(f"{BRIDGE}.bngsim", mock_bngsim), + tempfile.TemporaryDirectory() as tmpdir, + ): + xml_path = os.path.join(tmpdir, "model.xml") + with open(xml_path, "w") as f: + f.write("") + + with pytest.raises(BNGSimError, match="NFsim simulation failed"): + run_nfsim(xml_path, tmpdir) + + def test_gml_is_set(self): + from bionetgen.core.tools.bngsim_bridge import run_nfsim + + mock_bngsim, _ = _make_mock_bngsim_with_nfsim_session() + + with ( + patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), + patch(f"{BRIDGE}.BNGSIM_HAS_NFSIM", True), + patch(f"{BRIDGE}.bngsim", mock_bngsim), + tempfile.TemporaryDirectory() as tmpdir, + ): + xml_path = os.path.join(tmpdir, "model.xml") + with open(xml_path, "w") as f: + f.write("") + + run_nfsim(xml_path, tmpdir, gml=100000) + mock_bngsim.NfsimSession.assert_called_once_with( + xml_path, molecule_limit=100000 + ) + + +# ─── run_with_bngsim ───────────────────────────────────────────────── + + +class TestRunWithBngsim: + def test_raises_when_bngsim_unavailable(self): + from bionetgen.core.tools.bngsim_bridge import run_with_bngsim + + with patch(f"{BRIDGE}.BNGSIM_AVAILABLE", False): + with pytest.raises(BNGSimError, match="not usable"): + run_with_bngsim("/dummy.net", "/output", fmt="net") + + def test_bng_xml_routes_to_run_nfsim(self): + from bionetgen.core.tools.bngsim_bridge import run_with_bngsim + + mock_run_nfsim = MagicMock() + with ( + patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), + patch(f"{BRIDGE}.run_nfsim", mock_run_nfsim), + ): + run_with_bngsim("/model.xml", "/output", fmt="bng-xml", method="nf") + mock_run_nfsim.assert_called_once() + + def test_bng_xml_defaults_to_nf(self): + from bionetgen.core.tools.bngsim_bridge import run_with_bngsim + + mock_run_nfsim = MagicMock() + with ( + patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), + patch(f"{BRIDGE}.run_nfsim", mock_run_nfsim), + ): + run_with_bngsim("/model.xml", "/output", fmt="bng-xml", method=None) + mock_run_nfsim.assert_called_once() + + def test_bng_xml_bad_method_raises(self): + from bionetgen.core.tools.bngsim_bridge import run_with_bngsim + + with patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True): + with pytest.raises(BNGSimError, match="network-free simulation"): + run_with_bngsim("/model.xml", "/output", fmt="bng-xml", method="ssa") + + def test_bng_xml_ode_method_raises(self): + from bionetgen.core.tools.bngsim_bridge import run_with_bngsim + + with patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True): + with pytest.raises(BNGSimError, match="network-free simulation"): + run_with_bngsim("/model.xml", "/output", fmt="bng-xml", method="ode") + + def test_net_loads_from_net(self): + from bionetgen.core.tools.bngsim_bridge import run_with_bngsim + + mock_bngsim = MagicMock() + mock_model = _make_mock_model() + mock_bngsim.Model.from_net.return_value = mock_model + mock_result = _make_mock_result() + mock_sim = MagicMock() + mock_sim.run.return_value = mock_result + mock_bngsim.Simulator.return_value = mock_sim + + with ( + patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), + patch(f"{BRIDGE}.bngsim", mock_bngsim), + tempfile.TemporaryDirectory() as tmpdir, + ): + run_with_bngsim("/model.net", tmpdir, fmt="net", method="ode") + mock_bngsim.Model.from_net.assert_called_once() + + def test_sbml_loads_from_sbml(self): + from bionetgen.core.tools.bngsim_bridge import run_with_bngsim + + mock_bngsim = MagicMock() + mock_model = _make_mock_model() + mock_bngsim.Model.from_sbml.return_value = mock_model + mock_result = _make_mock_result() + mock_sim = MagicMock() + mock_sim.run.return_value = mock_result + mock_bngsim.Simulator.return_value = mock_sim + + with ( + patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), + patch(f"{BRIDGE}.bngsim", mock_bngsim), + tempfile.TemporaryDirectory() as tmpdir, + ): + run_with_bngsim("/model.xml", tmpdir, fmt="sbml", method="ode") + mock_bngsim.Model.from_sbml.assert_called_once() + + def test_antimony_loads_from_antimony(self): + from bionetgen.core.tools.bngsim_bridge import run_with_bngsim + + mock_bngsim = MagicMock() + mock_bngsim.Model.from_antimony.return_value = _make_mock_model() + mock_sim = MagicMock() + mock_sim.run.return_value = _make_mock_result() + mock_bngsim.Simulator.return_value = mock_sim + + with ( + patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), + patch(f"{BRIDGE}.bngsim", mock_bngsim), + tempfile.TemporaryDirectory() as tmpdir, + ): + run_with_bngsim("/model.ant", tmpdir, fmt="antimony", method="ode") + mock_bngsim.Model.from_antimony.assert_called_once() + + def test_nf_method_without_xml_raises(self): + from bionetgen.core.tools.bngsim_bridge import run_with_bngsim + + with patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True): + with pytest.raises(BNGSimError, match="requires a BioNetGen XML"): + run_with_bngsim("/model.net", "/output", fmt="net", method="nf") + + def test_nf_with_xml_path_routes_to_nfsim(self): + from bionetgen.core.tools.bngsim_bridge import run_with_bngsim + + mock_run_nfsim = MagicMock() + with ( + patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), + patch(f"{BRIDGE}.run_nfsim", mock_run_nfsim), + ): + run_with_bngsim( + "/model.net", + "/output", + fmt="net", + method="nf", + xml_path="/model.xml", + ) + mock_run_nfsim.assert_called_once() + + def test_unsupported_format_raises(self): + from bionetgen.core.tools.bngsim_bridge import run_with_bngsim + + mock_bngsim = MagicMock() + with ( + patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), + patch(f"{BRIDGE}.bngsim", mock_bngsim), + ): + with pytest.raises(BNGSimError, match="Unsupported format"): + run_with_bngsim("/model.bngl", "/output", fmt="bngl", method="ode") + + def test_simulation_exception_wrapped(self): + from bionetgen.core.tools.bngsim_bridge import run_with_bngsim + + mock_bngsim = MagicMock() + mock_bngsim.Model.from_net.side_effect = RuntimeError("boom") + + with ( + patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), + patch(f"{BRIDGE}.bngsim", mock_bngsim), + ): + with pytest.raises(BNGSimError, match="BNGsim simulation failed"): + run_with_bngsim("/model.net", "/output", fmt="net") + + +# ─── _try_prepare_codegen ───────────────────────────────────────────── + + +class TestTryPrepareCodegen: + def test_returns_empty_when_env_var_set(self): + from bionetgen.core.tools.bngsim_bridge import _try_prepare_codegen + + with patch.dict(os.environ, {"BIONETGEN_NO_CODEGEN": "1"}): + assert _try_prepare_codegen("/dummy.net") == "" + + def test_returns_empty_when_codegen_unavailable(self): + from bionetgen.core.tools.bngsim_bridge import _try_prepare_codegen + + with patch.dict(os.environ, {}, clear=False): + # Make sure BIONETGEN_NO_CODEGEN is not set + os.environ.pop("BIONETGEN_NO_CODEGEN", None) + # bngsim.prepare_codegen won't be importable + assert _try_prepare_codegen("/dummy.net") == "" + + def test_returns_so_path_when_codegen_available(self): + from bionetgen.core.tools.bngsim_bridge import _try_prepare_codegen + + mock_bngsim = MagicMock() + mock_bngsim.prepare_codegen.return_value = "/path/to/lib.so" + + with ( + patch.dict(os.environ, {}, clear=False), + patch.dict("sys.modules", {"bngsim": mock_bngsim}), + ): + os.environ.pop("BIONETGEN_NO_CODEGEN", None) + result = _try_prepare_codegen("/dummy.net") + assert result == "/path/to/lib.so" + + +# ─── Regression: XML sniffer must not misread BNG-generated SBML ──── + + +class TestSbmlWithBioNetGenComment: + """BNG2.pl writeSBML emits a 'Created by BioNetGen' comment in the + SBML output. The sniffer must not classify that as BNG XML.""" + + def _write_xml(self, content): + f = tempfile.NamedTemporaryFile(suffix=".xml", mode="w", delete=False) + f.write(content) + f.close() + return f.name + + def test_sbml_with_bionetgen_comment(self): + from bionetgen.core.tools.bngsim_bridge import ( + FORMAT_SBML, + _sniff_xml_format, + ) + + path = self._write_xml( + '\n' + "\n" + '\n' + ' \n' + " \n" + " \n" + "" + ) + try: + assert _sniff_xml_format(path) == FORMAT_SBML + finally: + os.unlink(path) + + +# ─── _truncate_cdat_to_endpoints (print_CDAT=>0) ───────────────────── + + +class TestTruncateCdatToEndpoints: + def test_keeps_header_and_first_last_rows(self): + from bionetgen.core.tools.bngsim_bridge import _truncate_cdat_to_endpoints + + with tempfile.TemporaryDirectory() as tmpdir: + cdat = os.path.join(tmpdir, "m.cdat") + with open(cdat, "w") as fh: + fh.write("# time S1\n") + for i in range(6): + fh.write(f" {float(i):.6e} {float(i * 10):.6e}\n") + _truncate_cdat_to_endpoints(cdat) + with open(cdat) as fh: + lines = [ln for ln in fh if ln.strip()] + header = [ln for ln in lines if ln.lstrip().startswith("#")] + data = [ln for ln in lines if not ln.lstrip().startswith("#")] + assert len(header) == 1 + assert len(data) == 2 + assert float(data[0].split()[0]) == 0.0 + assert float(data[1].split()[0]) == 5.0 + + def test_short_cdat_left_unchanged(self): + from bionetgen.core.tools.bngsim_bridge import _truncate_cdat_to_endpoints + + content = "# time S1\n 0.000000e+00 0.000000e+00\n 1.000000e+00 1.0e+01\n" + with tempfile.TemporaryDirectory() as tmpdir: + cdat = os.path.join(tmpdir, "m.cdat") + with open(cdat, "w") as fh: + fh.write(content) + _truncate_cdat_to_endpoints(cdat) + with open(cdat) as fh: + assert fh.read() == content + + +# ─── _partition_simulator_options: steady_state / ss_method forwarding ────────── + + +class TestPartitionSimulatorOptions: + """steady_state=>1 forwards to run(steady_state=True) instead of warn-drop.""" + + @staticmethod + def _partition(opts): + from bionetgen.core.tools.bngsim_bridge import _partition_simulator_options + + return _partition_simulator_options(opts) + + def test_steady_state_forwarded_to_run(self): + init_kwargs, run_kwargs = self._partition({"steady_state": "1", "atol": 1e-8}) + assert run_kwargs.get("steady_state") is True + assert run_kwargs.get("atol") == 1e-8 + assert "steady_state" not in init_kwargs + + def test_steady_state_tol_forwarded_as_float(self): + _, run_kwargs = self._partition( + {"steady_state": "1", "steady_state_tol": "1e-9"} + ) + assert run_kwargs["steady_state"] is True + assert run_kwargs["steady_state_tol"] == pytest.approx(1e-9) + + def test_steady_state_zero_not_forwarded(self): + _, run_kwargs = self._partition({"steady_state": "0"}) + assert "steady_state" not in run_kwargs + + def test_ss_method_newton_logged_not_forwarded(self, caplog): + # ss_method is not a run() kwarg; the direct route uses the parity + # integrator and logs (does not forward) a Newton request. + with caplog.at_level("INFO"): + _, run_kwargs = self._partition( + {"steady_state": "1", "ss_method": "newton"} + ) + assert "ss_method" not in run_kwargs + assert run_kwargs["steady_state"] is True + assert any("ss_method" in r.message for r in caplog.records) + + def test_ss_method_without_steady_state_ignored(self): + _, run_kwargs = self._partition({"ss_method": "newton"}) + assert "steady_state" not in run_kwargs + assert "ss_method" not in run_kwargs diff --git a/tests/test_bngsim_direct_job_executor.py b/tests/test_bngsim_direct_job_executor.py new file mode 100644 index 00000000..39a1fb39 --- /dev/null +++ b/tests/test_bngsim_direct_job_executor.py @@ -0,0 +1,240 @@ +import os +import tempfile +from unittest.mock import MagicMock, patch + +import numpy as np +import pytest + +from bionetgen.core.exc import BNGSimError +from bionetgen.core.tools.bngsim_bridge import ( + BngsimDirectJob, + FORMAT_ANTIMONY, + FORMAT_BNG_XML, + FORMAT_NET, + FORMAT_SBML, + execute_bngsim_direct_job, +) + +BRIDGE = "bionetgen.core.tools.bngsim_bridge" + + +def _mock_result(): + def write_cdat(path): + with open(path, "w") as f: + f.write("cdat\n") + + result = MagicMock() + result.time = np.array([0.0, 1.0]) + result.observable_names = ["obsA"] + result.observables = np.array([[0.0], [2.0]]) + result.n_observables = 1 + result.n_times = 2 + result.expression_names = [] + result.expressions = np.empty((2, 0)) + result.species = np.array([[10.0], [9.0]]) + result.to_cdat = MagicMock(side_effect=write_cdat) + return result + + +def _network_job(fmt=FORMAT_NET, method="ode", options=None, output_dir="/tmp/out"): + return BngsimDirectJob( + input_path=f"/model.{fmt}", + input_format=fmt, + method=method, + t_span=(0.0, 10.0), + n_points=11, + output_dir=output_dir, + output_root="model", + bngsim_options=options, + ) + + +@pytest.mark.parametrize("method", ["ode", "ssa", "psa", "rm"]) +def test_net_job_instantiates_simulator_with_expected_method(method): + mock_bngsim = MagicMock() + model = MagicMock() + mock_bngsim.Model.from_net.return_value = model + mock_bngsim.Simulator.return_value.run.return_value = _mock_result() + + with ( + patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), + patch(f"{BRIDGE}.bngsim", mock_bngsim), + patch(f"{BRIDGE}._write_bngsim_results"), + patch(f"{BRIDGE}._make_bng_result", return_value=MagicMock()), + ): + execute_bngsim_direct_job(_network_job(method=method)) + + mock_bngsim.Simulator.assert_called_once_with(model, method=method) + + +def test_psa_job_passes_poplevel_to_simulator(): + mock_bngsim = MagicMock() + model = MagicMock() + mock_bngsim.Model.from_net.return_value = model + mock_bngsim.Simulator.return_value.run.return_value = _mock_result() + + with ( + patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), + patch(f"{BRIDGE}.bngsim", mock_bngsim), + patch(f"{BRIDGE}._write_bngsim_results"), + patch(f"{BRIDGE}._make_bng_result", return_value=MagicMock()), + ): + execute_bngsim_direct_job( + _network_job(method="psa", options={"poplevel": 250.0}) + ) + + mock_bngsim.Simulator.assert_called_once_with(model, method="psa", poplevel=250.0) + + +@pytest.mark.parametrize( + ("fmt", "loader"), + [ + (FORMAT_SBML, "from_sbml"), + (FORMAT_ANTIMONY, "from_antimony"), + ], +) +def test_sbml_and_antimony_jobs_load_mocked_bngsim_models(fmt, loader): + mock_bngsim = MagicMock() + model = MagicMock() + getattr(mock_bngsim.Model, loader).return_value = model + mock_bngsim.Simulator.return_value.run.return_value = _mock_result() + + with ( + patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), + patch(f"{BRIDGE}.bngsim", mock_bngsim), + patch(f"{BRIDGE}._write_bngsim_results"), + patch(f"{BRIDGE}._make_bng_result", return_value=MagicMock()), + ): + execute_bngsim_direct_job(_network_job(fmt=fmt, method="ode")) + + getattr(mock_bngsim.Model, loader).assert_called_once() + mock_bngsim.Simulator.assert_called_once_with(model, method="ode") + + +def test_bng_xml_job_uses_nfsim_session_for_nf_method_only(): + mock_bngsim = MagicMock() + session = MagicMock() + session.simulate.return_value = _mock_result() + mock_bngsim.NfsimSession.return_value.__enter__.return_value = session + + job = BngsimDirectJob( + input_path="/model.xml", + input_format=FORMAT_BNG_XML, + method="nf", + t_span=(0.0, 10.0), + n_points=11, + output_dir="/tmp/out", + output_root="model", + bngsim_options={"seed": 7, "gml": 1000}, + ) + + with ( + patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), + patch(f"{BRIDGE}.BNGSIM_HAS_NFSIM", True), + patch(f"{BRIDGE}.bngsim", mock_bngsim), + patch(f"{BRIDGE}._write_bngsim_results"), + patch(f"{BRIDGE}._make_bng_result", return_value=MagicMock()), + ): + execute_bngsim_direct_job(job) + + # execute_bngsim_direct_job abspaths input_path (Windows: D:\model.xml). + mock_bngsim.NfsimSession.assert_called_once_with( + os.path.abspath("/model.xml"), molecule_limit=1000 + ) + session.initialize.assert_called_once_with(7) + session.simulate.assert_called_once_with(0.0, 10.0, 11) + mock_bngsim.Simulator.assert_not_called() + + bad_job = BngsimDirectJob( + input_path="/model.xml", + input_format=FORMAT_BNG_XML, + method="ode", + t_span=(0.0, 10.0), + n_points=11, + output_dir="/tmp/out", + output_root="model", + ) + mock_bngsim.NfsimSession.reset_mock() + + with ( + patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), + patch(f"{BRIDGE}.BNGSIM_HAS_NFSIM", True), + patch(f"{BRIDGE}.bngsim", mock_bngsim), + ): + with pytest.raises(BNGSimError, match="network-free simulation"): + execute_bngsim_direct_job(bad_job) + + mock_bngsim.NfsimSession.assert_not_called() + + +def test_bng_xml_rm_job_writes_final_state_and_replays_conc(): + """rm multi-segment continuation regression. + + ``method=>"rm"`` with ``get_final_state=>1`` must call the + RuleMonkeySession's ``save_species`` so BNG2.pl's ``readNFspecies`` + can continue across segments, and setConcentration/addConcentration + must replay via ``set_species_count`` / ``add_species`` — both rely on + the bngsim >= 0.9.2 RuleMonkeySession surface and were previously + dropped by ``_run_rulemonkey_job``. + """ + mock_bngsim = MagicMock() + session = MagicMock() + session.simulate.return_value = _mock_result() + mock_bngsim.RuleMonkeySession.return_value.__enter__.return_value = session + + job = BngsimDirectJob( + input_path="/model.xml", + input_format=FORMAT_BNG_XML, + method="rm", + t_span=(0.0, 10.0), + n_points=11, + output_dir="/tmp/out", + output_root="model", + bngsim_options={ + "seed": 7, + "gml": 1000, + "conc_overrides": {"A()": 5}, + "conc_deltas": {"B()": 3}, + }, + get_final_state=True, + ) + + with ( + patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), + patch(f"{BRIDGE}.BNGSIM_HAS_RULEMONKEY", True), + patch(f"{BRIDGE}.bngsim", mock_bngsim), + patch(f"{BRIDGE}._write_bngsim_results"), + patch(f"{BRIDGE}._make_bng_result", return_value=MagicMock()), + ): + execute_bngsim_direct_job(job) + + mock_bngsim.RuleMonkeySession.assert_called_once_with( + os.path.abspath("/model.xml"), molecule_limit=1000 + ) + session.initialize.assert_called_once_with(7) + session.simulate.assert_called_once_with(0.0, 10.0, 11) + # get_final_state writeback -> .species (was missing for rm) + assert session.save_species.call_count == 1 + assert session.save_species.call_args[0][0].endswith("model.species") + # concentration replay (was dropped for rm) + session.set_species_count.assert_any_call("A()", 5) + session.add_species.assert_any_call("B()", 3) + mock_bngsim.NfsimSession.assert_not_called() + + +def test_direct_job_writer_creates_gdat_and_cdat_files(): + mock_bngsim = MagicMock() + mock_bngsim.Model.from_net.return_value = MagicMock() + mock_bngsim.Simulator.return_value.run.return_value = _mock_result() + + with tempfile.TemporaryDirectory() as tmpdir: + job = _network_job(method="ode", output_dir=tmpdir) + with ( + patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), + patch(f"{BRIDGE}.bngsim", mock_bngsim), + patch(f"{BRIDGE}._make_bng_result", return_value=MagicMock()), + ): + execute_bngsim_direct_job(job) + + assert os.path.isfile(os.path.join(tmpdir, "model.gdat")) + assert os.path.isfile(os.path.join(tmpdir, "model.cdat")) diff --git a/tests/test_bngsim_method_defaults.py b/tests/test_bngsim_method_defaults.py new file mode 100644 index 00000000..9aaf5e2e --- /dev/null +++ b/tests/test_bngsim_method_defaults.py @@ -0,0 +1,181 @@ +import tempfile +from unittest.mock import MagicMock, patch + +import pytest + +BRIDGE = "bionetgen.core.tools.bngsim_bridge" + + +class TestLibraryMethodDefaults: + def test_bngl_run_passes_no_method_override_by_default(self): + from bionetgen.core.tools.bngsim_bridge import ( + ROUTE_BNGL_BNGSIM, + BngsimRouteDecision, + ) + from bionetgen.modelapi.runner import run + + sentinel = object() + + with ( + patch(f"{BRIDGE}.detect_input_format", return_value="bngl"), + patch( + f"{BRIDGE}.classify_bngsim_route", + return_value=BngsimRouteDecision(ROUTE_BNGL_BNGSIM, "atomic BNGL"), + ), + patch(f"{BRIDGE}.run_bngl_with_bngsim", return_value=sentinel) as mock_run, + ): + result = run("model.bngl", out="/tmp/out") + + assert result is sentinel + assert mock_run.call_args.kwargs["method"] is None + + def test_bngl_run_passes_explicit_method_override(self): + from bionetgen.core.tools.bngsim_bridge import ( + ROUTE_BNGL_BNGSIM, + BngsimRouteDecision, + ) + from bionetgen.modelapi.runner import run + + sentinel = object() + + with ( + patch(f"{BRIDGE}.detect_input_format", return_value="bngl"), + patch( + f"{BRIDGE}.classify_bngsim_route", + return_value=BngsimRouteDecision(ROUTE_BNGL_BNGSIM, "atomic BNGL"), + ), + patch(f"{BRIDGE}.run_bngl_with_bngsim", return_value=sentinel) as mock_run, + ): + result = run("model.bngl", out="/tmp/out", method="ode") + + assert result is sentinel + assert mock_run.call_args.kwargs["method"] == "ode" + + +class TestCliMethodDefaults: + def test_cli_run_passes_no_method_override_by_default(self): + from bionetgen.core.tools.bngsim_bridge import ( + ROUTE_BNGL_BNGSIM, + BngsimRouteDecision, + ) + from bionetgen.main import BioNetGenTest + + with ( + patch(f"{BRIDGE}.detect_input_format", return_value="bngl"), + patch( + f"{BRIDGE}.classify_bngsim_route", + return_value=BngsimRouteDecision(ROUTE_BNGL_BNGSIM, "atomic BNGL"), + ), + patch( + f"{BRIDGE}.run_bngl_with_bngsim", return_value=MagicMock() + ) as mock_run, + patch("bionetgen.main.test_perl"), + ): + with BioNetGenTest( + argv=["run", "-i", "model.bngl", "-o", "/tmp/out"] + ) as app: + app.run() + + assert mock_run.call_args.kwargs["method"] is None + assert mock_run.call_args.kwargs["timeout"] is None + + def test_cli_run_passes_explicit_method_override(self): + from bionetgen.core.tools.bngsim_bridge import ( + ROUTE_BNGL_BNGSIM, + BngsimRouteDecision, + ) + from bionetgen.main import BioNetGenTest + + with ( + patch(f"{BRIDGE}.detect_input_format", return_value="bngl"), + patch( + f"{BRIDGE}.classify_bngsim_route", + return_value=BngsimRouteDecision(ROUTE_BNGL_BNGSIM, "atomic BNGL"), + ), + patch( + f"{BRIDGE}.run_bngl_with_bngsim", return_value=MagicMock() + ) as mock_run, + patch("bionetgen.main.test_perl"), + ): + with BioNetGenTest( + argv=["run", "-i", "model.bngl", "-o", "/tmp/out", "--method", "ode"] + ) as app: + app.run() + + assert mock_run.call_args.kwargs["method"] == "ode" + + def test_cli_run_passes_timeout_override(self): + from bionetgen.core.tools.bngsim_bridge import ( + ROUTE_BNGL_BNGSIM, + BngsimRouteDecision, + ) + from bionetgen.main import BioNetGenTest + + with ( + patch(f"{BRIDGE}.detect_input_format", return_value="bngl"), + patch( + f"{BRIDGE}.classify_bngsim_route", + return_value=BngsimRouteDecision(ROUTE_BNGL_BNGSIM, "atomic BNGL"), + ), + patch( + f"{BRIDGE}.run_bngl_with_bngsim", return_value=MagicMock() + ) as mock_run, + patch("bionetgen.main.test_perl"), + ): + with BioNetGenTest( + argv=["run", "-i", "model.bngl", "-o", "/tmp/out", "--timeout", "17"] + ) as app: + app.run() + + assert mock_run.call_args.kwargs["timeout"] == 17 + + +class TestDirectInputMethodDefaults: + def test_direct_net_input_defaults_to_ode(self): + from bionetgen.core.tools.bngsim_bridge import run_with_bngsim + + mock_bngsim = MagicMock() + mock_model = MagicMock() + mock_bngsim.Model.from_net.return_value = mock_model + mock_sim = MagicMock() + mock_sim.run.return_value = MagicMock( + to_cdat=MagicMock(), + observable_names=[], + n_observables=0, + n_times=2, + ) + mock_bngsim.Simulator.return_value = mock_sim + + with ( + patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), + patch(f"{BRIDGE}.bngsim", mock_bngsim), + patch(f"{BRIDGE}._make_bng_result", return_value=MagicMock()), + tempfile.TemporaryDirectory() as tmpdir, + ): + run_with_bngsim("/model.net", tmpdir, fmt="net", method=None) + + mock_bngsim.Simulator.assert_called_once_with(mock_model, method="ode") + + def test_direct_bng_xml_input_defaults_to_nf(self): + from bionetgen.core.tools.bngsim_bridge import run_with_bngsim + + sentinel = object() + + with ( + patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), + patch(f"{BRIDGE}.run_nfsim", return_value=sentinel) as mock_run, + ): + result = run_with_bngsim( + "/model.xml", "/tmp/out", fmt="bng-xml", method=None + ) + + assert result is sentinel + mock_run.assert_called_once() + + def test_direct_bng_xml_input_rejects_ode_override(self): + from bionetgen.core.exc import BNGSimError + from bionetgen.core.tools.bngsim_bridge import run_with_bngsim + + with patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True): + with pytest.raises(BNGSimError, match="network-free simulation"): + run_with_bngsim("/model.xml", "/tmp/out", fmt="bng-xml", method="ode") diff --git a/tests/test_bngsim_parameter_scan_driver.py b/tests/test_bngsim_parameter_scan_driver.py new file mode 100644 index 00000000..a1f0fe4c --- /dev/null +++ b/tests/test_bngsim_parameter_scan_driver.py @@ -0,0 +1,744 @@ +"""Tests for the in-process BNGsim parameter_scan fast-path driver. + +These cover the pure (non-BNGsim) machinery: scan-value spacing, action +detection / fallback triggers, and ``.scan`` file formatting. The +end-to-end in-process driver is exercised by the parity sweeps. +""" + +import logging + +import pytest + +from bionetgen.core.tools.bngsim_bridge import BNGSIM_AVAILABLE +from bionetgen.core.tools.bngsim_parameter_scan import ( + ScanRequest, + detect_inprocess_scan, + scan_values, + _write_scan_file, + _write_bifurcation_file, +) + + +class _Action: + """Minimal stand-in for a parsed BNGL action.""" + + def __init__(self, action_type, action_args=None): + self.type = action_type + self.name = action_type + self.args = action_args or {} + + +def _make_request(**overrides): + """Build a ScanRequest with sensible defaults for the spacing tests.""" + fields = dict( + action="parameter_scan", + parameter="k", + par_min=0.0, + par_max=100.0, + n_scan_pts=5, + log_scale=False, + method="ode", + t_start=0.0, + t_end=1.0, + n_steps=1, + suffix=None, + prefix=None, + reset_conc=True, + seed=None, + atol=None, + rtol=None, + print_cdat=True, + print_functions=False, + ) + fields.update(overrides) + return ScanRequest(**fields) + + +def _scan_action(**overrides): + args = { + "parameter": '"k1"', + "par_min": "0.1", + "par_max": "10", + "n_scan_pts": "5", + "log_scale": "1", + "method": '"ode"', + "t_start": "0", + "t_end": "100", + "n_steps": "10", + } + args.update(overrides) + return _Action("parameter_scan", args) + + +def _valid_sequence(**scan_overrides): + return [ + _Action("generate_network", {"overwrite": "1"}), + _scan_action(**scan_overrides), + ] + + +def _bifurcate_action(**overrides): + args = { + "parameter": '"k1"', + "par_min": "1.0", + "par_max": "100.0", + "n_scan_pts": "10", + "log_scale": "1", + "method": '"ode"', + "t_start": "0", + "t_end": "100", + "n_steps": "10", + } + args.update(overrides) + return _Action("bifurcate", args) + + +def _valid_bifurcate_sequence(**overrides): + return [ + _Action("generate_network", {"overwrite": "1"}), + _bifurcate_action(**overrides), + ] + + +# ─── scan_values spacing ─────────────────────────────────────────── + + +def test_scan_values_linear_spacing_inclusive_endpoints(): + vals = scan_values(_make_request(par_min=0.0, par_max=100.0, n_scan_pts=5)) + assert vals == pytest.approx([0.0, 25.0, 50.0, 75.0, 100.0]) + + +def test_scan_values_log_spacing_is_geometric_and_inclusive(): + vals = scan_values( + _make_request( + par_min=1e-3, + par_max=1e3, + n_scan_pts=7, + log_scale=True, + ) + ) + assert vals[0] == pytest.approx(1e-3) + assert vals[-1] == pytest.approx(1e3) + # geometric: a constant ratio between successive points + ratios = [vals[i + 1] / vals[i] for i in range(len(vals) - 1)] + assert ratios == pytest.approx([10.0] * 6) + + +def test_scan_values_single_point(): + assert scan_values(_make_request(par_min=5.0, par_max=5.0, n_scan_pts=1)) == [5.0] + + +def test_scan_values_backward_pass_is_reversed_forward(): + # bifurcate's backward pass swaps par_min/par_max -> its value list is + # exactly the forward list reversed (log or linear). + fwd = scan_values( + _make_request(par_min=1.0, par_max=1e2, n_scan_pts=10, log_scale=True) + ) + bwd = scan_values( + _make_request(par_min=1e2, par_max=1.0, n_scan_pts=10, log_scale=True) + ) + assert bwd == pytest.approx(list(reversed(fwd))) + + +# ─── detect_inprocess_scan: accept ───────────────────────────────── + + +def test_detect_accepts_generate_network_plus_ode_scan(): + req = detect_inprocess_scan(_valid_sequence()) + assert req is not None + assert req.parameter == "k1" + assert req.par_min == 0.1 and req.par_max == 10.0 + assert req.n_scan_pts == 5 + assert req.log_scale is True + assert req.t_start == 0.0 and req.t_end == 100.0 + assert req.n_steps == 10 + + +def test_detect_accepts_setparameter_preamble(): + seq = [ + _Action("setParameter", {"name": '"k2"', "value": "3"}), + _Action("generate_network", {"overwrite": "1"}), + _scan_action(), + ] + assert detect_inprocess_scan(seq) is not None + + +def test_detect_accepts_cvode_method_and_absent_method(): + assert detect_inprocess_scan(_valid_sequence(method='"cvode"')) is not None + seq = _valid_sequence() + del seq[-1].args["method"] + assert detect_inprocess_scan(seq) is not None + + +def test_detect_parses_optional_options(): + req = detect_inprocess_scan( + _valid_sequence( + suffix='"scn"', + prefix='"pfx"', + atol="1e-9", + rtol="1e-7", + reset_conc="1", + print_CDAT="0", + ) + ) + assert req.suffix == "scn" and req.prefix == "pfx" + assert req.atol == 1e-9 and req.rtol == 1e-7 + assert req.print_cdat is False + assert req.action == "parameter_scan" and req.method == "ode" + + +def test_detect_accepts_ssa_method_and_seed(): + req = detect_inprocess_scan(_valid_sequence(method='"ssa"', seed="17")) + assert req is not None + assert req.method == "ssa" + assert req.seed == 17 + # absent seed -> None (the driver then uses BNGsim's default) + assert detect_inprocess_scan(_valid_sequence(method='"ssa"')).seed is None + + +def test_detect_accepts_bifurcate(): + req = detect_inprocess_scan(_valid_bifurcate_sequence()) + assert req is not None + assert req.action == "bifurcate" + # bifurcate always carries the prior point's end state + assert req.reset_conc is False + + +def test_detect_bifurcate_ignores_reset_conc_arg(): + # BNG2.pl forces reset_conc=>0 for bifurcate regardless of any value + # the user wrote. + req = detect_inprocess_scan(_valid_bifurcate_sequence(reset_conc="1")) + assert req is not None and req.reset_conc is False + + +def test_detect_accepts_ssa_bifurcate(): + req = detect_inprocess_scan(_valid_bifurcate_sequence(method='"ssa"')) + assert req is not None and req.action == "bifurcate" and req.method == "ssa" + + +def test_detect_declines_bifurcate_with_steady_state(): + # ExampleModel4_v6's bifurcate carries steady_state=>1 (bngsim #47). + assert detect_inprocess_scan(_valid_bifurcate_sequence(steady_state="1")) is None + + +def test_detect_declines_scan_plus_bifurcate(): + # exactly one workflow action is allowed + seq = [ + _Action("generate_network", {"overwrite": "1"}), + _scan_action(), + _bifurcate_action(), + ] + assert detect_inprocess_scan(seq) is None + + +# ─── detect_inprocess_scan: decline / fallback ───────────────────── + + +def test_detect_declines_empty_actions(): + assert detect_inprocess_scan(None) is None + assert detect_inprocess_scan([]) is None + + +def test_detect_declines_unsupported_method(): + for method in ('"nf"', '"pla"', '"psa"'): + assert detect_inprocess_scan(_valid_sequence(method=method)) is None + + +def test_detect_declines_par_scan_vals(): + seq = _valid_sequence() + seq[-1].args["par_scan_vals"] = "[1,2,3]" + assert detect_inprocess_scan(seq) is None + + +def test_detect_declines_sample_times(): + seq = _valid_sequence() + seq[-1].args["sample_times"] = "[0,1,2]" + assert detect_inprocess_scan(seq) is None + + +def test_detect_declines_steady_state(): + assert detect_inprocess_scan(_valid_sequence(steady_state="1")) is None + # steady_state=>0 is harmless and does not block the fast path + assert detect_inprocess_scan(_valid_sequence(steady_state="0")) is not None + + +def test_detect_accepts_reset_conc_zero(): + # Phase 2: reset_conc=>0 (carry the prior point's end state) is now + # supported for parameter_scan. + req = detect_inprocess_scan(_valid_sequence(reset_conc="0")) + assert req is not None and req.reset_conc is False + # reset_conc=>1 stays the default-equivalent. + assert detect_inprocess_scan(_valid_sequence(reset_conc="1")).reset_conc is True + + +def test_detect_parses_print_functions(): + # print_functions is honored in-process — BNGL functions go into the + # .gdat/.scan from Result.expressions, as the backend hook already does. + assert ( + detect_inprocess_scan(_valid_sequence(print_functions="1")).print_functions + is True + ) + assert ( + detect_inprocess_scan(_valid_sequence(print_functions="0")).print_functions + is False + ) + # absent => default off, matching BNG2.pl. + assert detect_inprocess_scan(_valid_sequence()).print_functions is False + + +def test_detect_declines_unknown_option(): + seq = _valid_sequence() + seq[-1].args["mystery_option"] = "1" + assert detect_inprocess_scan(seq) is None + + +def test_detect_declines_extra_simulate_action(): + seq = [ + _Action("generate_network", {"overwrite": "1"}), + _Action("simulate", {"method": '"ode"', "t_end": "10"}), + _scan_action(), + ] + assert detect_inprocess_scan(seq) is None + + +def test_detect_declines_scan_not_last(): + seq = [ + _Action("generate_network", {"overwrite": "1"}), + _scan_action(), + _Action("setParameter", {"name": '"k"', "value": "1"}), + ] + assert detect_inprocess_scan(seq) is None + + +def test_detect_declines_missing_generate_network(): + assert detect_inprocess_scan([_scan_action()]) is None + + +def test_detect_declines_generate_network_after_scan(): + seq = [_scan_action(), _Action("generate_network", {"overwrite": "1"})] + assert detect_inprocess_scan(seq) is None + + +def test_detect_declines_multiple_scans(): + seq = [ + _Action("generate_network", {"overwrite": "1"}), + _scan_action(), + _scan_action(), + ] + assert detect_inprocess_scan(seq) is None + + +def test_detect_declines_missing_required_arg(): + seq = _valid_sequence() + del seq[-1].args["t_end"] + assert detect_inprocess_scan(seq) is None + + +def test_detect_declines_log_scale_with_nonpositive_range(): + assert detect_inprocess_scan(_valid_sequence(par_min="0", log_scale="1")) is None + + +def test_detect_ignores_harmless_parallel_options(): + req = detect_inprocess_scan(_valid_sequence(parallel="1", num_cores="4")) + assert req is not None + + +def test_detect_declines_stray_backslash_in_action(): + # PyBioNetGen absorbs the stray "\" before log_scale; BNG2.pl treats + # "\log_scale" as an unrecognized key. The fast path must defer. + malformed = ( + "begin model\nend model\n\n" + "generate_network({overwrite=>1})\n" + 'parameter_scan({parameter=>"RT",par_min=>1e3,par_max=>1e6,\\\n' + 'n_scan_pts=>101,\\log_scale=>1,method=>"ode",\\\n' + "t_start=>0,t_end=>300,n_steps=>31})\n" + ) + assert detect_inprocess_scan(_valid_sequence(), bngl_text=malformed) is None + + +def test_detect_accepts_clean_line_continuations(): + # A backslash that is a genuine end-of-line continuation is fine. + clean = ( + "begin model\nend model\n\n" + "generate_network({overwrite=>1})\n" + 'parameter_scan({parameter=>"k1",par_min=>0.1,par_max=>10,\\\n' + 'n_scan_pts=>5,log_scale=>1,method=>"ode",\\\n' + "t_start=>0,t_end=>100,n_steps=>10})\n" + ) + assert detect_inprocess_scan(_valid_sequence(), bngl_text=clean) is not None + + +# ─── .scan file formatting ───────────────────────────────────────── + + +def test_write_scan_file_matches_bng2_format(tmp_path): + scan_path = tmp_path / "model_k1.scan" + rows = [ + (1.0e-3, [1.234e3, 5.0e1]), + (1.0e3, [9.876e8, 2.0e4]), + ] + _write_scan_file(str(scan_path), "k1", ["obs_a", "obs_b"], rows) + lines = scan_path.read_text().splitlines() + # header: "# " + param right-justified 14, then each obs right-just 16 + assert ( + lines[0] + == "# " + f"{'k1':>14}" + " " + f"{'obs_a':>16}" + " " + f"{'obs_b':>16}" + ) + # data rows: %16.8e fields, single-space separated + assert ( + lines[1] + == f"{1.0e-3:16.8e}" + " " + f"{1.234e3:16.8e}" + " " + f"{5.0e1:16.8e}" + ) + assert len(lines) == 3 + + +def test_write_scan_file_roundtrips_numerically(tmp_path): + scan_path = tmp_path / "rt.scan" + rows = [(float(i), [float(i) * 2, float(i) * 3]) for i in range(4)] + _write_scan_file(str(scan_path), "p", ["x", "y"], rows) + parsed = [ + [float(tok) for tok in ln.split()] + for ln in scan_path.read_text().splitlines() + if not ln.startswith("#") + ] + for (pv, obs), parsed_row in zip(rows, parsed): + assert parsed_row[0] == pytest.approx(pv) + assert parsed_row[1:] == pytest.approx(obs) + + +def test_write_bifurcation_file_matches_bng2_format(tmp_path): + bif_path = tmp_path / "m_bifurcation_A.scan" + # forward column: ascending parameter axis; backward column the same + # axis scanned in reverse (so backward[N-1-i] aligns with forward[i]). + fwd_col = [(1.0, 10.0), (2.0, 20.0), (3.0, 30.0)] + bwd_col = [(3.0, 33.0), (2.0, 22.0), (1.0, 11.0)] + _write_bifurcation_file(str(bif_path), "Kxy", "A", fwd_col, bwd_col) + lines = bif_path.read_text().splitlines() + assert ( + lines[0] + == "# " + f"{'Kxy':>14}" + " " + f"{'A_fwd':>16}" + " " + f"{'A_bwd':>16}" + ) + # row i pairs forward[i] with backward[N-1-i] + assert lines[1] == f"{1.0:16.8e} {10.0:16.8e} {11.0:16.8e}" + assert lines[2] == f"{2.0:16.8e} {20.0:16.8e} {22.0:16.8e}" + assert lines[3] == f"{3.0:16.8e} {30.0:16.8e} {33.0:16.8e}" + assert len(lines) == 4 + + +# ─── end-to-end (BNGsim + BNG2.pl required) ──────────────────────── + +# A tiny model whose scanned parameter (A0_scale) feeds an initial +# concentration through A0 = A0_scale*100 -- exercising the per-point +# set_concentration propagation the in-process driver must do itself. +_TINY_SCAN_MODEL = """\ +begin model +begin parameters + k1 0.5 + A0_scale 10 + A0 A0_scale*100 + kdeg 0.1 +end parameters +begin molecule types + A() + B() +end molecule types +begin seed species + A() A0 + B() 0 +end seed species +begin observables + Molecules A_tot A() + Molecules B_tot B() +end observables +begin reaction rules + A() -> B() k1 + B() -> 0 kdeg +end reaction rules +end model + +generate_network({overwrite=>1}) +%s +""" + +_FAST_SCAN_ACTION = ( + 'parameter_scan({parameter=>"A0_scale",par_min=>1,par_max=>100,' + 'n_scan_pts=>5,log_scale=>1,method=>"ode",t_start=>0,t_end=>20,n_steps=>10})' +) +# par_scan_vals is out of Phase 1 scope -> the detector declines and the +# backend-hook route runs it instead. +_FALLBACK_SCAN_ACTION = ( + 'parameter_scan({parameter=>"A0_scale",par_scan_vals=>[1,10,100],' + 'method=>"ode",t_start=>0,t_end=>20,n_steps=>10})' +) + + +# A model with a functions block, scanned with print_functions=>1: the +# .gdat/.scan must carry the two BNGL function columns after the two +# observables. +_FUNC_SCAN_MODEL = """\ +begin model +begin parameters + k1 0.5 + A0_scale 10 + A0 A0_scale*100 + kdeg 0.1 +end parameters +begin molecule types + A() + B() +end molecule types +begin seed species + A() A0 + B() 0 +end seed species +begin observables + Molecules A_tot A() + Molecules B_tot B() +end observables +begin functions + frac_B() B_tot/(A_tot+B_tot+1) + total_AB() A_tot+B_tot +end functions +begin reaction rules + A() -> B() k1 + B() -> 0 kdeg +end reaction rules +end model + +generate_network({overwrite=>1}) +parameter_scan({parameter=>"A0_scale",par_min=>1,par_max=>100,\ +n_scan_pts=>5,log_scale=>1,method=>"ode",t_start=>0,t_end=>20,\ +n_steps=>10,print_functions=>1}) +""" + + +def _scan_header(path): + for ln in open(path): + if ln.startswith("#"): + return ln.lstrip("#").split() + return [] + + +def _load_scan(path): + import numpy as np + + return np.array( + [ + [float(tok) for tok in ln.split()] + for ln in open(path) + if ln.strip() and not ln.startswith("#") + ] + ) + + +@pytest.mark.skipif(not BNGSIM_AVAILABLE, reason="BNGsim not installed") +def test_fast_path_runs_in_process_and_matches_subprocess(tmp_path, caplog): + import bionetgen + + model = tmp_path / "tiny.bngl" + model.write_text(_TINY_SCAN_MODEL % _FAST_SCAN_ACTION) + + fast_out = tmp_path / "fast" + ref_out = tmp_path / "ref" + with caplog.at_level(logging.INFO, logger="bionetgen.bngsim_bridge"): + bionetgen.run(str(model), out=str(fast_out)) + bionetgen.run(str(model), out=str(ref_out), simulator="subprocess") + + # the in-process fast path was taken (not the backend-hook route) + assert any("fast path" in rec.message for rec in caplog.records) + + scan_name = "tiny_A0_scale.scan" + fast = _load_scan(fast_out / scan_name) + ref = _load_scan(ref_out / scan_name) + assert fast.shape == ref.shape == (5, 3) + denom = (abs(fast) + abs(ref)).clip(min=1e-12) + assert (abs(fast - ref) / denom).max() < 1e-4 + + # per-point .gdat artifacts exist under the scan working directory + work = fast_out / "tiny_A0_scale" + gdats = sorted(p.name for p in work.iterdir() if p.suffix == ".gdat") + assert len(gdats) == 5 + + +@pytest.mark.skipif(not BNGSIM_AVAILABLE, reason="BNGsim not installed") +def test_print_functions_scan_includes_function_columns(tmp_path, caplog): + import bionetgen + + model = tmp_path / "funcs.bngl" + model.write_text(_FUNC_SCAN_MODEL) + + fast_out = tmp_path / "fast" + ref_out = tmp_path / "ref" + with caplog.at_level(logging.INFO, logger="bionetgen.bngsim_bridge"): + bionetgen.run(str(model), out=str(fast_out)) + bionetgen.run(str(model), out=str(ref_out), simulator="subprocess") + + # print_functions no longer declines the fast path. + assert any("fast path" in rec.message for rec in caplog.records) + + scan_name = "funcs_A0_scale.scan" + # the .scan carries both observables and both BNGL functions, in the + # same order and with the same column names as BNG2.pl. + fast_header = _scan_header(fast_out / scan_name) + assert fast_header == _scan_header(ref_out / scan_name) + assert fast_header == [ + "A0_scale", + "A_tot", + "B_tot", + "frac_B", + "total_AB", + ] + + fast = _load_scan(fast_out / scan_name) + ref = _load_scan(ref_out / scan_name) + assert fast.shape == ref.shape == (5, 5) + denom = (abs(fast) + abs(ref)).clip(min=1e-12) + assert (abs(fast - ref) / denom).max() < 1e-4 + + # the per-point .gdat carries the function columns too + # (time + 2 observables + 2 functions). + work = fast_out / "funcs_A0_scale" + gdat = sorted(p for p in work.iterdir() if p.suffix == ".gdat")[0] + assert _scan_header(gdat) == [ + "time", + "A_tot", + "B_tot", + "frac_B", + "total_AB", + ] + + +@pytest.mark.skipif(not BNGSIM_AVAILABLE, reason="BNGsim not installed") +def test_unsupported_scan_option_falls_back_and_still_runs(tmp_path): + import bionetgen + + model = tmp_path / "tiny_fb.bngl" + model.write_text(_TINY_SCAN_MODEL % _FALLBACK_SCAN_ACTION) + + fb_out = tmp_path / "fb" + ref_out = tmp_path / "fb_ref" + bionetgen.run(str(model), out=str(fb_out)) + bionetgen.run(str(model), out=str(ref_out), simulator="subprocess") + + scan_name = "tiny_fb_A0_scale.scan" + assert (fb_out / scan_name).is_file() + fb = _load_scan(fb_out / scan_name) + ref = _load_scan(ref_out / scan_name) + assert fb.shape == ref.shape == (3, 3) + denom = (abs(fb) + abs(ref)).clip(min=1e-12) + assert (abs(fb - ref) / denom).max() < 1e-4 + + +# ─── Phase 2: ssa parameter_scan ─────────────────────────────────── + +# An ssa scan of A0_scale (feeds the A() initial count via A0 = +# A0_scale*100): the log-spaced scan points yield fractional A0 values, +# which the driver must round to integers for SSA (bngsim issue #43). +_SSA_SCAN_ACTION = ( + 'parameter_scan({parameter=>"A0_scale",par_min=>1,par_max=>100,' + 'n_scan_pts=>5,log_scale=>1,method=>"ssa",t_start=>0,t_end=>20,' + "n_steps=>10,seed=>1234})" +) + + +@pytest.mark.skipif(not BNGSIM_AVAILABLE, reason="BNGsim not installed") +def test_ssa_scan_fast_path_is_taken_and_reproducible(tmp_path, caplog): + import bionetgen + + model = tmp_path / "tinyssa.bngl" + model.write_text(_TINY_SCAN_MODEL % _SSA_SCAN_ACTION) + + out_a = tmp_path / "a" + out_b = tmp_path / "b" + with caplog.at_level(logging.INFO, logger="bionetgen.bngsim_bridge"): + bionetgen.run(str(model), out=str(out_a)) + bionetgen.run(str(model), out=str(out_b)) + + # the in-process fast path ran the ssa scan + assert any( + "fast path" in rec.message and "ssa" in rec.message for rec in caplog.records + ) + + scan_name = "tinyssa_A0_scale.scan" + a = _load_scan(out_a / scan_name) + b = _load_scan(out_b / scan_name) + assert a.shape == b.shape == (5, 3) + # same seed -> byte-reproducible across runs + assert (a == b).all() + # SSA observables are molecule counts: integer-valued (the parameter + # column may be fractional from the log spacing). + counts = a[:, 1:] + assert (counts == counts.round()).all() + + # per-point .gdat artifacts exist + work = out_a / "tinyssa_A0_scale" + gdats = [p for p in work.iterdir() if p.suffix == ".gdat"] + assert len(gdats) == 5 + + +# ─── Phase 2: bifurcate ──────────────────────────────────────────── + +# A reversible A<->B model that does not fully equilibrate within +# t_end, so reset_conc=>0 carry-over genuinely matters (forward and +# backward passes differ). bifurcate scans the rate constant k1. +_BIFURCATE_MODEL = """\ +begin model +begin parameters + k1 1.0 + ktot 100 +end parameters +begin molecule types + A() + B() +end molecule types +begin seed species + A() ktot + B() 0 +end seed species +begin observables + Molecules A_tot A() + Molecules B_tot B() +end observables +begin reaction rules + A() <-> B() k1, k1 +end reaction rules +end model + +generate_network({overwrite=>1}) +bifurcate({parameter=>"k1",par_min=>0.1,par_max=>10,n_scan_pts=>8,\ +log_scale=>1,method=>"ode",t_start=>0,t_end=>5,n_steps=>5}) +""" + + +@pytest.mark.skipif(not BNGSIM_AVAILABLE, reason="BNGsim not installed") +def test_bifurcate_fast_path_runs_and_matches_subprocess(tmp_path, caplog): + import bionetgen + + model = tmp_path / "bif.bngl" + model.write_text(_BIFURCATE_MODEL) + + fast_out = tmp_path / "fast" + ref_out = tmp_path / "ref" + with caplog.at_level(logging.INFO, logger="bionetgen.bngsim_bridge"): + bionetgen.run(str(model), out=str(fast_out)) + bionetgen.run(str(model), out=str(ref_out), simulator="subprocess") + + assert any("bifurcate fast path" in rec.message for rec in caplog.records) + + # one _bifurcation_.scan per observable, matching subprocess + for obs in ("A_tot", "B_tot"): + name = f"bif_bifurcation_{obs}.scan" + fast = _load_scan(fast_out / name) + ref = _load_scan(ref_out / name) + assert fast.shape == ref.shape == (8, 3) + denom = (abs(fast) + abs(ref)).clip(min=1e-12) + assert (abs(fast - ref) / denom).max() < 1e-4 + + # per-point .gdat artifacts under the forward/backward workdirs + for sub in ("bif_forward", "bif_backward"): + gdats = [p for p in (fast_out / sub).iterdir() if p.suffix == ".gdat"] + assert len(gdats) == 8 + + # the intermediate forward/backward .scan files are not left behind + assert not (fast_out / "bif_forward.scan").exists() + assert not (fast_out / "bif_backward.scan").exists() diff --git a/tests/test_bngsim_persistent_helper.py b/tests/test_bngsim_persistent_helper.py new file mode 100644 index 00000000..67d23bb1 --- /dev/null +++ b/tests/test_bngsim_persistent_helper.py @@ -0,0 +1,163 @@ +"""Tests for the persistent BNGsim backend helper (serve mode). + +The backend hook otherwise spawns a fresh Python process per atomic job, +paying ``import bngsim`` every time -- which dominates a parameter_scan (one +job per scan point). ``serve`` mode runs one long-lived helper for a whole +BNG2.pl run; ``BNGCLI`` starts it and advertises a Unix-domain socket, and +the hook falls back to the one-shot path if the socket is unavailable. +""" + +import json +import os +import shutil +import socket +import tempfile +import threading +import time + +import pytest + +import bionetgen.core.tools.bngsim_backend_helper as helper + + +def _wait_for_socket(path, timeout=5.0): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if os.path.exists(path): + try: + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as probe: + probe.connect(path) + return True + except OSError: + pass + time.sleep(0.02) + return False + + +def _request(path, message, read_reply=True): + """Send one newline-terminated request; optionally read the reply line.""" + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock: + sock.settimeout(5) + sock.connect(path) + sock.sendall((message + "\n").encode("utf-8")) + if not read_reply: + return None + return sock.makefile("r", encoding="utf-8").readline() + + +@pytest.fixture +def serve_thread(): + """Run ``serve`` on a background thread; yields (socket_path, thread). + + The socket lives under a short temp dir -- AF_UNIX paths are length + limited (~104 chars) and pytest's tmp_path is too long on macOS. + """ + if os.name != "posix": + # serve uses an AF_UNIX socket and BNGCLI only starts the persistent + # helper on POSIX (it returns early on Windows, falling back to the + # one-shot per-job path). Nothing to test off POSIX. + pytest.skip("persistent helper (AF_UNIX serve) is POSIX-only") + base = "/tmp" if os.path.isdir("/tmp") else None + sock_dir = tempfile.mkdtemp(prefix="bngsh-test-", dir=base) + sock_path = os.path.join(sock_dir, "h.sock") + thread = threading.Thread(target=helper.serve, args=(sock_path,), daemon=True) + thread.start() + assert _wait_for_socket(sock_path), "serve socket never became ready" + try: + yield sock_path, thread + finally: + if thread.is_alive(): + try: + _request(sock_path, helper.SHUTDOWN_REQUEST, read_reply=False) + except OSError: + pass + thread.join(timeout=5) + shutil.rmtree(sock_dir, ignore_errors=True) + + +def test_serve_dispatches_multiple_jobs_in_one_process(serve_thread, monkeypatch): + """One serve process handles every job -- the whole point of the mode.""" + sock_path, thread = serve_thread + seen = [] + + def fake_run(job_path): + seen.append(job_path) + return {"success": True, "job": job_path} + + monkeypatch.setattr(helper, "_run_job_file", fake_run) + + reply1 = _request(sock_path, "/jobs/point1.json") + reply2 = _request(sock_path, "/jobs/point2.json") + + assert reply1.startswith("OK ") + assert reply2.startswith("OK ") + assert json.loads(reply1[3:])["job"] == "/jobs/point1.json" + # Both jobs were served by the same (still-alive) process. + assert seen == ["/jobs/point1.json", "/jobs/point2.json"] + assert thread.is_alive() + + +def test_serve_reports_failed_job_as_err_without_dying(serve_thread, monkeypatch): + """A job that returns success=False or raises is ERR, not fatal.""" + sock_path, thread = serve_thread + + def fake_run(job_path): + if "boom" in job_path: + raise RuntimeError("job blew up") + return {"success": False, "error": "bngsim said no"} + + monkeypatch.setattr(helper, "_run_job_file", fake_run) + + raised = _request(sock_path, "/jobs/boom.json") + assert raised.startswith("ERR ") + assert "job blew up" in json.loads(raised[4:])["error"] + + # Server survived the failure and still serves the next job. + declined = _request(sock_path, "/jobs/ok.json") + assert declined.startswith("ERR ") + assert json.loads(declined[4:])["error"] == "bngsim said no" + assert thread.is_alive() + + +def test_serve_shutdown_request_stops_the_loop(serve_thread): + sock_path, thread = serve_thread + _request(sock_path, helper.SHUTDOWN_REQUEST, read_reply=False) + thread.join(timeout=5) + assert not thread.is_alive() + # The socket file is cleaned up on exit. + assert not os.path.exists(sock_path) + + +def test_run_job_file_executes_in_the_job_output_directory(tmp_path, monkeypatch): + """serve dispatches through _run_job_file, which chdirs to the job's + output dir so BNGsim writes alongside BNG2.pl's run directory (matching + the one-shot helper, which inherited BNG2.pl's cwd).""" + out_dir = tmp_path / "run_out" + out_dir.mkdir() + job_path = tmp_path / "job.json" + job_path.write_text( + json.dumps( + { + "artifact_path": str(tmp_path / "model.net"), + "artifact_format": "net", + "method": "ode", + "simulation_options": {}, + "output_prefix": str(out_dir / "model"), + } + ) + ) + + cwd_seen = {} + + def fake_exec(payload): + cwd_seen["cwd"] = os.getcwd() + return {"success": True} + + monkeypatch.setattr(helper, "execute_backend_payload", fake_exec) + start_cwd = os.getcwd() + status = helper._run_job_file(str(job_path)) + + assert status == {"success": True} + assert os.path.samefile(cwd_seen["cwd"], out_dir) + # cwd is restored afterwards. + assert os.path.samefile(os.getcwd(), start_cwd) diff --git a/tests/test_bngsim_routing_classifier.py b/tests/test_bngsim_routing_classifier.py new file mode 100644 index 00000000..43d1cd6a --- /dev/null +++ b/tests/test_bngsim_routing_classifier.py @@ -0,0 +1,643 @@ +import os +import textwrap +import time +from unittest.mock import MagicMock, patch + +import pytest + +BRIDGE = "bionetgen.core.tools.bngsim_bridge" + + +class _Action: + def __init__(self, action_type, action_args=None): + self.type = action_type + self.name = action_type + self.args = action_args or {} + + +def _action(action_type, action_args=None): + return _Action(action_type, action_args) + + +def _classify(fmt, actions=None, **kwargs): + from bionetgen.core.tools.bngsim_bridge import classify_bngsim_route + + return classify_bngsim_route( + "model.bngl" if fmt == "bngl" else f"model.{fmt}", + fmt, + bngl_actions=actions, + has_protocol=False, + **kwargs, + ) + + +def _write_minimal_bngl(tmp_path, action_text): + path = tmp_path / "stage3_complex.bngl" + path.write_text( + textwrap.dedent(f"""\ + begin model + begin parameters + k 1 + end parameters + begin molecule types + A() + end molecule types + begin seed species + A() 1 + end seed species + begin observables + Molecules A A() + end observables + begin reaction rules + end reaction rules + {action_text} + end model + """), + encoding="utf-8", + ) + return path + + +COMPLEX_BNGL_ACTION_CASES = [ + pytest.param( + 'setParameter("k", 2)\nsimulate_ode({t_end=>1,n_steps=>1})', + id="setParameter", + ), + pytest.param( + 'setConcentration("A()", 10)\nsimulate_ode({t_end=>1,n_steps=>1})', + id="setConcentration", + ), + pytest.param( + "saveConcentrations()\nresetConcentrations()\nsimulate_ode({t_end=>1,n_steps=>1})", + id="save-reset-concentrations", + ), + pytest.param( + "saveParameters()\nresetParameters()\nsimulate_ode({t_end=>1,n_steps=>1})", + id="save-reset-parameters", + ), + pytest.param( + 'parameter_scan({method=>"ode",parameter=>"k",par_min=>0,par_max=>1,n_scan_pts=>2})', + id="parameter-scan", + ), + pytest.param( + 'bifurcate({method=>"ode",parameter=>"k",par_min=>0,par_max=>1,n_scan_pts=>2})', + id="bifurcate", + ), + pytest.param( + 'parameter_scan({method=>"protocol",parameter=>"k",par_min=>0,par_max=>1,n_scan_pts=>2})', + id="protocol-parameter-scan", + ), + pytest.param( + "writeSBML()\nsimulate_ode({t_end=>1,n_steps=>1})", + id="write-action", + ), +] + + +class TestBngsimRouteClassifier: + def test_no_bngsim_for_bngl_uses_subprocess(self): + from bionetgen.core.tools.bngsim_bridge import ROUTE_SUBPROCESS + + decision = _classify( + "bngl", + simulator="subprocess", + bngsim_available=True, + actions=[_action("simulate_ode")], + ) + + assert decision.route == ROUTE_SUBPROCESS + + def test_bngsim_unavailable_for_bngl_uses_subprocess(self): + from bionetgen.core.tools.bngsim_bridge import ROUTE_SUBPROCESS + + decision = _classify( + "bngl", + simulator="auto", + bngsim_available=False, + actions=[_action("simulate_ode")], + ) + + assert decision.route == ROUTE_SUBPROCESS + + @pytest.mark.parametrize("fmt", ["net", "sbml", "antimony"]) + def test_direct_formats_use_bngsim_when_available(self, fmt): + from bionetgen.core.tools.bngsim_bridge import ROUTE_DIRECT_BNGSIM + + decision = _classify(fmt, simulator="auto", bngsim_available=True) + + assert decision.route == ROUTE_DIRECT_BNGSIM + + def test_direct_net_pla_uses_subprocess(self): + from bionetgen.core.tools.bngsim_bridge import ROUTE_SUBPROCESS + + decision = _classify( + "net", + simulator="auto", + bngsim_available=True, + method="pla", + ) + + assert decision.route == ROUTE_SUBPROCESS + assert decision.method == "pla" + + @pytest.mark.parametrize("fmt", ["sbml", "antimony"]) + def test_direct_required_formats_reject_pla(self, fmt): + from bionetgen.core.tools.bngsim_bridge import ROUTE_ERROR + + decision = _classify( + fmt, + simulator="auto", + bngsim_available=True, + method="pla", + ) + + assert decision.route == ROUTE_ERROR + assert decision.method == "pla" + + def test_bng_xml_defaults_to_direct_nf(self): + from bionetgen.core.tools.bngsim_bridge import ROUTE_DIRECT_BNGSIM + + decision = _classify( + "bng-xml", + simulator="auto", + bngsim_available=True, + bngsim_has_nfsim=True, + ) + + assert decision.route == ROUTE_DIRECT_BNGSIM + assert decision.method == "nf" + + def test_bng_xml_without_nfsim_support_uses_subprocess(self): + from bionetgen.core.tools.bngsim_bridge import ROUTE_SUBPROCESS + + decision = _classify( + "bng-xml", + simulator="auto", + bngsim_available=True, + bngsim_has_nfsim=False, + ) + + assert decision.route == ROUTE_SUBPROCESS + assert decision.method == "nf" + + @pytest.mark.parametrize("fmt", ["sbml", "antimony"]) + def test_required_formats_error_without_bngsim(self, fmt): + from bionetgen.core.tools.bngsim_bridge import ROUTE_ERROR + + decision = _classify(fmt, simulator="auto", bngsim_available=False) + + assert decision.route == ROUTE_ERROR + assert "requires BNGsim" in decision.reason + + @pytest.mark.parametrize( + ("action", "expected_method"), + [ + (_action("simulate_ode"), "ode"), + (_action("simulate_ssa"), "ssa"), + (_action("simulate_psa"), "psa"), + (_action("simulate", {"method": "psa"}), "psa"), + (_action("simulate_ssa", {"poplevel": "100"}), "psa"), + (_action("simulate", {"method": "rm"}), "rm"), + ], + ) + def test_atomic_supported_bngl_methods_use_bngsim(self, action, expected_method): + from bionetgen.core.tools.bngsim_bridge import ROUTE_BNGL_BNGSIM + + decision = _classify( + "bngl", + simulator="auto", + bngsim_available=True, + actions=[action], + ) + + assert decision.route == ROUTE_BNGL_BNGSIM + assert decision.method == expected_method + + def test_bngl_method_override_preserves_legacy_psa_classification(self): + from bionetgen.core.tools.bngsim_bridge import ROUTE_BNGL_BNGSIM + + decision = _classify( + "bngl", + simulator="auto", + bngsim_available=True, + method="ssa", + actions=[_action("simulate_ssa", {"poplevel": "100"})], + ) + + assert decision.route == ROUTE_BNGL_BNGSIM + assert decision.method == "psa" + + @pytest.mark.parametrize( + "action", + [ + _action("simulate_pla"), + _action("simulate", {"method": "pla"}), + ], + ) + def test_bngl_pla_uses_subprocess(self, action): + from bionetgen.core.tools.bngsim_bridge import ROUTE_SUBPROCESS + + decision = _classify( + "bngl", + simulator="auto", + bngsim_available=True, + actions=[action], + ) + + assert decision.route == ROUTE_SUBPROCESS + assert decision.method == "pla" + + def test_bngl_method_override_does_not_pull_pla_into_bngsim(self): + from bionetgen.core.tools.bngsim_bridge import ROUTE_SUBPROCESS + + decision = _classify( + "bngl", + simulator="auto", + bngsim_available=True, + method="ode", + actions=[_action("simulate_pla")], + ) + + assert decision.route == ROUTE_SUBPROCESS + assert decision.method == "pla" + + def test_bngl_without_simulation_actions_uses_subprocess(self): + from bionetgen.core.tools.bngsim_bridge import ROUTE_SUBPROCESS + + decision = _classify( + "bngl", + simulator="auto", + bngsim_available=True, + actions=[], + ) + + assert decision.route == ROUTE_SUBPROCESS + + @pytest.mark.parametrize( + "actions", + [ + [ + _action("setParameter", {'"k"': None, "2": None}), + _action("simulate_ode"), + ], + [ + _action("setConcentration", {'"A()"': None, "10": None}), + _action("simulate_ode"), + ], + [_action("saveConcentrations"), _action("simulate_ode")], + [_action("resetConcentrations"), _action("simulate_ode")], + [_action("saveParameters"), _action("simulate_ode")], + [_action("resetParameters"), _action("simulate_ode")], + [_action("parameter_scan", {"method": "ode"})], + [_action("parameter_scan", {"method": "protocol"})], + [_action("bifurcate", {"method": "ode"})], + [_action("writeSBML"), _action("simulate_ode")], + [_action("simulate_ode", {"prefix": "equil"})], + [_action("simulate_ode", {"suffix": "prod"})], + [_action("simulate_ode", {"continue": "1"})], + [_action("simulate_ode"), _action("simulate_ssa")], + ], + ) + def test_supported_complex_bngl_workflows_use_backend_hook_route(self, actions): + from bionetgen.core.tools.bngsim_bridge import ROUTE_BNGL_BNGSIM + + decision = _classify( + "bngl", + simulator="auto", + bngsim_available=True, + actions=actions, + ) + + assert decision.route == ROUTE_BNGL_BNGSIM + + def test_bngl_method_override_keeps_multi_sim_workflow_on_backend_hook_route(self): + from bionetgen.core.tools.bngsim_bridge import ROUTE_BNGL_BNGSIM + + decision = _classify( + "bngl", + simulator="auto", + bngsim_available=True, + method="ode", + actions=[_action("simulate_ode"), _action("simulate_ssa")], + ) + + assert decision.route == ROUTE_BNGL_BNGSIM + + def test_protocol_blocks_use_backend_hook_route(self): + from bionetgen.core.tools.bngsim_bridge import ( + ROUTE_BNGL_BNGSIM, + classify_bngsim_route, + ) + + decision = classify_bngsim_route( + "model.bngl", + "bngl", + simulator="auto", + bngsim_available=True, + bngl_actions=[_action("simulate_ode")], + has_protocol=True, + ) + + assert decision.route == ROUTE_BNGL_BNGSIM + + +@pytest.mark.parametrize("action_text", COMPLEX_BNGL_ACTION_CASES) +def test_parser_backed_supported_complex_bngl_actions_use_backend_hook_route( + tmp_path, action_text +): + from bionetgen.core.tools.bngsim_bridge import ( + ROUTE_BNGL_BNGSIM, + classify_bngsim_route, + ) + + bngl_path = _write_minimal_bngl(tmp_path, action_text) + + decision = classify_bngsim_route( + str(bngl_path), + "bngl", + simulator="auto", + bngsim_available=True, + bngsim_has_nfsim=True, + ) + + assert decision.route == ROUTE_BNGL_BNGSIM + assert "BNG2.pl" in decision.reason + + +@pytest.mark.parametrize("action_text", COMPLEX_BNGL_ACTION_CASES) +def test_library_complex_bngl_uses_bngsim_route_not_subprocess_classifier( + tmp_path, action_text +): + from bionetgen.modelapi.runner import run + + bngl_path = _write_minimal_bngl(tmp_path, action_text) + out_dir = tmp_path / "out" + sentinel = object() + + with ( + patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), + patch(f"{BRIDGE}.BNGSIM_HAS_NFSIM", True), + patch( + f"{BRIDGE}.run_bngl_with_bngsim", return_value=sentinel + ) as mock_bngsim_run, + patch( + "bionetgen.modelapi.runner.get_conf", return_value={"bngpath": "/fake/bng"} + ), + patch("bionetgen.modelapi.runner.BNGCLI") as mock_bngcli, + ): + result = run(str(bngl_path), out=str(out_dir)) + + assert result is sentinel + mock_bngsim_run.assert_called_once() + assert mock_bngsim_run.call_args.args[:3] == ( + str(bngl_path), + str(out_dir), + "/fake/bng", + ) + mock_bngcli.assert_not_called() + + +def test_run_bngl_with_bngsim_complex_action_uses_backend_hook_without_executor( + tmp_path, +): + from bionetgen.core.tools.bngsim_bridge import run_bngl_with_bngsim + + bngl_path = _write_minimal_bngl( + tmp_path, + 'setParameter("k", 2)\nsimulate_ode({t_end=>1,n_steps=>1})', + ) + sentinel = object() + + with ( + patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), + patch(f"{BRIDGE}.BNGSIM_HAS_NFSIM", True), + patch( + f"{BRIDGE}.run_bngl_with_bngsim_backend_hook", + return_value=sentinel, + ) as mock_hook, + ): + result = run_bngl_with_bngsim( + str(bngl_path), str(tmp_path / "out"), "/fake/bng" + ) + + assert result is sentinel + mock_hook.assert_called_once() + assert mock_hook.call_args.args[:3] == ( + str(bngl_path), + str(tmp_path / "out"), + "/fake/bng", + ) + + +def test_stage6_removed_python_bngl_interpreter_symbols(): + import bionetgen.core.tools.bngsim_bridge as bridge + + removed_symbols = [ + "_execute_bngsim_actions", + "_parse_simulate_params", + "_resolve_sample_times", + "_resolve_scan_points", + "_run_parameter_scan_bngsim", + "_run_bifurcate_bngsim", + "_run_protocol", + "_parse_protocol_block", + "_safe_math_namespace", + "_safe_eval_expr", + "_eval_numeric", + "_normalize_bngl_expr", + "_aliased_keyword_namespace", + "_resolve_bngmodel_params", + "_evaluate_bngmodel_functions", + "_evaluate_functions_per_timepoint", + "_strip_zero_arg_calls", + "_parse_net_species_initializers", + "_sync_species_concentrations", + "_parse_bngmodel_seed_species_initializers", + "_parse_xml_parameter_table", + "_resolve_xml_params", + "_apply_nfsim_derived_params", + "_apply_nfsim_seed_species_initializers", + "_write_scan_file", + "_read_scan_file", + "_scan_result_to_row", + "_parse_table_functions", + "_parse_tfun_args", + "_add_table_functions", + ] + + for name in removed_symbols: + assert not hasattr(bridge, name) + + +def test_run_bngl_with_bngsim_protocol_uses_backend_hook_without_python_parser( + tmp_path, +): + from bionetgen.core.tools.bngsim_bridge import run_bngl_with_bngsim + + bngl_path = _write_minimal_bngl( + tmp_path, + "simulate_ode({t_end=>1,n_steps=>1})", + ) + bngl_path.write_text( + bngl_path.read_text(encoding="utf-8") + + "\nbegin protocol\nsimulate_ode({t_end=>1,n_steps=>1})\nend protocol\n", + encoding="utf-8", + ) + sentinel = object() + + with ( + patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), + patch(f"{BRIDGE}.BNGSIM_HAS_NFSIM", True), + patch( + f"{BRIDGE}.run_bngl_with_bngsim_backend_hook", + return_value=sentinel, + ) as mock_hook, + ): + result = run_bngl_with_bngsim( + str(bngl_path), str(tmp_path / "out"), "/fake/bng" + ) + + assert result is sentinel + mock_hook.assert_called_once() + + +def test_run_bngl_with_bngsim_scan_uses_backend_hook_without_python_scan_outputs( + tmp_path, +): + from bionetgen.core.tools.bngsim_bridge import run_bngl_with_bngsim + + bngl_path = _write_minimal_bngl( + tmp_path, + 'parameter_scan({method=>"ode",parameter=>"k",par_min=>0,par_max=>1,n_scan_pts=>2})', + ) + sentinel = object() + + with ( + patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), + patch(f"{BRIDGE}.BNGSIM_HAS_NFSIM", True), + patch( + f"{BRIDGE}.run_bngl_with_bngsim_backend_hook", + return_value=sentinel, + ) as mock_hook, + ): + result = run_bngl_with_bngsim( + str(bngl_path), str(tmp_path / "out"), "/fake/bng" + ) + + assert result is sentinel + mock_hook.assert_called_once() + assert not list((tmp_path / "out").glob("*.scan")) + + +def test_library_subprocess_route_uses_bngcli(tmp_path): + from bionetgen.core.tools.bngsim_bridge import ( + ROUTE_SUBPROCESS, + BngsimRouteDecision, + ) + from bionetgen.modelapi.runner import run + + sentinel = MagicMock() + mock_cli = MagicMock() + mock_cli.result = sentinel + + with ( + patch(f"{BRIDGE}.detect_input_format", return_value="bngl"), + patch( + f"{BRIDGE}.classify_bngsim_route", + return_value=BngsimRouteDecision(ROUTE_SUBPROCESS, "complex BNGL"), + ), + patch( + "bionetgen.modelapi.runner.get_conf", return_value={"bngpath": "/fake/bng"} + ), + patch("bionetgen.modelapi.runner.BNGCLI", return_value=mock_cli), + ): + result = run("model.bngl", out=str(tmp_path)) + + assert result is sentinel + mock_cli.run.assert_called_once() + + +class TestRoutingActionCache: + """The route-classification action parse is memoized per file identity. + + Routing re-asks for a BNGL's action list ~4 times per ``bionetgen.run`` + (the classifier from ``runner.run`` and again inside + ``run_bngl_with_bngsim``, the in-process-scan detector, the + network-free-method probe). Each uncached parse builds a ``bngmodel`` + that shells out to BNG2.pl — serial timing measured ~1.9 s of redundant + pre-flight per run before this cache existed, which made the BNGsim + route slower than plain subprocess on every model. + """ + + def test_repeated_routing_queries_parse_the_file_once(self, tmp_path): + from bionetgen.core.tools import bngsim_bridge as bridge + + bridge._clear_routing_actions_cache() + bngl = tmp_path / "memo.bngl" + bngl.write_text("generate_network({overwrite=>1})\n", encoding="utf-8") + fake_model = MagicMock() + fake_model.actions.items = [_action("generate_network")] + + with patch( + "bionetgen.modelapi.model.bngmodel", return_value=fake_model + ) as bngmodel: + first = bridge._load_bngl_actions_for_routing(str(bngl)) + second = bridge._load_bngl_actions_for_routing(str(bngl)) + third = bridge._load_bngl_actions_for_routing(str(bngl)) + + assert bngmodel.call_count == 1 + assert first is second is third + assert [a.type for a in first] == ["generate_network"] + + def test_cache_reparses_after_the_file_changes(self, tmp_path): + from bionetgen.core.tools import bngsim_bridge as bridge + + bridge._clear_routing_actions_cache() + bngl = tmp_path / "memo.bngl" + bngl.write_text("generate_network({overwrite=>1})\n", encoding="utf-8") + fake_model = MagicMock() + fake_model.actions.items = [_action("generate_network")] + + with patch( + "bionetgen.modelapi.model.bngmodel", return_value=fake_model + ) as bngmodel: + bridge._load_bngl_actions_for_routing(str(bngl)) + # Edit the file: different size, and a strictly later mtime so + # the change is caught even on coarse-resolution clocks. + bngl.write_text( + 'generate_network({overwrite=>1})\nsimulate({method=>"ode"})\n', + encoding="utf-8", + ) + future = time.time() + 10 + os.utime(bngl, (future, future)) + bridge._load_bngl_actions_for_routing(str(bngl)) + + assert bngmodel.call_count == 2 + + def test_parse_failure_is_cached_not_retried(self, tmp_path): + from bionetgen.core.tools import bngsim_bridge as bridge + + bridge._clear_routing_actions_cache() + bngl = tmp_path / "broken.bngl" + bngl.write_text("not valid bngl\n", encoding="utf-8") + + with patch( + "bionetgen.modelapi.model.bngmodel", side_effect=RuntimeError("boom") + ) as bngmodel: + first = bridge._load_bngl_actions_for_routing(str(bngl)) + second = bridge._load_bngl_actions_for_routing(str(bngl)) + + assert first is None and second is None + assert bngmodel.call_count == 1 + + def test_unstattable_path_parses_without_caching(self): + from bionetgen.core.tools import bngsim_bridge as bridge + + bridge._clear_routing_actions_cache() + with patch( + f"{BRIDGE}._parse_bngl_actions_for_routing", return_value=None + ) as parse: + bridge._load_bngl_actions_for_routing("/no/such/file.bngl") + bridge._load_bngl_actions_for_routing("/no/such/file.bngl") + + assert parse.call_count == 2 diff --git a/tests/test_bngsim_version_guard.py b/tests/test_bngsim_version_guard.py new file mode 100644 index 00000000..88a645bd --- /dev/null +++ b/tests/test_bngsim_version_guard.py @@ -0,0 +1,165 @@ +"""Tests for the bngsim runtime version guard in bngsim_bridge. + +PyBioNetGen treats bngsim as an *optional* dependency by design, but +when bngsim is present it must be at MINIMUM_BNGSIM_VERSION or newer. +The guard sits in `bngsim_bridge` and downgrades `BNGSIM_AVAILABLE` to +False (with a descriptive reason) whenever the installed bngsim is too +old, so the existing fall-back paths (subprocess for the network path, +explicit-error for BNGsim-required formats) fire naturally. + +These tests exercise the module-load logic by reimporting the bridge +with the bngsim module patched into something the version probe will +interpret as "wrong version." We don't touch the real bngsim install. +""" + +import importlib +import sys +import types + +import pytest + +BRIDGE = "bionetgen.core.tools.bngsim_bridge" + + +def _reload_bridge_with_fake_bngsim(monkeypatch, fake_version, attrs=None): + """Install a stub bngsim with __version__=fake_version and reload the + bridge. Returns the freshly-reloaded module.""" + stub = types.ModuleType("bngsim") + stub.__version__ = fake_version + stub.HAS_NFSIM = True + stub.HAS_RULEMONKEY = True + if attrs: + for k, v in attrs.items(): + setattr(stub, k, v) + monkeypatch.setitem(sys.modules, "bngsim", stub) + monkeypatch.delenv("BIONETGEN_NO_BNGSIM", raising=False) + sys.modules.pop(BRIDGE, None) + return importlib.import_module(BRIDGE) + + +def _reload_bridge_without_bngsim(monkeypatch): + monkeypatch.setitem(sys.modules, "bngsim", None) # forces ImportError + sys.modules.pop(BRIDGE, None) + return importlib.import_module(BRIDGE) + + +def _reload_bridge_with_env_disable(monkeypatch): + monkeypatch.setenv("BIONETGEN_NO_BNGSIM", "1") + sys.modules.pop(BRIDGE, None) + return importlib.import_module(BRIDGE) + + +@pytest.fixture(autouse=True) +def _restore_bridge_after_test(): + """Make sure the real bngsim_bridge is restored so other tests see + the real install state.""" + yield + sys.modules.pop(BRIDGE, None) + importlib.import_module(BRIDGE) + + +class TestVersionGuardStates: + def test_too_old_marks_unavailable(self, monkeypatch): + b = _reload_bridge_with_fake_bngsim(monkeypatch, "0.3.0") + assert not b.is_bngsim_available() + reason = b.get_bngsim_unavailable_reason() + assert reason is not None + assert "0.3.0" in reason + assert b.MINIMUM_BNGSIM_VERSION in reason + + def test_minimum_version_available(self, monkeypatch): + b = _reload_bridge_with_fake_bngsim(monkeypatch, "0.6.0") + assert b.is_bngsim_available() + assert b.get_bngsim_unavailable_reason() is None + + def test_newer_version_available(self, monkeypatch): + b = _reload_bridge_with_fake_bngsim(monkeypatch, "1.2.3") + assert b.is_bngsim_available() + + def test_string_compare_does_not_misorder_0_10_vs_0_6(self, monkeypatch): + """Naive string `<` says '0.10.0' < '0.6.0' (lexicographic); + PEP 440 numeric compare says it's greater. The guard must use + the latter. Catches a regression to plain string comparison. + """ + b = _reload_bridge_with_fake_bngsim(monkeypatch, "0.10.0") + assert b.is_bngsim_available() + + def test_not_installed_reason_is_silent_path(self, monkeypatch): + b = _reload_bridge_without_bngsim(monkeypatch) + assert not b.is_bngsim_available() + reason = b.get_bngsim_unavailable_reason() + assert reason == "bngsim is not installed" + + def test_env_disable_marked_with_specific_reason(self, monkeypatch): + b = _reload_bridge_with_env_disable(monkeypatch) + assert not b.is_bngsim_available() + assert "BIONETGEN_NO_BNGSIM" in b.get_bngsim_unavailable_reason() + + +class TestVersionGuardRouting: + """The guard's effect on `classify_bngsim_route` for the two modes + the user can pick.""" + + def test_explicit_bngsim_with_too_old_errors_with_reason(self, monkeypatch): + b = _reload_bridge_with_fake_bngsim(monkeypatch, "0.3.0") + decision = b.classify_bngsim_route( + "model.bngl", "bngl", simulator="bngsim", bngl_actions=[] + ) + assert decision.route == b.ROUTE_ERROR + # The reason must surface in the error message so the user knows + # whether to install vs. upgrade. + assert "0.3.0" in decision.reason + assert b.MINIMUM_BNGSIM_VERSION in decision.reason + + def test_auto_with_too_old_falls_back_silently(self, monkeypatch): + b = _reload_bridge_with_fake_bngsim(monkeypatch, "0.3.0") + decision = b.classify_bngsim_route( + "model.bngl", "bngl", simulator="auto", bngl_actions=[] + ) + assert decision.route == b.ROUTE_SUBPROCESS + + def test_auto_with_too_old_warns_only_once(self, monkeypatch, caplog): + import logging + + b = _reload_bridge_with_fake_bngsim(monkeypatch, "0.3.0") + # Reset the warned flag so this test sees a fresh warning slot. + b._VERSION_FALLBACK_WARNED = False + with caplog.at_level(logging.WARNING, logger="bionetgen.bngsim_bridge"): + b.classify_bngsim_route("a.bngl", "bngl", simulator="auto", bngl_actions=[]) + b.classify_bngsim_route("b.bngl", "bngl", simulator="auto", bngl_actions=[]) + b.classify_bngsim_route("c.bngl", "bngl", simulator="auto", bngl_actions=[]) + warnings = [r for r in caplog.records if r.levelno >= logging.WARNING] + assert len(warnings) == 1 + assert "0.3.0" in warnings[0].getMessage() + + def test_auto_when_not_installed_does_not_warn(self, monkeypatch, caplog): + """The 'not installed' path is the documented optional contract — + users on subprocess-only installs should not see a noisy warning.""" + import logging + + b = _reload_bridge_without_bngsim(monkeypatch) + b._VERSION_FALLBACK_WARNED = False + with caplog.at_level(logging.WARNING, logger="bionetgen.bngsim_bridge"): + b.classify_bngsim_route("a.bngl", "bngl", simulator="auto", bngl_actions=[]) + warnings = [r for r in caplog.records if r.levelno >= logging.WARNING] + assert warnings == [] + + def test_required_format_with_too_old_errors_with_reason(self, monkeypatch): + b = _reload_bridge_with_fake_bngsim(monkeypatch, "0.3.0") + # SBML requires bngsim — no subprocess fallback exists. + decision = b.classify_bngsim_route( + "m.xml", "sbml", simulator="auto", bngl_actions=[] + ) + assert decision.route == b.ROUTE_ERROR + assert "0.3.0" in decision.reason + + +class TestUnparseableVersionFallsOpen: + """If bngsim ships a version string that `packaging` can't parse, + err on the side of "available" — better to surface a downstream + BNGsim-API error with its real cause than silently disable a + possibly-fine install.""" + + def test_unparseable_version_treated_as_available(self, monkeypatch): + b = _reload_bridge_with_fake_bngsim(monkeypatch, "not-a-version") + assert b.is_bngsim_available() From 2bf86aa46cb3f42d0266cfc8402b7531075dfa68 Mon Sep 17 00:00:00 2001 From: Bill Hlavacek Date: Tue, 26 May 2026 00:24:58 -0600 Subject: [PATCH 2/4] Add optional bngsim extra; bump version floor to 0.9.10 Packaging (B3): add extras_require={"bngsim": ["bngsim>=0.9.10"]} so the optional in-process engine is discoverable via `pip install bionetgen[bngsim]`, while keeping bngsim out of install_requires (never a hard dependency). The extra's floor matches MINIMUM_BNGSIM_VERSION in the bridge. Bridge: bump MINIMUM_BNGSIM_VERSION 0.6.0 -> 0.9.10. 0.9.0 settled the method-independent gdat/scan output schema (bngsim #58) the parity differ normalizes against; 0.9.10 is the current validated release. Refresh the floor-rationale comment, which still cited the stale 0.6.0/#40 reason. Update the version-guard test's minimum-version case to 0.9.10. With bngsim absent the bridge soft-imports to the legacy subprocess path unchanged; the bngsim test files skip/pass cleanly (239 passed, 9 skipped, 0 errors with bngsim genuinely uninstalled). --- bionetgen/core/tools/bngsim_bridge.py | 10 +++++----- setup.py | 9 +++++++++ tests/test_bngsim_version_guard.py | 2 +- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/bionetgen/core/tools/bngsim_bridge.py b/bionetgen/core/tools/bngsim_bridge.py index 4552fe7b..5548a3d9 100644 --- a/bionetgen/core/tools/bngsim_bridge.py +++ b/bionetgen/core/tools/bngsim_bridge.py @@ -26,10 +26,10 @@ # PyBioNetGen routes every simulation through subprocess BNG2.pl / # run_network / NFsim (the documented fallback). When BNGsim *is* # installed, however, PyBioNetGen depends on specific behaviour that -# only the modern releases provide — most recently 0.6.0's NFsim -# global-function support (closes bngsim #40), without which the -# network-free corpus produces incomplete output. So the contract is -# "optional, but if present must be recent." +# only the modern releases provide — the settled, method-independent +# gdat/scan output schema (bngsim #58, finalized in 0.9.0) that the +# parity differ normalizes against, plus the validated current release. +# So the contract is "optional, but if present must be recent." # # Implementation: # * Not installed → BNGSIM_AVAILABLE=False, reason "not installed", @@ -47,7 +47,7 @@ # `if not BNGSIM_AVAILABLE` site (≈8 places) naturally falls back without # needing per-site version checks. -MINIMUM_BNGSIM_VERSION = "0.6.0" +MINIMUM_BNGSIM_VERSION = "0.9.10" BNGSIM_UNAVAILABLE_REASON: str | None = None _VERSION_FALLBACK_WARNED = False diff --git a/setup.py b/setup.py index 478d7499..7f9263b9 100644 --- a/setup.py +++ b/setup.py @@ -203,4 +203,13 @@ def get_folder(arch): "pyparsing", "packaging", ], + # bngsim is an OPTIONAL in-process simulation engine. It is never a hard + # dependency: absent it, the bridge transparently falls back to the + # subprocess BNG2.pl path (see core/tools/bngsim_bridge.py). The extra is + # provided for discoverability and pins the version floor that matches + # MINIMUM_BNGSIM_VERSION in the bridge. (bngsim is not yet on PyPI, so + # `pip install bionetgen[bngsim]` will not resolve until it is published.) + extras_require={ + "bngsim": ["bngsim>=0.9.10"], + }, ) diff --git a/tests/test_bngsim_version_guard.py b/tests/test_bngsim_version_guard.py index 88a645bd..1208ab5f 100644 --- a/tests/test_bngsim_version_guard.py +++ b/tests/test_bngsim_version_guard.py @@ -68,7 +68,7 @@ def test_too_old_marks_unavailable(self, monkeypatch): assert b.MINIMUM_BNGSIM_VERSION in reason def test_minimum_version_available(self, monkeypatch): - b = _reload_bridge_with_fake_bngsim(monkeypatch, "0.6.0") + b = _reload_bridge_with_fake_bngsim(monkeypatch, "0.9.10") assert b.is_bngsim_available() assert b.get_bngsim_unavailable_reason() is None From d1a42f7633f27c162db9f00b957364d93b5b7e79 Mon Sep 17 00:00:00 2001 From: Bill Hlavacek Date: Wed, 27 May 2026 18:17:59 -0600 Subject: [PATCH 3/4] Fix Python 3.8 syntax: drop parenthesized with-statements in bngsim tests Parenthesized context managers (with (cm1 as a, cm2 as b):) are 3.10+ syntax; 3.9's PEG parser tolerated them but 3.8 raises SyntaxError at import, failing CI collection on the 3.8 matrix legs. Rewrite the headers to the equivalent comma-separated form (black's pre-3.9 wrapping). AST is unchanged; purely a syntax-compatibility fix. --- tests/test_bngsim_bridge_extended.py | 180 ++++++++++-------------- tests/test_bngsim_method_defaults.py | 93 +++++------- tests/test_bngsim_routing_classifier.py | 79 +++++------ 3 files changed, 149 insertions(+), 203 deletions(-) diff --git a/tests/test_bngsim_bridge_extended.py b/tests/test_bngsim_bridge_extended.py index 77f667e5..38ffb24b 100644 --- a/tests/test_bngsim_bridge_extended.py +++ b/tests/test_bngsim_bridge_extended.py @@ -291,9 +291,8 @@ def test_raises_when_bngsim_unavailable(self): def test_raises_when_nfsim_unavailable(self): from bionetgen.core.tools.bngsim_bridge import run_nfsim - with ( - patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), - patch(f"{BRIDGE}.BNGSIM_HAS_NFSIM", False), + with patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), patch( + f"{BRIDGE}.BNGSIM_HAS_NFSIM", False ): with pytest.raises(BNGSimError, match="not available"): run_nfsim("/dummy.xml", "/output") @@ -303,12 +302,11 @@ def test_happy_path(self): mock_bngsim, session = _make_mock_bngsim_with_nfsim_session() - with ( - patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), - patch(f"{BRIDGE}.BNGSIM_HAS_NFSIM", True), - patch(f"{BRIDGE}.bngsim", mock_bngsim), - tempfile.TemporaryDirectory() as tmpdir, - ): + with patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), patch( + f"{BRIDGE}.BNGSIM_HAS_NFSIM", True + ), patch( + f"{BRIDGE}.bngsim", mock_bngsim + ), tempfile.TemporaryDirectory() as tmpdir: # Create a dummy xml file xml_path = os.path.join(tmpdir, "model.xml") with open(xml_path, "w") as f: @@ -328,12 +326,11 @@ def test_param_overrides(self): mock_bngsim, session = _make_mock_bngsim_with_nfsim_session() - with ( - patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), - patch(f"{BRIDGE}.BNGSIM_HAS_NFSIM", True), - patch(f"{BRIDGE}.bngsim", mock_bngsim), - tempfile.TemporaryDirectory() as tmpdir, - ): + with patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), patch( + f"{BRIDGE}.BNGSIM_HAS_NFSIM", True + ), patch( + f"{BRIDGE}.bngsim", mock_bngsim + ), tempfile.TemporaryDirectory() as tmpdir: xml_path = os.path.join(tmpdir, "model.xml") with open(xml_path, "w") as f: f.write("") @@ -347,12 +344,11 @@ def test_conc_overrides_set_exact_species_count(self): mock_bngsim, session = _make_mock_bngsim_with_nfsim_session() - with ( - patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), - patch(f"{BRIDGE}.BNGSIM_HAS_NFSIM", True), - patch(f"{BRIDGE}.bngsim", mock_bngsim), - tempfile.TemporaryDirectory() as tmpdir, - ): + with patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), patch( + f"{BRIDGE}.BNGSIM_HAS_NFSIM", True + ), patch( + f"{BRIDGE}.bngsim", mock_bngsim + ), tempfile.TemporaryDirectory() as tmpdir: xml_path = os.path.join(tmpdir, "model.xml") with open(xml_path, "w") as f: f.write("") @@ -368,12 +364,11 @@ def test_conc_overrides_can_decrease_exact_species_count(self): mock_bngsim, session = _make_mock_bngsim_with_nfsim_session() - with ( - patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), - patch(f"{BRIDGE}.BNGSIM_HAS_NFSIM", True), - patch(f"{BRIDGE}.bngsim", mock_bngsim), - tempfile.TemporaryDirectory() as tmpdir, - ): + with patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), patch( + f"{BRIDGE}.BNGSIM_HAS_NFSIM", True + ), patch( + f"{BRIDGE}.bngsim", mock_bngsim + ), tempfile.TemporaryDirectory() as tmpdir: xml_path = os.path.join(tmpdir, "model.xml") with open(xml_path, "w") as f: f.write("") @@ -389,12 +384,11 @@ def test_conc_overrides_same_mol_type_stay_pattern_specific(self): mock_bngsim, session = _make_mock_bngsim_with_nfsim_session() - with ( - patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), - patch(f"{BRIDGE}.BNGSIM_HAS_NFSIM", True), - patch(f"{BRIDGE}.bngsim", mock_bngsim), - tempfile.TemporaryDirectory() as tmpdir, - ): + with patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), patch( + f"{BRIDGE}.BNGSIM_HAS_NFSIM", True + ), patch( + f"{BRIDGE}.bngsim", mock_bngsim + ), tempfile.TemporaryDirectory() as tmpdir: xml_path = os.path.join(tmpdir, "model.xml") with open(xml_path, "w") as f: f.write("") @@ -416,12 +410,11 @@ def test_conc_overrides_and_deltas_remain_pattern_specific(self): mock_bngsim, session = _make_mock_bngsim_with_nfsim_session() - with ( - patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), - patch(f"{BRIDGE}.BNGSIM_HAS_NFSIM", True), - patch(f"{BRIDGE}.bngsim", mock_bngsim), - tempfile.TemporaryDirectory() as tmpdir, - ): + with patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), patch( + f"{BRIDGE}.BNGSIM_HAS_NFSIM", True + ), patch( + f"{BRIDGE}.bngsim", mock_bngsim + ), tempfile.TemporaryDirectory() as tmpdir: xml_path = os.path.join(tmpdir, "model.xml") with open(xml_path, "w") as f: f.write("") @@ -443,12 +436,11 @@ def test_conc_overrides_and_deltas_same_pattern_combine(self): mock_bngsim, session = _make_mock_bngsim_with_nfsim_session() - with ( - patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), - patch(f"{BRIDGE}.BNGSIM_HAS_NFSIM", True), - patch(f"{BRIDGE}.bngsim", mock_bngsim), - tempfile.TemporaryDirectory() as tmpdir, - ): + with patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), patch( + f"{BRIDGE}.BNGSIM_HAS_NFSIM", True + ), patch( + f"{BRIDGE}.bngsim", mock_bngsim + ), tempfile.TemporaryDirectory() as tmpdir: xml_path = os.path.join(tmpdir, "model.xml") with open(xml_path, "w") as f: f.write("") @@ -468,12 +460,11 @@ def test_conc_deltas_can_decrease_exact_species_count(self): mock_bngsim, session = _make_mock_bngsim_with_nfsim_session() - with ( - patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), - patch(f"{BRIDGE}.BNGSIM_HAS_NFSIM", True), - patch(f"{BRIDGE}.bngsim", mock_bngsim), - tempfile.TemporaryDirectory() as tmpdir, - ): + with patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), patch( + f"{BRIDGE}.BNGSIM_HAS_NFSIM", True + ), patch( + f"{BRIDGE}.bngsim", mock_bngsim + ), tempfile.TemporaryDirectory() as tmpdir: xml_path = os.path.join(tmpdir, "model.xml") with open(xml_path, "w") as f: f.write("") @@ -524,12 +515,11 @@ def test_defaults(self): mock_bngsim, session = _make_mock_bngsim_with_nfsim_session() - with ( - patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), - patch(f"{BRIDGE}.BNGSIM_HAS_NFSIM", True), - patch(f"{BRIDGE}.bngsim", mock_bngsim), - tempfile.TemporaryDirectory() as tmpdir, - ): + with patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), patch( + f"{BRIDGE}.BNGSIM_HAS_NFSIM", True + ), patch( + f"{BRIDGE}.bngsim", mock_bngsim + ), tempfile.TemporaryDirectory() as tmpdir: xml_path = os.path.join(tmpdir, "model.xml") with open(xml_path, "w") as f: f.write("") @@ -545,12 +535,11 @@ def test_simulation_failure_wraps_exception(self): mock_bngsim = MagicMock() mock_bngsim.NfsimSession.side_effect = RuntimeError("boom") - with ( - patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), - patch(f"{BRIDGE}.BNGSIM_HAS_NFSIM", True), - patch(f"{BRIDGE}.bngsim", mock_bngsim), - tempfile.TemporaryDirectory() as tmpdir, - ): + with patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), patch( + f"{BRIDGE}.BNGSIM_HAS_NFSIM", True + ), patch( + f"{BRIDGE}.bngsim", mock_bngsim + ), tempfile.TemporaryDirectory() as tmpdir: xml_path = os.path.join(tmpdir, "model.xml") with open(xml_path, "w") as f: f.write("") @@ -563,12 +552,11 @@ def test_gml_is_set(self): mock_bngsim, _ = _make_mock_bngsim_with_nfsim_session() - with ( - patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), - patch(f"{BRIDGE}.BNGSIM_HAS_NFSIM", True), - patch(f"{BRIDGE}.bngsim", mock_bngsim), - tempfile.TemporaryDirectory() as tmpdir, - ): + with patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), patch( + f"{BRIDGE}.BNGSIM_HAS_NFSIM", True + ), patch( + f"{BRIDGE}.bngsim", mock_bngsim + ), tempfile.TemporaryDirectory() as tmpdir: xml_path = os.path.join(tmpdir, "model.xml") with open(xml_path, "w") as f: f.write("") @@ -594,9 +582,8 @@ def test_bng_xml_routes_to_run_nfsim(self): from bionetgen.core.tools.bngsim_bridge import run_with_bngsim mock_run_nfsim = MagicMock() - with ( - patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), - patch(f"{BRIDGE}.run_nfsim", mock_run_nfsim), + with patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), patch( + f"{BRIDGE}.run_nfsim", mock_run_nfsim ): run_with_bngsim("/model.xml", "/output", fmt="bng-xml", method="nf") mock_run_nfsim.assert_called_once() @@ -605,9 +592,8 @@ def test_bng_xml_defaults_to_nf(self): from bionetgen.core.tools.bngsim_bridge import run_with_bngsim mock_run_nfsim = MagicMock() - with ( - patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), - patch(f"{BRIDGE}.run_nfsim", mock_run_nfsim), + with patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), patch( + f"{BRIDGE}.run_nfsim", mock_run_nfsim ): run_with_bngsim("/model.xml", "/output", fmt="bng-xml", method=None) mock_run_nfsim.assert_called_once() @@ -637,11 +623,9 @@ def test_net_loads_from_net(self): mock_sim.run.return_value = mock_result mock_bngsim.Simulator.return_value = mock_sim - with ( - patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), - patch(f"{BRIDGE}.bngsim", mock_bngsim), - tempfile.TemporaryDirectory() as tmpdir, - ): + with patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), patch( + f"{BRIDGE}.bngsim", mock_bngsim + ), tempfile.TemporaryDirectory() as tmpdir: run_with_bngsim("/model.net", tmpdir, fmt="net", method="ode") mock_bngsim.Model.from_net.assert_called_once() @@ -656,11 +640,9 @@ def test_sbml_loads_from_sbml(self): mock_sim.run.return_value = mock_result mock_bngsim.Simulator.return_value = mock_sim - with ( - patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), - patch(f"{BRIDGE}.bngsim", mock_bngsim), - tempfile.TemporaryDirectory() as tmpdir, - ): + with patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), patch( + f"{BRIDGE}.bngsim", mock_bngsim + ), tempfile.TemporaryDirectory() as tmpdir: run_with_bngsim("/model.xml", tmpdir, fmt="sbml", method="ode") mock_bngsim.Model.from_sbml.assert_called_once() @@ -673,11 +655,9 @@ def test_antimony_loads_from_antimony(self): mock_sim.run.return_value = _make_mock_result() mock_bngsim.Simulator.return_value = mock_sim - with ( - patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), - patch(f"{BRIDGE}.bngsim", mock_bngsim), - tempfile.TemporaryDirectory() as tmpdir, - ): + with patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), patch( + f"{BRIDGE}.bngsim", mock_bngsim + ), tempfile.TemporaryDirectory() as tmpdir: run_with_bngsim("/model.ant", tmpdir, fmt="antimony", method="ode") mock_bngsim.Model.from_antimony.assert_called_once() @@ -692,9 +672,8 @@ def test_nf_with_xml_path_routes_to_nfsim(self): from bionetgen.core.tools.bngsim_bridge import run_with_bngsim mock_run_nfsim = MagicMock() - with ( - patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), - patch(f"{BRIDGE}.run_nfsim", mock_run_nfsim), + with patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), patch( + f"{BRIDGE}.run_nfsim", mock_run_nfsim ): run_with_bngsim( "/model.net", @@ -709,9 +688,8 @@ def test_unsupported_format_raises(self): from bionetgen.core.tools.bngsim_bridge import run_with_bngsim mock_bngsim = MagicMock() - with ( - patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), - patch(f"{BRIDGE}.bngsim", mock_bngsim), + with patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), patch( + f"{BRIDGE}.bngsim", mock_bngsim ): with pytest.raises(BNGSimError, match="Unsupported format"): run_with_bngsim("/model.bngl", "/output", fmt="bngl", method="ode") @@ -722,9 +700,8 @@ def test_simulation_exception_wrapped(self): mock_bngsim = MagicMock() mock_bngsim.Model.from_net.side_effect = RuntimeError("boom") - with ( - patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), - patch(f"{BRIDGE}.bngsim", mock_bngsim), + with patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), patch( + f"{BRIDGE}.bngsim", mock_bngsim ): with pytest.raises(BNGSimError, match="BNGsim simulation failed"): run_with_bngsim("/model.net", "/output", fmt="net") @@ -755,9 +732,8 @@ def test_returns_so_path_when_codegen_available(self): mock_bngsim = MagicMock() mock_bngsim.prepare_codegen.return_value = "/path/to/lib.so" - with ( - patch.dict(os.environ, {}, clear=False), - patch.dict("sys.modules", {"bngsim": mock_bngsim}), + with patch.dict(os.environ, {}, clear=False), patch.dict( + "sys.modules", {"bngsim": mock_bngsim} ): os.environ.pop("BIONETGEN_NO_CODEGEN", None) result = _try_prepare_codegen("/dummy.net") diff --git a/tests/test_bngsim_method_defaults.py b/tests/test_bngsim_method_defaults.py index 9aaf5e2e..3d596287 100644 --- a/tests/test_bngsim_method_defaults.py +++ b/tests/test_bngsim_method_defaults.py @@ -16,14 +16,10 @@ def test_bngl_run_passes_no_method_override_by_default(self): sentinel = object() - with ( - patch(f"{BRIDGE}.detect_input_format", return_value="bngl"), - patch( - f"{BRIDGE}.classify_bngsim_route", - return_value=BngsimRouteDecision(ROUTE_BNGL_BNGSIM, "atomic BNGL"), - ), - patch(f"{BRIDGE}.run_bngl_with_bngsim", return_value=sentinel) as mock_run, - ): + with patch(f"{BRIDGE}.detect_input_format", return_value="bngl"), patch( + f"{BRIDGE}.classify_bngsim_route", + return_value=BngsimRouteDecision(ROUTE_BNGL_BNGSIM, "atomic BNGL"), + ), patch(f"{BRIDGE}.run_bngl_with_bngsim", return_value=sentinel) as mock_run: result = run("model.bngl", out="/tmp/out") assert result is sentinel @@ -38,14 +34,10 @@ def test_bngl_run_passes_explicit_method_override(self): sentinel = object() - with ( - patch(f"{BRIDGE}.detect_input_format", return_value="bngl"), - patch( - f"{BRIDGE}.classify_bngsim_route", - return_value=BngsimRouteDecision(ROUTE_BNGL_BNGSIM, "atomic BNGL"), - ), - patch(f"{BRIDGE}.run_bngl_with_bngsim", return_value=sentinel) as mock_run, - ): + with patch(f"{BRIDGE}.detect_input_format", return_value="bngl"), patch( + f"{BRIDGE}.classify_bngsim_route", + return_value=BngsimRouteDecision(ROUTE_BNGL_BNGSIM, "atomic BNGL"), + ), patch(f"{BRIDGE}.run_bngl_with_bngsim", return_value=sentinel) as mock_run: result = run("model.bngl", out="/tmp/out", method="ode") assert result is sentinel @@ -60,16 +52,13 @@ def test_cli_run_passes_no_method_override_by_default(self): ) from bionetgen.main import BioNetGenTest - with ( - patch(f"{BRIDGE}.detect_input_format", return_value="bngl"), - patch( - f"{BRIDGE}.classify_bngsim_route", - return_value=BngsimRouteDecision(ROUTE_BNGL_BNGSIM, "atomic BNGL"), - ), - patch( - f"{BRIDGE}.run_bngl_with_bngsim", return_value=MagicMock() - ) as mock_run, - patch("bionetgen.main.test_perl"), + with patch(f"{BRIDGE}.detect_input_format", return_value="bngl"), patch( + f"{BRIDGE}.classify_bngsim_route", + return_value=BngsimRouteDecision(ROUTE_BNGL_BNGSIM, "atomic BNGL"), + ), patch( + f"{BRIDGE}.run_bngl_with_bngsim", return_value=MagicMock() + ) as mock_run, patch( + "bionetgen.main.test_perl" ): with BioNetGenTest( argv=["run", "-i", "model.bngl", "-o", "/tmp/out"] @@ -86,16 +75,13 @@ def test_cli_run_passes_explicit_method_override(self): ) from bionetgen.main import BioNetGenTest - with ( - patch(f"{BRIDGE}.detect_input_format", return_value="bngl"), - patch( - f"{BRIDGE}.classify_bngsim_route", - return_value=BngsimRouteDecision(ROUTE_BNGL_BNGSIM, "atomic BNGL"), - ), - patch( - f"{BRIDGE}.run_bngl_with_bngsim", return_value=MagicMock() - ) as mock_run, - patch("bionetgen.main.test_perl"), + with patch(f"{BRIDGE}.detect_input_format", return_value="bngl"), patch( + f"{BRIDGE}.classify_bngsim_route", + return_value=BngsimRouteDecision(ROUTE_BNGL_BNGSIM, "atomic BNGL"), + ), patch( + f"{BRIDGE}.run_bngl_with_bngsim", return_value=MagicMock() + ) as mock_run, patch( + "bionetgen.main.test_perl" ): with BioNetGenTest( argv=["run", "-i", "model.bngl", "-o", "/tmp/out", "--method", "ode"] @@ -111,16 +97,13 @@ def test_cli_run_passes_timeout_override(self): ) from bionetgen.main import BioNetGenTest - with ( - patch(f"{BRIDGE}.detect_input_format", return_value="bngl"), - patch( - f"{BRIDGE}.classify_bngsim_route", - return_value=BngsimRouteDecision(ROUTE_BNGL_BNGSIM, "atomic BNGL"), - ), - patch( - f"{BRIDGE}.run_bngl_with_bngsim", return_value=MagicMock() - ) as mock_run, - patch("bionetgen.main.test_perl"), + with patch(f"{BRIDGE}.detect_input_format", return_value="bngl"), patch( + f"{BRIDGE}.classify_bngsim_route", + return_value=BngsimRouteDecision(ROUTE_BNGL_BNGSIM, "atomic BNGL"), + ), patch( + f"{BRIDGE}.run_bngl_with_bngsim", return_value=MagicMock() + ) as mock_run, patch( + "bionetgen.main.test_perl" ): with BioNetGenTest( argv=["run", "-i", "model.bngl", "-o", "/tmp/out", "--timeout", "17"] @@ -146,12 +129,11 @@ def test_direct_net_input_defaults_to_ode(self): ) mock_bngsim.Simulator.return_value = mock_sim - with ( - patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), - patch(f"{BRIDGE}.bngsim", mock_bngsim), - patch(f"{BRIDGE}._make_bng_result", return_value=MagicMock()), - tempfile.TemporaryDirectory() as tmpdir, - ): + with patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), patch( + f"{BRIDGE}.bngsim", mock_bngsim + ), patch( + f"{BRIDGE}._make_bng_result", return_value=MagicMock() + ), tempfile.TemporaryDirectory() as tmpdir: run_with_bngsim("/model.net", tmpdir, fmt="net", method=None) mock_bngsim.Simulator.assert_called_once_with(mock_model, method="ode") @@ -161,10 +143,9 @@ def test_direct_bng_xml_input_defaults_to_nf(self): sentinel = object() - with ( - patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), - patch(f"{BRIDGE}.run_nfsim", return_value=sentinel) as mock_run, - ): + with patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), patch( + f"{BRIDGE}.run_nfsim", return_value=sentinel + ) as mock_run: result = run_with_bngsim( "/model.xml", "/tmp/out", fmt="bng-xml", method=None ) diff --git a/tests/test_bngsim_routing_classifier.py b/tests/test_bngsim_routing_classifier.py index 43d1cd6a..56568066 100644 --- a/tests/test_bngsim_routing_classifier.py +++ b/tests/test_bngsim_routing_classifier.py @@ -374,17 +374,15 @@ def test_library_complex_bngl_uses_bngsim_route_not_subprocess_classifier( out_dir = tmp_path / "out" sentinel = object() - with ( - patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), - patch(f"{BRIDGE}.BNGSIM_HAS_NFSIM", True), - patch( - f"{BRIDGE}.run_bngl_with_bngsim", return_value=sentinel - ) as mock_bngsim_run, - patch( - "bionetgen.modelapi.runner.get_conf", return_value={"bngpath": "/fake/bng"} - ), - patch("bionetgen.modelapi.runner.BNGCLI") as mock_bngcli, - ): + with patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), patch( + f"{BRIDGE}.BNGSIM_HAS_NFSIM", True + ), patch( + f"{BRIDGE}.run_bngl_with_bngsim", return_value=sentinel + ) as mock_bngsim_run, patch( + "bionetgen.modelapi.runner.get_conf", return_value={"bngpath": "/fake/bng"} + ), patch( + "bionetgen.modelapi.runner.BNGCLI" + ) as mock_bngcli: result = run(str(bngl_path), out=str(out_dir)) assert result is sentinel @@ -408,14 +406,12 @@ def test_run_bngl_with_bngsim_complex_action_uses_backend_hook_without_executor( ) sentinel = object() - with ( - patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), - patch(f"{BRIDGE}.BNGSIM_HAS_NFSIM", True), - patch( - f"{BRIDGE}.run_bngl_with_bngsim_backend_hook", - return_value=sentinel, - ) as mock_hook, - ): + with patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), patch( + f"{BRIDGE}.BNGSIM_HAS_NFSIM", True + ), patch( + f"{BRIDGE}.run_bngl_with_bngsim_backend_hook", + return_value=sentinel, + ) as mock_hook: result = run_bngl_with_bngsim( str(bngl_path), str(tmp_path / "out"), "/fake/bng" ) @@ -485,14 +481,12 @@ def test_run_bngl_with_bngsim_protocol_uses_backend_hook_without_python_parser( ) sentinel = object() - with ( - patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), - patch(f"{BRIDGE}.BNGSIM_HAS_NFSIM", True), - patch( - f"{BRIDGE}.run_bngl_with_bngsim_backend_hook", - return_value=sentinel, - ) as mock_hook, - ): + with patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), patch( + f"{BRIDGE}.BNGSIM_HAS_NFSIM", True + ), patch( + f"{BRIDGE}.run_bngl_with_bngsim_backend_hook", + return_value=sentinel, + ) as mock_hook: result = run_bngl_with_bngsim( str(bngl_path), str(tmp_path / "out"), "/fake/bng" ) @@ -512,14 +506,12 @@ def test_run_bngl_with_bngsim_scan_uses_backend_hook_without_python_scan_outputs ) sentinel = object() - with ( - patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), - patch(f"{BRIDGE}.BNGSIM_HAS_NFSIM", True), - patch( - f"{BRIDGE}.run_bngl_with_bngsim_backend_hook", - return_value=sentinel, - ) as mock_hook, - ): + with patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), patch( + f"{BRIDGE}.BNGSIM_HAS_NFSIM", True + ), patch( + f"{BRIDGE}.run_bngl_with_bngsim_backend_hook", + return_value=sentinel, + ) as mock_hook: result = run_bngl_with_bngsim( str(bngl_path), str(tmp_path / "out"), "/fake/bng" ) @@ -540,16 +532,13 @@ def test_library_subprocess_route_uses_bngcli(tmp_path): mock_cli = MagicMock() mock_cli.result = sentinel - with ( - patch(f"{BRIDGE}.detect_input_format", return_value="bngl"), - patch( - f"{BRIDGE}.classify_bngsim_route", - return_value=BngsimRouteDecision(ROUTE_SUBPROCESS, "complex BNGL"), - ), - patch( - "bionetgen.modelapi.runner.get_conf", return_value={"bngpath": "/fake/bng"} - ), - patch("bionetgen.modelapi.runner.BNGCLI", return_value=mock_cli), + with patch(f"{BRIDGE}.detect_input_format", return_value="bngl"), patch( + f"{BRIDGE}.classify_bngsim_route", + return_value=BngsimRouteDecision(ROUTE_SUBPROCESS, "complex BNGL"), + ), patch( + "bionetgen.modelapi.runner.get_conf", return_value={"bngpath": "/fake/bng"} + ), patch( + "bionetgen.modelapi.runner.BNGCLI", return_value=mock_cli ): result = run("model.bngl", out=str(tmp_path)) From cb5b2a99b2d17d219f5686926a568f1ba14a8f8b Mon Sep 17 00:00:00 2001 From: Bill Hlavacek Date: Wed, 27 May 2026 18:35:08 -0600 Subject: [PATCH 4/4] Fix Python 3.8 with-tuple trap in direct_job_executor tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On 3.8 'with (cm1, cm2, cm3):' (no 'as' clauses) parses as a single context manager that is the tuple (cm1, cm2, cm3) — which has no __enter__, so all 10 tests raised AttributeError once 3.8 collection succeeded. Drop the grouping parens; behavior on 3.9+ is unchanged (AST identical). This was the last parenthesized-with in the repo. --- tests/test_bngsim_direct_job_executor.py | 67 +++++++++++------------- 1 file changed, 30 insertions(+), 37 deletions(-) diff --git a/tests/test_bngsim_direct_job_executor.py b/tests/test_bngsim_direct_job_executor.py index 39a1fb39..1fd4fc1e 100644 --- a/tests/test_bngsim_direct_job_executor.py +++ b/tests/test_bngsim_direct_job_executor.py @@ -56,11 +56,10 @@ def test_net_job_instantiates_simulator_with_expected_method(method): mock_bngsim.Model.from_net.return_value = model mock_bngsim.Simulator.return_value.run.return_value = _mock_result() - with ( - patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), - patch(f"{BRIDGE}.bngsim", mock_bngsim), - patch(f"{BRIDGE}._write_bngsim_results"), - patch(f"{BRIDGE}._make_bng_result", return_value=MagicMock()), + with patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), patch( + f"{BRIDGE}.bngsim", mock_bngsim + ), patch(f"{BRIDGE}._write_bngsim_results"), patch( + f"{BRIDGE}._make_bng_result", return_value=MagicMock() ): execute_bngsim_direct_job(_network_job(method=method)) @@ -73,11 +72,10 @@ def test_psa_job_passes_poplevel_to_simulator(): mock_bngsim.Model.from_net.return_value = model mock_bngsim.Simulator.return_value.run.return_value = _mock_result() - with ( - patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), - patch(f"{BRIDGE}.bngsim", mock_bngsim), - patch(f"{BRIDGE}._write_bngsim_results"), - patch(f"{BRIDGE}._make_bng_result", return_value=MagicMock()), + with patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), patch( + f"{BRIDGE}.bngsim", mock_bngsim + ), patch(f"{BRIDGE}._write_bngsim_results"), patch( + f"{BRIDGE}._make_bng_result", return_value=MagicMock() ): execute_bngsim_direct_job( _network_job(method="psa", options={"poplevel": 250.0}) @@ -99,11 +97,10 @@ def test_sbml_and_antimony_jobs_load_mocked_bngsim_models(fmt, loader): getattr(mock_bngsim.Model, loader).return_value = model mock_bngsim.Simulator.return_value.run.return_value = _mock_result() - with ( - patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), - patch(f"{BRIDGE}.bngsim", mock_bngsim), - patch(f"{BRIDGE}._write_bngsim_results"), - patch(f"{BRIDGE}._make_bng_result", return_value=MagicMock()), + with patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), patch( + f"{BRIDGE}.bngsim", mock_bngsim + ), patch(f"{BRIDGE}._write_bngsim_results"), patch( + f"{BRIDGE}._make_bng_result", return_value=MagicMock() ): execute_bngsim_direct_job(_network_job(fmt=fmt, method="ode")) @@ -128,12 +125,12 @@ def test_bng_xml_job_uses_nfsim_session_for_nf_method_only(): bngsim_options={"seed": 7, "gml": 1000}, ) - with ( - patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), - patch(f"{BRIDGE}.BNGSIM_HAS_NFSIM", True), - patch(f"{BRIDGE}.bngsim", mock_bngsim), - patch(f"{BRIDGE}._write_bngsim_results"), - patch(f"{BRIDGE}._make_bng_result", return_value=MagicMock()), + with patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), patch( + f"{BRIDGE}.BNGSIM_HAS_NFSIM", True + ), patch(f"{BRIDGE}.bngsim", mock_bngsim), patch( + f"{BRIDGE}._write_bngsim_results" + ), patch( + f"{BRIDGE}._make_bng_result", return_value=MagicMock() ): execute_bngsim_direct_job(job) @@ -156,11 +153,9 @@ def test_bng_xml_job_uses_nfsim_session_for_nf_method_only(): ) mock_bngsim.NfsimSession.reset_mock() - with ( - patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), - patch(f"{BRIDGE}.BNGSIM_HAS_NFSIM", True), - patch(f"{BRIDGE}.bngsim", mock_bngsim), - ): + with patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), patch( + f"{BRIDGE}.BNGSIM_HAS_NFSIM", True + ), patch(f"{BRIDGE}.bngsim", mock_bngsim): with pytest.raises(BNGSimError, match="network-free simulation"): execute_bngsim_direct_job(bad_job) @@ -199,12 +194,12 @@ def test_bng_xml_rm_job_writes_final_state_and_replays_conc(): get_final_state=True, ) - with ( - patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), - patch(f"{BRIDGE}.BNGSIM_HAS_RULEMONKEY", True), - patch(f"{BRIDGE}.bngsim", mock_bngsim), - patch(f"{BRIDGE}._write_bngsim_results"), - patch(f"{BRIDGE}._make_bng_result", return_value=MagicMock()), + with patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), patch( + f"{BRIDGE}.BNGSIM_HAS_RULEMONKEY", True + ), patch(f"{BRIDGE}.bngsim", mock_bngsim), patch( + f"{BRIDGE}._write_bngsim_results" + ), patch( + f"{BRIDGE}._make_bng_result", return_value=MagicMock() ): execute_bngsim_direct_job(job) @@ -229,11 +224,9 @@ def test_direct_job_writer_creates_gdat_and_cdat_files(): with tempfile.TemporaryDirectory() as tmpdir: job = _network_job(method="ode", output_dir=tmpdir) - with ( - patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), - patch(f"{BRIDGE}.bngsim", mock_bngsim), - patch(f"{BRIDGE}._make_bng_result", return_value=MagicMock()), - ): + with patch(f"{BRIDGE}.BNGSIM_AVAILABLE", True), patch( + f"{BRIDGE}.bngsim", mock_bngsim + ), patch(f"{BRIDGE}._make_bng_result", return_value=MagicMock()): execute_bngsim_direct_job(job) assert os.path.isfile(os.path.join(tmpdir, "model.gdat"))