diff --git a/.github/workflows/ci_tests.yml b/.github/workflows/ci_tests.yml index f43687da..8d877569 100644 --- a/.github/workflows/ci_tests.yml +++ b/.github/workflows/ci_tests.yml @@ -29,6 +29,9 @@ jobs: run: uvx pre-commit run --all-files if: matrix.platform == 'ubuntu-latest' + - name: Fetch BNGL test corpus + run: uv run python tests/v1/fetch_bngl_corpus.py + - name: Unit tests run: | uv sync --group ci diff --git a/.gitignore b/.gitignore index 91de2cf8..62025428 100644 --- a/.gitignore +++ b/.gitignore @@ -114,3 +114,6 @@ venv.bak/ _untracked doc/_static/README.rst + +# BNGL corpus fixtures are fetched on demand (tests/v1/fetch_bngl_corpus.py) +tests/v1/bngl_corpus/*.bngl diff --git a/petab/v1/models/__init__.py b/petab/v1/models/__init__.py index 75a6bb9c..29c44a26 100644 --- a/petab/v1/models/__init__.py +++ b/petab/v1/models/__init__.py @@ -4,12 +4,21 @@ MODEL_TYPE_SBML = "sbml" #: PySB model type as used in a PEtab v2 yaml file as `language`. MODEL_TYPE_PYSB = "pysb" +#: BNGL model type as used in a PEtab v2 yaml file as `language`. +MODEL_TYPE_BNGL = "bngl" known_model_types = { MODEL_TYPE_SBML, MODEL_TYPE_PYSB, + MODEL_TYPE_BNGL, } from .model import Model # noqa F401 -__all__ = ["MODEL_TYPE_PYSB", "MODEL_TYPE_SBML", "Model", "known_model_types"] +__all__ = [ + "MODEL_TYPE_BNGL", + "MODEL_TYPE_PYSB", + "MODEL_TYPE_SBML", + "Model", + "known_model_types", +] diff --git a/petab/v1/models/bngl_model.py b/petab/v1/models/bngl_model.py new file mode 100644 index 00000000..6c68672b --- /dev/null +++ b/petab/v1/models/bngl_model.py @@ -0,0 +1,397 @@ +"""BNGL (BioNetGen Language) model support for PEtab. + +Adds a ``bngl`` model type, so a PEtab problem declaring ``language: bngl`` +can be loaded and validated. + +:class:`BnglModel` is backed by :func:`parse_bngl`, a small, dependency-free +BNGL reader. It only reads a model's declared entities -- parameters, +observables, functions, molecule types, compartments, seed species -- which +is all PEtab validation needs; it never runs BNG2.pl or generates a reaction +network. The one exception is :meth:`BnglModel.is_valid`: if a ``BNG2.pl`` +is found (``BNGPATH`` or ``PATH``), it runs ``BNG2.pl --check`` (a +parse/semantic check, no network generation); otherwise the model is +assumed valid. + +Two things worth knowing if a model doesn't parse the way you expect: + +* Symbols usable in an observable formula are parameters, observables, and + functions -- *not* compartments. +* The reader accepts line continuations (a trailing ``\\``), ``begin + species`` as an alias for ``begin seed species``, line labels (both the + numeric ``1 L0 1`` and named ``CD14: ...`` forms), and a leading ``$`` + (fixed-concentration) marker on a seed species. +""" + +from __future__ import annotations + +import os +import re +import shutil +import subprocess +from collections.abc import Iterable +from dataclasses import dataclass +from pathlib import Path + +from ..._utils import _generate_path +from .. import is_valid_identifier +from . import MODEL_TYPE_BNGL +from .model import Model + +__all__ = ["BnglEntities", "BnglModel", "parse_bngl"] + +#: The three keywords that open an observable declaration line. +_OBS_KEYWORDS = frozenset({"Molecules", "Species", "Counter"}) + +#: Short spellings BNG2.pl accepts for a block's canonical (long) name; either +#: spelling opens and closes the same block. The grammar doc +#: (``BNG_vscode_extension/docs/bngl-grammar.md``) also lists ``molecules`` and +#: ``rules``, but BNG2.pl 2.9.3 -- the reference this reader targets -- rejects +#: both ("Could not process block type"), so honoring them would accept models +#: BNG2.pl refuses. Only ``species`` (for ``seed species``, also BNG2.pl's own +#: canonical output spelling) is real. +_BLOCK_ALIASES = { + "seed species": ("species",), +} + + +@dataclass(frozen=True) +class BnglEntities: + """The named entities of a BNGL model that the PEtab layer reads. + + :ivar text: The verbatim BNGL model text. + :ivar parameters: Maps a parameter name to its raw right-hand side -- a + number (``"5"``, ``"6.02e23"``) or an expression (``"2*base"``), + kept verbatim; numeric coercion is the caller's job. + :ivar observable_names: Bare observable names. + :ivar function_names: Bare global-function names (without ``()``). + :ivar molecule_type_names: Bare molecule-type names. + :ivar seed_species: Concrete seed-species pattern strings (verbatim). + :ivar compartment_names: Bare compartment names. + """ + + text: str + parameters: dict[str, str] + observable_names: frozenset[str] + function_names: frozenset[str] + molecule_type_names: frozenset[str] + seed_species: frozenset[str] + compartment_names: frozenset[str] + + +def parse_bngl(text: str) -> BnglEntities: + """Parse BNGL ``text`` into a :class:`BnglEntities`. + + A stdlib ``begin``/``end `` scanner -- no BNG2.pl, no network + generation. Sufficient for PEtab validation, which only introspects the + model's declared entities. + + :param text: The BNGL model text. + :returns: The model's named entities. + """ + parameters: dict[str, str] = {} + for line in _block_lines(text, "parameters"): + name_value = _parameter_name_value(line) + if name_value is not None: + parameters[name_value[0]] = name_value[1] + return BnglEntities( + text=text, + parameters=parameters, + observable_names=_names(text, "observables", _observable_name), + function_names=_names(text, "functions", _function_name), + molecule_type_names=_names( + text, "molecule types", _molecule_type_name + ), + seed_species=_names(text, "seed species", _seed_species_pattern), + compartment_names=_names(text, "compartments", _compartment_name), + ) + + +def _names(text: str, block_name: str, extractor) -> frozenset[str]: + """The non-empty names ``extractor`` yields over a block's lines.""" + return frozenset( + name + for name in ( + extractor(line) for line in _block_lines(text, block_name) + ) + if name + ) + + +def _logical_lines(text: str) -> list[str]: + """The comment-stripped *logical* lines of ``text`` -- physical lines with + BNGL line continuations joined. + + Mirrors BNG2.pl's ``readFile`` (``Perl2/BNGModel.pm``): strip the ``#`` + comment first, then while the line ends with ``\\`` (as its last + non-whitespace character) drop that ``\\`` and append the next + comment-stripped physical line *directly* -- no separating space, so a + token split across the break (``1e\\`` + ``3`` -> ``1e3``) rejoins. + Without this, a continued parameter / function / observable is truncated at + the ``\\`` (e.g. a ``k = \\`` line would read as the value ``"\\"``). + """ + raw_lines = text.splitlines() + out = [] + i, n = 0, len(raw_lines) + while i < n: + line = raw_lines[i].split("#", 1)[0] + i += 1 + while re.search(r"\\\s*$", line): + line = re.sub(r"\\\s*$", "", line) + if i >= n: + break # a dangling continuation at EOF + line += raw_lines[i].split("#", 1)[0] + i += 1 + out.append(line.strip()) + return out + + +def _block_lines(text: str, block_name: str) -> list[str]: + """The comment-stripped, non-blank lines inside ``begin``/``end``. + + ``block_name`` is the canonical (long) spelling; a BNG2.pl-accepted alias + for it (only ``species`` for ``seed species``; see :data:`_BLOCK_ALIASES`) + opens and closes the same block. Lines are logical lines -- continuations + joined (see :func:`_logical_lines`). + """ + names = "|".join( + re.escape(name) + for name in (block_name, *_BLOCK_ALIASES.get(block_name, ())) + ) + begin = re.compile(rf"^begin\s+(?:{names})\b", re.IGNORECASE) + end = re.compile(rf"^end\s+(?:{names})\b", re.IGNORECASE) + lines = [] + in_block = False + for line in _logical_lines(text): + if begin.match(line): + in_block = True + elif end.match(line): + in_block = False + elif in_block and line: + lines.append(line) + return lines + + +def _strip_line_label(line: str) -> str: + """Drop a leading BNGL line label so the entity, not the label, is read. + + ``LineLabel = {Digit}, WS | Name, ":", [WS]`` (grammar) -- either a numeric + index (the legacy ``.net``-style ``1 L0 1`` form) or a named label + (``CD14: CD14(...)``). A valid BNGL identifier starts with a letter, so a + leading digit-run is always an index; a compartment prefix is ``@Name:`` + (with the ``@``), so a bare ``Name:`` at line start is unambiguously a + label. + """ + match = re.match(r"^\d+\s+(.*)$", line) or re.match( + r"^[A-Za-z]\w*:\s+(.*)$", line + ) + return match.group(1) if match else line + + +def _parameter_name_value(line: str) -> tuple[str, str] | None: + """``(name, rhs)`` for a ``[LineLabel] Name (WS|"=") MathExpr`` line.""" + line = _strip_line_label(line) + match = re.match(r"^(\w+)\s*=\s*(.+)$", line) or re.match( + r"^(\w+)\s+(.+)$", line + ) + return (match.group(1), match.group(2).strip()) if match else None + + +def _observable_name(line: str) -> str | None: + """The name in a `` `` observable line.""" + tokens = line.split() + if len(tokens) >= 2 and tokens[0] in _OBS_KEYWORDS: + return tokens[1] + return None + + +def _function_name(line: str) -> str | None: + """The name in a ``() = ...`` global-function line.""" + match = re.match(r"(\w+)\s*\(", line) or re.match(r"(\w+)\s*=", line) + return match.group(1) if match else None + + +def _molecule_type_name(line: str) -> str | None: + """The name in a ``(...)`` molecule-type line.""" + match = re.match(r"(\w+)", line) + return match.group(1) if match else None + + +def _seed_species_pattern(line: str) -> str | None: + """The species pattern in a ``[LineLabel] ["$"] `` line. + + A leading line label (numeric index ``1 A() 100`` or named + ``CD14: CD14(...)``; see :func:`_strip_line_label`) is dropped first so the + label is not mistaken for the species. A leading ``$`` (the fixed/clamped- + concentration marker, ``SeedSpeciesDefn = ["$"], Species, WS, + MathExpression``) is a modifier, not part of the species identity, so it + too is stripped: ``$counter() 10`` + enumerates the state variable ``counter()``. + """ + line = _strip_line_label(line) + if line.startswith("$"): + line = line[1:].lstrip() + tokens = line.split() + return tokens[0] if tokens else None + + +def _compartment_name(line: str) -> str | None: + """The name in a `` [outside]`` line.""" + tokens = line.split() + return tokens[0] if tokens else None + + +class BnglModel(Model): + """PEtab wrapper for BNGL models.""" + + type_id = MODEL_TYPE_BNGL + + def __init__( + self, + model: BnglEntities, + model_id: str | None = None, + rel_path: Path | str | None = None, + base_path: str | Path | None = None, + ): + super().__init__() + + self.rel_path = rel_path + self.base_path = base_path + + self.model = model + self._model_id = model_id + + if not is_valid_identifier(self._model_id): + raise ValueError( + f"Model ID '{self._model_id}' is not a valid identifier. " + "Either provide a valid identifier or rename the model file " + "to a valid PEtab model identifier." + ) + + @staticmethod + def from_file( + filepath_or_buffer, + model_id: str | None = None, + base_path: str | Path | None = None, + ) -> BnglModel: + path = Path(_generate_path(filepath_or_buffer, base_path)) + text = path.read_text(encoding="utf-8", errors="replace") + return BnglModel( + model=parse_bngl(text), + model_id=model_id or path.stem, + rel_path=filepath_or_buffer, + base_path=base_path, + ) + + def to_file(self, filename: str | Path | None = None) -> None: + target = filename or _generate_path(self.rel_path, self.base_path) + with open(target, "w", encoding="utf-8") as f: + f.write(self.model.text) + + @property + def model_id(self): + return self._model_id + + @model_id.setter + def model_id(self, model_id): + self._model_id = model_id + + def get_parameter_ids(self) -> Iterable[str]: + return list(self.model.parameters) + + def get_parameter_value(self, id_: str) -> float: + try: + rhs = self.model.parameters[id_] + except KeyError as e: + raise ValueError(f"Parameter {id_} does not exist.") from e + try: + return float(rhs) + except ValueError as e: + raise NotImplementedError( + f"Parameter '{id_}' has an expression value '{rhs}'. " + "Evaluating a BNGL parameter expression requires BNG2.pl / " + "network generation, which is out of scope for the " + "introspection-only BnglModel." + ) from e + + def get_free_parameter_ids_with_values( + self, + ) -> Iterable[tuple[str, float]]: + out = [] + for name, rhs in self.model.parameters.items(): + try: + out.append((name, float(rhs))) + except ValueError: + # An expression-valued parameter has no introspection-grade + # value; skip it rather than evaluate the expression. + continue + return out + + def get_valid_parameters_for_parameter_table(self) -> Iterable[str]: + # All parameters are allowed in the parameter table. + return list(self.model.parameters) + + def has_entity_with_id(self, entity_id) -> bool: + # The full declared-identifier namespace. + return ( + entity_id in self.model.parameters + or entity_id in self.model.observable_names + or entity_id in self.model.function_names + or entity_id in self.model.molecule_type_names + or entity_id in self.model.compartment_names + or entity_id in self.model.seed_species + ) + + def get_valid_ids_for_condition_table(self) -> Iterable[str]: + return list(self.model.parameters) + list(self.model.compartment_names) + + def symbol_allowed_in_observable_formula(self, id_: str) -> bool: + # The BNG ParamList: parameters, observables, global functions only. + return ( + id_ in self.model.parameters + or id_ in self.model.observable_names + or id_ in self.model.function_names + ) + + def is_state_variable(self, id_: str) -> bool: + # At introspection grade only the concrete seed species are known; + # the full species set is a network-generation product. + return id_ in self.model.seed_species + + def is_valid(self) -> bool: + # Real BNG2.pl --check (parse/semantic validation, no network + # generation) when locatable, else True -- never a false failure + # where no BNG backend is available. + bng2 = _locate_bng2() + if bng2 is None or self.rel_path is None: + return True + path = Path(_generate_path(self.rel_path, self.base_path)) + if not path.is_file(): + # No local model file to check (e.g. a buffer-loaded model). + return True + # Resolve to an absolute path so BNG2.pl finds the model regardless + # of the working directory it runs in (its output stays next to the + # model via ``cwd``). + path = path.resolve() + try: + result = subprocess.run( # noqa: S603 + [bng2, "--check", str(path)], + capture_output=True, + text=True, + timeout=120, + cwd=str(path.parent), + check=False, + ) + except (OSError, subprocess.SubprocessError): + # A tooling hiccup must not masquerade as an invalid model. + return True + return result.returncode == 0 + + +def _locate_bng2() -> str | None: + """A path to ``BNG2.pl`` via ``BNGPATH`` or ``PATH``, else ``None``.""" + bngpath = os.environ.get("BNGPATH") + if bngpath: + candidate = Path(bngpath) / "BNG2.pl" + if candidate.is_file(): + return str(candidate) + return shutil.which("BNG2.pl") diff --git a/petab/v1/models/model.py b/petab/v1/models/model.py index c4f0b9ef..57c991dc 100644 --- a/petab/v1/models/model.py +++ b/petab/v1/models/model.py @@ -152,7 +152,12 @@ def model_factory( :param model_id: PEtab model ID for the given model :returns: A :py:class:`Model` instance representing the given model """ - from . import MODEL_TYPE_PYSB, MODEL_TYPE_SBML, known_model_types + from . import ( + MODEL_TYPE_BNGL, + MODEL_TYPE_PYSB, + MODEL_TYPE_SBML, + known_model_types, + ) if model_language == MODEL_TYPE_SBML: from .sbml_model import SbmlModel @@ -168,6 +173,13 @@ def model_factory( filepath_or_buffer, model_id=model_id, base_path=base_path ) + if model_language == MODEL_TYPE_BNGL: + from .bngl_model import BnglModel + + return BnglModel.from_file( + filepath_or_buffer, model_id=model_id, base_path=base_path + ) + if model_language in known_model_types: raise NotImplementedError( f"Unsupported model format: {model_language}" diff --git a/petab/v2/models/bngl_model.py b/petab/v2/models/bngl_model.py new file mode 100644 index 00000000..42df986d --- /dev/null +++ b/petab/v2/models/bngl_model.py @@ -0,0 +1,3 @@ +"""Functions for handling BNGL models""" + +from ...v1.models.bngl_model import * diff --git a/tests/v1/bngl/parabola.bngl b/tests/v1/bngl/parabola.bngl new file mode 100644 index 00000000..aa1e0245 --- /dev/null +++ b/tests/v1/bngl/parabola.bngl @@ -0,0 +1,30 @@ +begin model + + begin parameters + v1 5 + v2 5 + v3 5 + k_cond 1 + end parameters + + begin molecule types + counter() + end molecule types + + begin seed species + counter() -10 + end seed species + + begin observables + Molecules x counter() + end observables + + begin functions + y()=v1*(x^2)+(v2*x)+v3 + end functions + + begin reaction rules + 0->counter() 1 + end reaction rules + +end model diff --git a/tests/v1/bngl_corpus/README.md b/tests/v1/bngl_corpus/README.md new file mode 100644 index 00000000..ae591d4f --- /dev/null +++ b/tests/v1/bngl_corpus/README.md @@ -0,0 +1,36 @@ +# Curated BNGL corpus fixtures + +Public community BNGL models, chosen for feature coverage, backing +`tests/v1/test_bngl_corpus.py`: it asserts `parse_bngl` enumerates the same +model entities BNG2.pl does. The BNG2.pl answers are cached in `golden.json` +(the entity name sets BNG2.pl emits from `writeModel`, its canonical parse), +so the test needs **no BNG2.pl** -- it compares the reader against the frozen +oracle. Regenerate the golden (needs BNG2.pl) with: + + python tests/v1/test_bngl_corpus.py + +Vendored from public repos (RuleWorld/RuleHub, wshlavacek/BNGL-Models). + +| model | source | upstream path | exercises | +|---|---|---|---| +| `An_2009.bngl` | rulehub | `Published/An2009/An_2009.bngl` | labeled seed species; large rule-based (TLR signaling) | +| `Barua_2009__PATCHED.bngl` | rulehub | `Published/Barua2009/Barua_2009.bngl` | uses the `begin species` block alias | +| `Chattaraj_2021.bngl` | rulehub | `Published/Chattaraj2021/Chattaraj_2021.bngl` | indexed seed species | +| `LR.bngl` | rulehub | `Tutorials/NativeTutorials/LR/LR.bngl` | uses the `begin species` block alias | +| `LRR.bngl` | rulehub | `Tutorials/NativeTutorials/LRR/LRR.bngl` | uses the `begin species` block alias | +| `Motivating_example_cBNGL.bngl` | rulehub | `Tutorials/MotivatingexamplecBNGL/Motivating_example_cBNGL.bngl` | compartmental BNGL | +| `Ras_bistability_v2.bngl` | bngl_models | `my_models/ode/Ras_bistability_v2.bngl` | component reordering vs BNG canonical order | +| `Rule_based_egfr_tutorial.bngl` | rulehub | `Published/Rulebasedegfrtutorial/Rule_based_egfr_tutorial.bngl` | indexed seed species | +| `akt-signaling.bngl` | rulehub | `Examples/biology/aktsignaling/akt-signaling.bngl` | states + bonds; multisite phosphorylation | +| `apoptosis-cascade.bngl` | rulehub | `Examples/biology/apoptosiscascade/apoptosis-cascade.bngl` | states + bonds; compartment-free signaling | +| `bcr-signaling.bngl` | rulehub | `Examples/biology/bcrsignaling/bcr-signaling.bngl` | $ clamp; states + bonds (B-cell receptor) | +| `blood-coagulation-thrombin.bngl` | rulehub | `Examples/biology/bloodcoagulationthrombin/blood-coagulation-thrombin.bngl` | large rule-based; local functions | +| `bmp-signaling.bngl` | rulehub | `Examples/biology/bmpsignaling/bmp-signaling.bngl` | complex formation; $ clamp | +| `brusselator-oscillator.bngl` | rulehub | `Examples/biology/brusselatoroscillator/brusselator-oscillator.bngl` | small sanity model | +| `catalysis.bngl` | bngl_models | `my_models/ode/catalysis.bngl` | compartmental BNGL; $ clamp under @compartment prefix | +| `egg.bngl` | rulehub | `Published/Hlavacek2018Egg/egg.bngl` | line continuations; bare-molecule seed (t -> t()) | +| `elephant_EFA.bngl` | rulehub | `Published/Hlavacek2018Elephant/elephant_EFA.bngl` | bare-molecule seed; continuations | +| `energy_transport_pump.bngl` | rulehub | `Examples/energy/energytransportpump/energy_transport_pump.bngl` | energy patterns | +| `example1.bngl` | rulehub | `Tutorials/example1/example1.bngl` | indexed parameters + indexed seed species | +| `genetic_bistability_energy.bngl` | rulehub | `Examples/genetics/geneticbistabilityenergy/genetic_bistability_energy.bngl` | energy patterns | +| `immob_equiv_lig_sites.bngl` | bngl_models | `my_models/nf/immob_equiv_lig_sites.bngl` | line continuations in functions; local functions | diff --git a/tests/v1/bngl_corpus/golden.json b/tests/v1/bngl_corpus/golden.json new file mode 100644 index 00000000..c702e21a --- /dev/null +++ b/tests/v1/bngl_corpus/golden.json @@ -0,0 +1,1429 @@ +{ + "An_2009.bngl": { + "compartments": [], + "functions": [], + "molecule_types": [ + "A20", + "A20mRNA", + "Administer", + "CD14", + "DNA", + "IRAK1", + "IRAK4", + "IRAKM", + "IkB", + "IkBmRNA", + "Ikk_Complex", + "LPS", + "MAL", + "MD2", + "MyD88", + "MyD88s", + "NFkB", + "Proteasome26s", + "RP1", + "SARM", + "TAK1", + "TLR4", + "TNF", + "TNFmRNA", + "TRAF4", + "TRAF6", + "TRAM", + "TRIF", + "Tollip", + "Trash", + "iNOSmRNA" + ], + "observables": [ + "A20", + "A20_NFkB_DNA", + "Activated_Ikk_complex", + "Activated_TAK1", + "IkB_Degraded", + "IkB_Prot26s", + "IkB_active", + "IkBmRNA_Off", + "Inactive_Cyto_NFkB", + "NFkB_Active_Cyto", + "NFkB_Active_Nucleus", + "NFkB_DNA_IkB", + "NFkB_Inactive", + "NonBoundNonPhos_IkB", + "Phos_IkB_NFkB", + "TNF", + "TNF_NFkB_DNA", + "TNFmRNA_Off", + "Unbound_Cyto_NFkB" + ], + "parameters": [ + "A20_Degrade", + "A20_IkkAct_Deactivate", + "A20_Init", + "A20_MyD88IRAK1TRAF6_Degrade", + "A20_Preconditioned", + "A20_TRAF6TRIFRP1_Degrade", + "A20_TRAF6_Bind", + "A20_TRAF6_Unbind", + "A20_Transcription_Execute", + "A20_Translation_Execute", + "CD14_Init", + "CD14_MD2_Bind", + "CD14_MD2_Unbind", + "CD14_TLR4_Bind", + "CD14_TLR4_Unbind", + "DNA", + "IRAK1_IRAK4_Bind", + "IRAK1_IRAK4_Unbind", + "IRAK1_Init", + "IRAK4_Init", + "IkB_DegradeNFkB", + "IkB_Init", + "IkB_Proteasome23_Degrade", + "IkB_Transcription_Execute", + "IkB_Translation_Execute", + "Ikk_Complex_Init", + "Ikk_Deactivation", + "Ikk_Degradation_Rate", + "Ikk_complex_IkB_Phos", + "LPS_CD14_Bind", + "LPS_CD14_Unbind", + "LPS_Init", + "LPS_MD2_Bind", + "LPS_MD2_Unbind", + "LPS_TLR4_Bind", + "LPS_TLR4_Unbind", + "MAL_Init", + "MD2_Init", + "MD2_TLR4_Bind", + "MD2_TLR4_Unbind", + "MyD88IRAK1TRAF6_TAK1_Activate", + "MyD88IRAK1_TRAF6_B_Unbind", + "MyD88IRAK1_TRAF6_Bind", + "MyD88_IRAK1_Bind", + "MyD88_IRAK1_Unbind", + "MyD88_IRAK4_Bind", + "MyD88_IRAK4_Unbind", + "MyD88_Init", + "NFkB_DNA_A20_Bind", + "NFkB_DNA_A20_Unbind", + "NFkB_DNA_IkB_Bind", + "NFkB_DNA_IkB_Unbind", + "NFkB_DNA_TNF_Bind", + "NFkB_DNA_TNF_Unbind", + "NFkB_Degredation", + "NFkB_IkB_Bind", + "NFkB_IkB_Unbind", + "NFkB_Inactive_Cytoplasm", + "NFkB_Translocation_Nucleus", + "Proteasome23_Init", + "RP1_Init", + "RP1_TRAF6_Bind", + "RP1_TRAF6_Unbind", + "RP1_TRIF_Bind", + "RP1_TRIF_Unbind", + "TAK1_Deactivation", + "TAK1_Degradation", + "TAK1_Ikk_Complex_Activate", + "TAK1_Init", + "TLR4MAL_MyD88_Bind", + "TLR4MAL_MyD88_Unbind", + "TLR4TRAM_TRIF_Bind", + "TLR4TRAM_TRIF_Unbind", + "TLR4_Complex_Dimer_Bind", + "TLR4_Complex_Dimer_Unbind", + "TLR4_MAL_Bind", + "TLR4_MAL_Unbind", + "TLR4_TRAM_Bind", + "TLR4_TRAM_Unbind", + "TLR_Init", + "TNF_Degrade", + "TNF_Transcription_Execute", + "TNF_Translation_Execute", + "TRAF6TRIF_TAK1_Activate", + "TRAF6_Init", + "TRAF6_MyD88IRAK1_Bind", + "TRAF6_MyD88IRAK1_Unbind", + "TRAF6_TRIF_Bind", + "TRAF6_TRIF_Unbind", + "TRAM_Init", + "TRIF_Init", + "TRIF_TRAF6_Bind", + "TRIF_TRAF6_Unbind", + "p50_Init", + "p65_Init", + "p65_p50_Bind", + "p65_p50_Unbind" + ], + "seed_composition": [ + [ + "A20" + ], + [ + "CD14" + ], + [ + "DNA" + ], + [ + "IRAK1" + ], + [ + "IRAK4" + ], + [ + "IkB" + ], + [ + "Ikk_Complex" + ], + [ + "LPS" + ], + [ + "MAL" + ], + [ + "MD2" + ], + [ + "MyD88" + ], + [ + "NFkB" + ], + [ + "Proteasome26s" + ], + [ + "RP1" + ], + [ + "TAK1" + ], + [ + "TLR4" + ], + [ + "TRAF6" + ], + [ + "TRAM" + ], + [ + "TRIF" + ] + ] + }, + "Barua_2009__PATCHED.bngl": { + "compartments": [], + "functions": [], + "molecule_types": [ + "J", + "S" + ], + "observables": [ + "JS", + "JSS", + "JSSJ", + "J_active", + "J_inactive", + "J_mono" + ], + "parameters": [ + "Jtot", + "Stot", + "koff_SH2", + "koff_dimer", + "kon_SH2", + "kon_dimer", + "kphos_fast", + "kphos_slow" + ], + "seed_composition": [ + [ + "J" + ], + [ + "S" + ] + ] + }, + "Chattaraj_2021.bngl": { + "compartments": [], + "functions": [], + "molecule_types": [ + "NWASP", + "Nck", + "Nephrin" + ], + "observables": [ + "cluster_nck_nw", + "cluster_neph_nck_nw", + "free_NWASP", + "free_Nck", + "free_Nephrin", + "fully_bound_NWASP", + "fully_bound_Nck", + "fully_bound_Nephrin", + "tot_NWASP", + "tot_Nck", + "tot_Nephrin" + ], + "parameters": [ + "kd_12", + "kd_23", + "koff_12", + "koff_23", + "kon_12", + "kon_23" + ], + "seed_composition": [ + [ + "NWASP" + ], + [ + "Nck" + ], + [ + "Nephrin" + ] + ] + }, + "LR.bngl": { + "compartments": [], + "functions": [], + "molecule_types": [ + "L", + "R" + ], + "observables": [ + "Bound", + "FreeR" + ], + "parameters": [ + "L0", + "NaV", + "R0", + "Vcell", + "Vec", + "km1", + "kp1", + "lig_conc" + ], + "seed_composition": [ + [ + "L" + ], + [ + "R" + ] + ] + }, + "LRR.bngl": { + "compartments": [], + "functions": [], + "molecule_types": [ + "L", + "R" + ], + "observables": [ + "Bound", + "Dimers", + "FreeR" + ], + "parameters": [ + "Acell", + "L0", + "NaV", + "R0", + "Vcell", + "Vec", + "Vpm", + "d_pm", + "km1", + "kp1", + "lig_conc" + ], + "seed_composition": [ + [ + "L" + ], + [ + "R" + ] + ] + }, + "Motivating_example_cBNGL.bngl": { + "compartments": [ + "CP", + "EC", + "EM", + "EN", + "NM", + "NU", + "PM" + ], + "functions": [], + "molecule_types": [ + "DNA", + "Im", + "L", + "NP", + "P1", + "P2", + "R", + "Sink", + "TF", + "mRNA1", + "mRNA2" + ], + "observables": [ + "Bound_prom1", + "Bound_prom2", + "Catalytic_R", + "Catalytic_TF", + "CountSink", + "Im_CP", + "Im_Cargo_NP", + "Im_NU", + "L_Bound_EM", + "L_Bound_PM", + "L_Dimers_EC", + "L_Dimers_EM", + "L_Dimers_EN", + "L_Dimers_PM", + "P1_CP", + "P1_NU", + "P1_NU_dna", + "P1_NU_free", + "Phos_TF", + "R_Dimers_EM", + "R_Dimers_PM", + "TF_Dimer_CP", + "TF_Dimer_NU", + "Tot_DNA", + "Tot_Im", + "Tot_L", + "Tot_NP", + "Tot_P1", + "Tot_P2", + "Tot_R", + "Tot_TF", + "Tot_mRNA1", + "Tot_mRNA2" + ], + "parameters": [ + "DNA0", + "Im0", + "L0", + "NP0", + "R0", + "TF0", + "eff_width", + "k_Im_bind_CP", + "k_Im_bind_NU", + "k_Im_cross_NP", + "k_Im_enters_NP", + "k_Im_exits_NP", + "k_Im_unbind_CP", + "k_Im_unbind_NU", + "k_P_deg", + "k_R_dephos", + "k_R_endo", + "k_R_transphos", + "k_TF_dephos", + "k_TF_transphos", + "k_mRNA_deg", + "k_mRNA_to_CP", + "k_recycle", + "k_transcribe", + "k_translate", + "km_LL", + "km_LR", + "km_P1_p2", + "km_R_TF", + "km_R_TFp", + "km_TF_TF", + "km_TF_p1", + "kp_LL", + "kp_LR", + "kp_P1_p2", + "kp_R_TF", + "kp_R_TFp", + "kp_TF_TF", + "kp_TF_p1", + "nEndo", + "sa_EM", + "sa_NM", + "sa_PM", + "vol_CP", + "vol_EC", + "vol_EN", + "vol_NU" + ], + "seed_composition": [ + [ + "DNA" + ], + [ + "Im" + ], + [ + "L" + ], + [ + "NP" + ], + [ + "R" + ], + [ + "Sink" + ], + [ + "TF" + ] + ] + }, + "Ras_bistability_v2.bngl": { + "compartments": [], + "functions": [], + "molecule_types": [ + "CRAF", + "GDP", + "GTP", + "KRAS", + "KRASG12D", + "RASA1", + "RTK", + "SOS1" + ], + "observables": [ + "ActiveEffector", + "CRAFtot", + "Gtot", + "KRAStot", + "RASA1tot", + "RTKbndGAP", + "RTKbndGEF", + "RTKtot", + "RasGDP", + "RasGTP", + "SOS1tot" + ], + "parameters": [ + "CRAF_num", + "GDP_num", + "GTP_num", + "K1", + "K5", + "KRAS_WT_num", + "KRAS_mut_num", + "KRAS_tot_num", + "NA", + "RASA1_num", + "RTK_num", + "SOS1_num", + "Vcyto", + "Vpm", + "f", + "fmut", + "kaEff", + "kaGAP_cyto", + "kaGAP_pm", + "kaGEF_cyto", + "kaGEF_pm", + "kcat", + "kdEff", + "kdGAP", + "kdGEF", + "kf_cyto", + "kf_pm", + "khyd", + "km1", + "km2", + "km3", + "km4", + "km5", + "km6", + "km7", + "kp1", + "kp2_cyto", + "kp2_pm", + "kp3_cyto", + "kp3_pm", + "kp4", + "kp5", + "kp6", + "kp7_cyto", + "kp7_pm", + "kr" + ], + "seed_composition": [ + [ + "CRAF" + ], + [ + "GDP" + ], + [ + "GTP" + ], + [ + "KRAS" + ], + [ + "KRAS" + ], + [ + "KRAS" + ], + [ + "KRASG12D" + ], + [ + "KRASG12D" + ], + [ + "KRASG12D" + ], + [ + "RASA1" + ], + [ + "RTK" + ], + [ + "SOS1" + ] + ] + }, + "Rule_based_egfr_tutorial.bngl": { + "compartments": [ + "c0" + ], + "functions": [], + "molecule_types": [ + "EGF", + "EGFR", + "Grb2", + "Shc" + ], + "observables": [ + "Dimers", + "Dimers_s", + "O0_EGFR_tot", + "O0_EGF_tot", + "O0_Grb2_tot", + "O0_Shc_tot", + "Y1", + "Y2", + "Y_total" + ], + "parameters": [], + "seed_composition": [ + [ + "EGF" + ], + [ + "EGFR" + ], + [ + "Grb2" + ], + [ + "Shc" + ], + [ + "Shc" + ] + ] + }, + "akt-signaling.bngl": { + "compartments": [], + "functions": [], + "molecule_types": [ + "AKT", + "GrowthFactor", + "PI3K", + "RTK", + "S6K", + "mTORC1", + "mTORC2" + ], + "observables": [ + "Active_PI3K", + "Active_RTK", + "Active_mTORC1", + "Double_pAkt", + "pAkt_S473", + "pAkt_T308" + ], + "parameters": [ + "AKT_tot", + "GF_tot", + "PI3K_tot", + "RTK_tot", + "S6K_tot", + "k_akt_s473", + "k_akt_t308", + "k_bind", + "k_fb", + "k_mtorc1", + "k_pi3k", + "k_reset", + "k_rtk_act", + "k_s6k_act", + "k_unbind", + "mTORC1_tot", + "mTORC2_tot" + ], + "seed_composition": [ + [ + "AKT" + ], + [ + "GrowthFactor" + ], + [ + "PI3K" + ], + [ + "RTK" + ], + [ + "S6K" + ], + [ + "mTORC1" + ], + [ + "mTORC2" + ] + ] + }, + "apoptosis-cascade.bngl": { + "compartments": [], + "functions": [ + "v_momp" + ], + "molecule_types": [ + "Apaf1", + "Bid", + "Caspase3", + "Caspase8", + "DeathLigand", + "Mito", + "SMAC", + "XIAP" + ], + "observables": [ + "Active_Bid", + "CytoC_Released", + "Global_Death", + "Inhibitor_Sequest", + "Mitochondria_Gate", + "Mitochondrial_Fail" + ], + "parameters": [ + "Apaf1_tot", + "Bid_tot", + "C3_tot", + "C8_tot", + "Km_tbid", + "MOMP_sites", + "SMAC_tot", + "XIAP_tot", + "k_apaf_act", + "k_bid_trunc", + "k_c3_act", + "k_ligand", + "k_momp_max", + "k_smac_rec", + "k_xiap_bind", + "n_hill" + ], + "seed_composition": [ + [ + "Apaf1" + ], + [ + "Bid" + ], + [ + "Caspase3" + ], + [ + "Caspase8" + ], + [ + "DeathLigand" + ], + [ + "Mito" + ], + [ + "SMAC" + ], + [ + "XIAP" + ] + ] + }, + "bcr-signaling.bngl": { + "compartments": [], + "functions": [ + "v_ca" + ], + "molecule_types": [ + "Antigen", + "BCR", + "CD22", + "Calcium", + "PLCg2", + "SHP1", + "Syk" + ], + "observables": [ + "Active_PLCg2", + "Active_Relay", + "Calcium_Signal", + "Effector_Load", + "Negative_Brake", + "Sync_Antigen" + ], + "parameters": [ + "Antigen_tot", + "BCR_tot", + "CD22_tot", + "Ca_cyt", + "Km_ca", + "PLCg2_tot", + "SHP1_tot", + "Syk_tot", + "k_bind_ag", + "k_ca_max", + "k_dephos_bcr", + "k_endo", + "k_lyn_phos", + "k_plcg_act", + "k_shp_rec", + "k_syk_act", + "n_hill" + ], + "seed_composition": [ + [ + "Antigen" + ], + [ + "BCR" + ], + [ + "CD22" + ], + [ + "Calcium" + ], + [ + "PLCg2" + ], + [ + "SHP1" + ], + [ + "Syk" + ] + ] + }, + "blood-coagulation-thrombin.bngl": { + "compartments": [], + "functions": [], + "molecule_types": [ + "AT", + "FactorV", + "FactorX", + "Fibrinogen", + "Prothrombin", + "TF", + "Thrombin" + ], + "observables": [ + "Active_Thrombin", + "Clot_Mass", + "Feedback_Factors", + "Inhibitor_Work", + "Proth_Complex" + ], + "parameters": [ + "ATIII_tot", + "FactorV_tot", + "FactorX_tot", + "Fibrino_tot", + "Km_clot", + "Prothrom_tot", + "TF_sites", + "k_at_inh", + "k_complex_va_xa", + "k_deg_complex", + "k_fibrin_synth", + "k_pt_burst", + "k_tf_vii", + "k_tpi_inh", + "k_v_act", + "k_viii_act", + "k_xa_act" + ], + "seed_composition": [ + [ + "AT" + ], + [ + "FactorV" + ], + [ + "FactorX" + ], + [ + "Fibrinogen" + ], + [ + "Prothrombin" + ], + [ + "TF" + ] + ] + }, + "bmp-signaling.bngl": { + "compartments": [], + "functions": [ + "v_feedback" + ], + "molecule_types": [ + "BMP", + "Noggin", + "Receptor1", + "Receptor2", + "Smad1", + "Smad4", + "Smad6" + ], + "observables": [ + "Active_Nuclear_Smad1", + "Feedback_Brake", + "Noggin_Sink", + "SMAD_Output", + "Signal_Complex", + "Surface_Engage" + ], + "parameters": [ + "BMP_tot", + "Noggin_init", + "R1_tot", + "R2_tot", + "Smad1_tot", + "Smad4_tot", + "Smad6_init", + "k_bmp_bind", + "k_complex", + "k_export", + "k_import", + "k_noggin_bind", + "k_smad6_inh", + "k_smad6_synth", + "k_smad_phos" + ], + "seed_composition": [ + [ + "BMP" + ], + [ + "Noggin" + ], + [ + "Receptor1" + ], + [ + "Receptor2" + ], + [ + "Smad1" + ], + [ + "Smad4" + ], + [ + "Smad6" + ] + ] + }, + "brusselator-oscillator.bngl": { + "compartments": [], + "functions": [ + "v_auto", + "v_decay", + "v_supply" + ], + "molecule_types": [ + "A", + "B", + "X", + "Y" + ], + "observables": [ + "A_Level", + "Activator_X", + "Drive_Force", + "Feed_Status", + "Inhibitor_Y", + "Ratio_XY", + "X_Level" + ], + "parameters": [ + "A_pool", + "B_drive", + "Km_a", + "Km_x", + "X_init", + "Y_init", + "k_auto", + "k_cross", + "k_decay_max", + "k_temp_mod", + "v_supply_max", + "v_temp_current" + ], + "seed_composition": [ + [ + "A" + ], + [ + "B" + ], + [ + "X" + ], + [ + "Y" + ] + ] + }, + "catalysis.bngl": { + "compartments": [ + "C" + ], + "functions": [], + "molecule_types": [ + "ADP", + "ATP", + "S", + "kinase", + "pptase" + ], + "observables": [ + "S_kinase", + "S_pptase", + "Sp", + "Stot", + "kinaseTot", + "pptaseTot" + ], + "parameters": [ + "Ea0_S_kinase", + "Ea0_S_pptase", + "Ea0_cat_kinase", + "Ea0_cat_pptase", + "Gf_ATP", + "Gf_S_kinase", + "Gf_S_pptase", + "Gf_Sp", + "NA", + "RT", + "conc_ADP_0", + "conc_ATP_0", + "conc_S_0", + "conc_kinase_0", + "conc_pptase_0", + "phi", + "volC" + ], + "seed_composition": [ + [ + "ADP" + ], + [ + "ATP" + ], + [ + "S" + ], + [ + "kinase" + ], + [ + "pptase" + ] + ] + }, + "egg.bngl": { + "compartments": [], + "functions": [ + "X", + "Y" + ], + "molecule_types": [ + "t" + ], + "observables": [ + "t" + ], + "parameters": [ + "a0", + "a0__FREE", + "a1", + "a1__FREE", + "a2", + "a2__FREE", + "b1", + "b1__FREE", + "b2", + "b2__FREE", + "c0", + "c0__FREE", + "c1", + "c1__FREE", + "c2", + "c2__FREE", + "d1", + "d1__FREE", + "d2", + "d2__FREE", + "m", + "period", + "pi" + ], + "seed_composition": [ + [ + "t" + ] + ] + }, + "elephant_EFA.bngl": { + "compartments": [], + "functions": [ + "X", + "Y" + ], + "molecule_types": [ + "t" + ], + "observables": [ + "t" + ], + "parameters": [ + "a0", + "a1", + "a10", + "a11", + "a12", + "a13", + "a14", + "a15", + "a16", + "a17", + "a18", + "a19", + "a2", + "a20", + "a3", + "a4", + "a5", + "a6", + "a7", + "a8", + "a9", + "b0", + "b1", + "b10", + "b11", + "b12", + "b13", + "b14", + "b15", + "b16", + "b17", + "b18", + "b19", + "b2", + "b20", + "b3", + "b4", + "b5", + "b6", + "b7", + "b8", + "b9", + "c0", + "c1", + "c10", + "c11", + "c12", + "c13", + "c14", + "c15", + "c16", + "c17", + "c18", + "c19", + "c2", + "c20", + "c3", + "c4", + "c5", + "c6", + "c7", + "c8", + "c9", + "d0", + "d1", + "d10", + "d11", + "d12", + "d13", + "d14", + "d15", + "d16", + "d17", + "d18", + "d19", + "d2", + "d20", + "d3", + "d4", + "d5", + "d6", + "d7", + "d8", + "d9", + "m", + "period", + "pi" + ], + "seed_composition": [ + [ + "t" + ] + ] + }, + "energy_transport_pump.bngl": { + "compartments": [], + "functions": [], + "molecule_types": [ + "A", + "ADP", + "ATP", + "Pi", + "T" + ], + "observables": [ + "A_in", + "A_out", + "Energy_Source" + ], + "parameters": [ + "ADP_conc", + "ATP_conc", + "Atot", + "Ea_transport", + "G_ADP", + "G_ATP", + "G_A_in", + "G_A_out", + "G_Pi", + "Pi_conc", + "RT", + "phi" + ], + "seed_composition": [ + [ + "A" + ], + [ + "ADP" + ], + [ + "ATP" + ], + [ + "Pi" + ], + [ + "T" + ] + ] + }, + "example1.bngl": { + "compartments": [], + "functions": [], + "molecule_types": [], + "observables": [ + "A_R", + "R_dim", + "R_phos" + ], + "parameters": [ + "A0", + "L0", + "R0", + "d1", + "km1", + "km2", + "kmA", + "kp1", + "kp2", + "kpA", + "p1" + ], + "seed_composition": [ + [ + "A" + ], + [ + "L" + ], + [ + "R" + ] + ] + }, + "genetic_bistability_energy.bngl": { + "compartments": [], + "functions": [], + "molecule_types": [ + "GeneA", + "GeneB", + "ProtA", + "ProtB" + ], + "observables": [ + "A", + "B", + "GeneA_Free", + "GeneB_Free" + ], + "parameters": [ + "Ea_bind", + "G_Gene", + "G_Protein", + "G_bind", + "G_coop", + "RT", + "k_deg", + "k_synth", + "phi" + ], + "seed_composition": [ + [ + "GeneA" + ], + [ + "GeneB" + ], + [ + "ProtA" + ], + [ + "ProtB" + ] + ] + }, + "immob_equiv_lig_sites.bngl": { + "compartments": [], + "functions": [ + "avg_agg_size_R1", + "avg_agg_size_R2", + "large_T2", + "mobility_factor_T2", + "twobigprod_T2a", + "twobigprod_T2b" + ], + "molecule_types": [ + "L1", + "L2", + "R1", + "R2" + ], + "observables": [ + "L1free", + "L2free", + "R1_naggs", + "R1free", + "R1tot", + "R2_naggs", + "R2free", + "R2tot", + "T2aL1", + "T2aL2", + "T2aL3", + "T2aL4", + "T2aL5", + "T2aL6", + "T2aL7", + "T2aL8", + "T2aS1", + "T2aS2", + "T2aS3", + "T2aS4", + "T2bL1", + "T2bL2", + "T2bL3", + "T2bL4", + "T2bL5", + "T2bL6", + "T2bL7", + "T2bL8", + "T2bS1", + "T2bS2", + "T2bS3", + "T2bS4", + "monomericR1", + "monomericR2" + ], + "parameters": [ + "Lconc", + "Lconc_nM", + "LcopyNum", + "NA", + "RcopyNum", + "T2", + "T3", + "Vecf", + "delta", + "epsilon", + "f", + "kf", + "kmx", + "kpx", + "kr", + "rho", + "sigma", + "valL", + "valR" + ], + "seed_composition": [ + [ + "L1" + ], + [ + "L2" + ], + [ + "R1" + ], + [ + "R2" + ] + ] + } +} diff --git a/tests/v1/fetch_bngl_corpus.py b/tests/v1/fetch_bngl_corpus.py new file mode 100755 index 00000000..21e9d721 --- /dev/null +++ b/tests/v1/fetch_bngl_corpus.py @@ -0,0 +1,324 @@ +#!/usr/bin/env python3 +"""Materialize the BNGL test corpus backing ``tests/v1/test_bngl_corpus.py``. + +The corpus is 21 third-party ``.bngl`` fixture files. +Rather than vendoring ~3200 lines of model text, this script fetches them on +demand at test/CI time. Sources: + + rulehub RuleWorld/RuleHub + bngl_models wshlavacek/BNGL-Models + +Only a tiny manifest (the ``CORPUS`` table below) is committed -- which +model, which pinned commit, which path, its sha256 -- and the real bytes are +fetched at test/CI time. Same idea as lanl/bngsim's +``parity_checks/bng_parity/vendor_corpus.py``. + +Usage: + python tests/v1/fetch_bngl_corpus.py [--dest DIR] [--force] [--dry-run] +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +# Running this file directly makes the interpreter prepend its own directory +# to sys.path. The `math/` directory then shadows the stdlib `math` module, +# resulting in an ImportError. Drop that entry before importing anything that +# could pull in `math`. +_SCRIPT_DIR = str(Path(__file__).resolve().parent) +if sys.path and sys.path[0] == _SCRIPT_DIR: + del sys.path[0] + +import argparse # noqa: E402 -- must follow the sys.path fix above +import hashlib # noqa: E402 +import urllib.error # noqa: E402 +import urllib.request # noqa: E402 + +JSDELIVR_GH = "https://cdn.jsdelivr.net/gh/{repo}@{sha}/{path}" + +# GitHub repo pins +_RULEHUB = ("RuleWorld/RuleHub", "479d6d62a175572f28b2f6b6d7a376b6d1132da2") +_BNGL_MODELS = ( + "wshlavacek/BNGL-Models", + "81c90d8f58354a925651859c94142149afefc4b1", +) + +# (dest filename, (repo, sha), upstream path, expected sha256 of the RAW +# upstream bytes, optional (find, replace, expected_count) repair applied +# after the hash check). Transcribed from +# tests/v1/bngl_corpus/README.md's provenance table. +CORPUS = [ + ( + "An_2009.bngl", + _RULEHUB, + "Published/An2009/An_2009.bngl", + "1b48efd6fc190988faa5eb28fed78af492f49f8fdf67f064436fbf0f14b634e9", + None, + ), + ( + "Barua_2009__PATCHED.bngl", + _RULEHUB, + "Published/Barua2009/Barua_2009.bngl", + "26ca5053c4a340b597b2d839edd736469fc14cc2a03cf96b0447b7537089c454", + ("atoll=>", "atol=>", 1), + ), + ( + "Chattaraj_2021.bngl", + _RULEHUB, + "Published/Chattaraj2021/Chattaraj_2021.bngl", + "86d43fdf6d5490acb0aa8ba473d92822b18b4af6f2f28a00a3b2ed91505399ed", + None, + ), + ( + "LR.bngl", + _RULEHUB, + "Tutorials/NativeTutorials/LR/LR.bngl", + "e1c72a8f760110a354e0e3e4d41c1fbba504d4d6888e4df2ae91413146d529cb", + None, + ), + ( + "LRR.bngl", + _RULEHUB, + "Tutorials/NativeTutorials/LRR/LRR.bngl", + "b67d551f6d77a74414c2afc9c390b650ccf77d51cebfee45cba9d6ba91f255f3", + None, + ), + ( + "Motivating_example_cBNGL.bngl", + _RULEHUB, + "Tutorials/MotivatingexamplecBNGL/Motivating_example_cBNGL.bngl", + "b3086d656e089a57bbf6d8f31e5ddb6b1cdade598b3bc062a31e2af99ea7482e", + None, + ), + ( + "Ras_bistability_v2.bngl", + _BNGL_MODELS, + "my_models/ode/Ras_bistability_v2.bngl", + "de36f9a416dcc42189d848c32ba3600f07952b375b2cb7248546a11b7b80f731", + None, + ), + ( + "Rule_based_egfr_tutorial.bngl", + _RULEHUB, + "Published/Rulebasedegfrtutorial/Rule_based_egfr_tutorial.bngl", + "56a51fed773324a063c7a1b5e33b0426c1e9d70ab726d20f1b02aa5798fe747a", + None, + ), + ( + "akt-signaling.bngl", + _RULEHUB, + "Examples/biology/aktsignaling/akt-signaling.bngl", + "c89be2edd80d4406571b770c2f1ac39e37e5a2a84a843a5d06390e2ca0070f4f", + None, + ), + ( + "apoptosis-cascade.bngl", + _RULEHUB, + "Examples/biology/apoptosiscascade/apoptosis-cascade.bngl", + "4a8c3bc5248a3245cbce35dc274f4f8f9906f8389da352b0f994e739fc1be050", + None, + ), + ( + "bcr-signaling.bngl", + _RULEHUB, + "Examples/biology/bcrsignaling/bcr-signaling.bngl", + "c07947fbe462dba01d0b44ccc77b4e91d3d1bf5a90371944beb4ef96c5ccc50e", + None, + ), + ( + "blood-coagulation-thrombin.bngl", + _RULEHUB, + "Examples/biology/bloodcoagulationthrombin/blood-coagulation-thrombin.bngl", + "a0ba1f78f4e48592c94d45aff44305370d9a0ca597301ffe593400ad5f6d8c94", + None, + ), + ( + "bmp-signaling.bngl", + _RULEHUB, + "Examples/biology/bmpsignaling/bmp-signaling.bngl", + "fdacb14cddae7fdbdae472800602e03a55642adc92e6a51fc4636867236a2a61", + None, + ), + ( + "brusselator-oscillator.bngl", + _RULEHUB, + "Examples/biology/brusselatoroscillator/brusselator-oscillator.bngl", + "2c86990fc8e36a4a814cdb48a076fae4a359de5b5ffe8dfead15980c515aef67", + None, + ), + ( + "catalysis.bngl", + _BNGL_MODELS, + "my_models/ode/catalysis.bngl", + "7910543d941a1de6b4e8010014a2ee0bf4bc6660665c54bafc56432c5a458256", + None, + ), + ( + "egg.bngl", + _RULEHUB, + "Published/Hlavacek2018Egg/egg.bngl", + "603abf5da09d5eab5ec40816ba558d4b963a0b7faebd676398790980ded8b9ab", + None, + ), + ( + "elephant_EFA.bngl", + _RULEHUB, + "Published/Hlavacek2018Elephant/elephant_EFA.bngl", + "79846c26b8250d6b470a4481d02a2a5d2bebc9fc56c72450f7af7659e7dfc07c", + None, + ), + ( + "energy_transport_pump.bngl", + _RULEHUB, + "Examples/energy/energytransportpump/energy_transport_pump.bngl", + "c5ec8034bf0286942c7cc63c1382d49ec11b6b6cb8366b55e4b838e98a9b2ea3", + None, + ), + ( + "example1.bngl", + _RULEHUB, + "Tutorials/example1/example1.bngl", + "e9aa128804069907a7b8e8b64c498de88d9339cbc312dd5c8abc0cb40a089d64", + None, + ), + ( + "genetic_bistability_energy.bngl", + _RULEHUB, + "Examples/genetics/geneticbistabilityenergy/genetic_bistability_energy.bngl", + "c1b41ff63d7b3209cb30157f7e8be0f34b32afd8ebbd779165fcecffbfc6d59e", + None, + ), + ( + "immob_equiv_lig_sites.bngl", + _BNGL_MODELS, + "my_models/nf/immob_equiv_lig_sites.bngl", + "3ec97bac71f19cc65b8762dc9d6baae091b725b528366a80718d17aeab3221e3", + None, + ), +] + + +class CorpusError(Exception): + pass + + +def fetch_one( + dest_name, repo_pin, upstream_path, expected_sha256, repair, *, dry_run +): + """Fetch, verify, and (optionally) repair one corpus file. + + Returns the final bytes. + """ + repo, sha = repo_pin + url = JSDELIVR_GH.format(repo=repo, sha=sha, path=upstream_path) + if dry_run: + print(f" [dry-run] would fetch {url}") + return None + + try: + with urllib.request.urlopen(url, timeout=30) as resp: # noqa: S310 -- pinned https CDN URL + raw = resp.read() + except urllib.error.URLError as exc: + raise CorpusError( + f"fetch failed for {dest_name} ({url}): {exc}" + ) from exc + + digest = hashlib.sha256(raw).hexdigest() + if digest != expected_sha256: + raise CorpusError( + f"sha256 mismatch for {dest_name}: expected {expected_sha256}, " + f"got {digest} -- upstream {repo}@{sha[:12]}:{upstream_path} " + "changed or the fetch was tampered with; re-verify before " + "trusting this content." + ) + + text = raw.decode("utf-8", errors="replace") + if repair is not None: + find, replace, expected_count = repair + n = text.count(find) + if n != expected_count: + raise CorpusError( + f"repair for {dest_name} expected {expected_count}x " + f"{find!r} but found {n} -- upstream changed; re-verify " + "the repair before applying it" + ) + text = text.replace(find, replace) + + return text.encode("utf-8") + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument( + "--dest", + type=Path, + default=Path(__file__).parent / "bngl_corpus", + help="directory to write fetched .bngl files into (default: " + "bngl_corpus/ next to this script, i.e. tests/v1/bngl_corpus)", + ) + ap.add_argument( + "--force", + action="store_true", + help="re-fetch even if the file already exists", + ) + ap.add_argument( + "--dry-run", + action="store_true", + help="print what would be fetched; write nothing", + ) + args = ap.parse_args() + + n_fetched = n_skipped = n_failed = 0 + failures = [] + + for dest_name, repo_pin, upstream_path, expected_sha256, repair in CORPUS: + dest_path = args.dest / dest_name + if dest_path.exists() and not args.force and not args.dry_run: + print( + f"skip {dest_name} " + "(already present; use --force to re-fetch)" + ) + n_skipped += 1 + continue + + print( + f"fetch {dest_name} <- " + f"{repo_pin[0]}@{repo_pin[1][:12]}:{upstream_path}" + ) + try: + content = fetch_one( + dest_name, + repo_pin, + upstream_path, + expected_sha256, + repair, + dry_run=args.dry_run, + ) + except CorpusError as exc: + print(f" FAILED: {exc}") + failures.append(str(exc)) + n_failed += 1 + continue + + if args.dry_run: + continue + + args.dest.mkdir(parents=True, exist_ok=True) + dest_path.write_bytes(content) + print(f" wrote {len(content)} bytes, sha256 verified") + n_fetched += 1 + + print( + f"\nfetched {n_fetched}, skipped {n_skipped}, failed {n_failed} " + f"(of {len(CORPUS)} total)" + ) + if failures: + print("\nfailures:") + for f in failures: + print(f" - {f}") + return 1 if n_failed else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/v1/test_bngl_corpus.py b/tests/v1/test_bngl_corpus.py new file mode 100644 index 00000000..aa49f7d3 --- /dev/null +++ b/tests/v1/test_bngl_corpus.py @@ -0,0 +1,187 @@ +"""Corpus regression gate: the BNGL reader agrees with BNG2.pl on real models. + +Asserts :func:`petab.v1.models.bngl_model.parse_bngl` enumerates the same model +entities BNG2.pl does, over a curated set of public community BNGL models under +``bngl_corpus/``. BNG2.pl's answers are cached in ``bngl_corpus/golden.json``: +the entity name sets BNG2.pl emits from ``writeModel`` (its canonical parse, no +network generation). So the test itself needs **no BNG2.pl** -- it compares the +reader's output against the frozen oracle. Seed species are compared by +molecule composition, which absorbs BNG2.pl's pattern canonicalization (``t`` +vs ``t()``, component reordering, ``@compartment`` prefix vs suffix) while +still catching a genuinely missing or invented species. + +Regenerate the golden after adding a fixture or changing the reader (needs a +BNG2.pl on ``BNGPATH``/``PATH``) and review the diff:: + + python tests/v1/test_bngl_corpus.py +""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +import pytest + +from petab.v1.models.bngl_model import parse_bngl + +_CORPUS = Path(__file__).parent / "bngl_corpus" +_GOLDEN = _CORPUS / "golden.json" +_MODELS = sorted(_CORPUS.glob("*.bngl")) +_NO_CORPUS_REASON = ( + "no .bngl files in tests/v1/bngl_corpus/ -- fetch them first with " + "`python tests/v1/fetch_bngl_corpus.py`" +) + + +def _molecule_name(part): + """The molecule name in one ``.``-separated piece of a species pattern, + ignoring an ``@compartment:`` prefix, a ``$`` clamp, and any components. + + BNG2.pl writes the compartment prefix before the clamp (``@C::$ADP()``), so + the prefix is stripped first, then the ``$``. + """ + part = part.strip() + prefix = re.match(r"@\w+::?", part) + if prefix: + part = part[prefix.end() :] + part = part.lstrip("$") + name = re.match(r"(\w+)", part) + return name.group(1) if name else None + + +def seed_composition(patterns): + """Reordering-robust seed-species signature: each species as its sorted + molecule-name list, the whole sorted. JSON-safe (lists, not tuples).""" + sig = [] + for pattern in patterns: + mols = sorted( + n for n in (_molecule_name(p) for p in pattern.split(".")) if n + ) + sig.append(mols) + return sorted(sig) + + +def _reader_entities(entities): + """The entity name sets the golden records / the reader is checked against. + BNG2.pl-generated ``_``-names (a valid BNGL name starts with a letter) are + excluded from both sides.""" + + def named(names): + return sorted(n for n in names if not n.startswith("_")) + + return { + "parameters": named(entities.parameters), + "observables": sorted(entities.observable_names), + "functions": named(entities.function_names), + "molecule_types": sorted(entities.molecule_type_names), + "compartments": sorted(entities.compartment_names), + "seed_composition": seed_composition(entities.seed_species), + } + + +@pytest.mark.parametrize( + "model", + _MODELS + or [pytest.param(None, marks=pytest.mark.skip(reason=_NO_CORPUS_REASON))], + ids=lambda p: p.stem if p else "no-corpus", +) +def test_reader_matches_bng2_golden(model): + golden = json.loads(_GOLDEN.read_text()) + expected = golden[model.name] + actual = _reader_entities(parse_bngl(model.read_text())) + assert actual == expected, ( + f"{model.name}: reader disagrees with the BNG2.pl golden -- " + f"regenerate with `python {__file__}` if the change is intended" + ) + + +# -------------------------------------------------------------------------- +# Golden regeneration -- dev-only, needs BNG2.pl. Runs `writeModel` on each +# model's definition blocks (actions stripped, so no network generation), +# re-parses the canonical BNGL BNG2.pl emits, and records its entity name sets. +# -------------------------------------------------------------------------- + +_MODEL_BLOCKS = frozenset( + { + "parameters", + "molecule types", + "molecules", + "seed species", + "species", + "observables", + "functions", + "compartments", + "reaction rules", + "rules", + "energy patterns", + "population types", + "population maps", + } +) + + +def _strip_to_model(text): + """The model-definition blocks plus a single ``writeModel`` action; drops + actions (even nested in ``begin model``) and bare top-level directives.""" + out, stack = [], [] + for line in text.splitlines(): + s = line.split("#", 1)[0].strip() + begin = re.match(r"begin\s+(.+)", s, re.IGNORECASE) + end = re.match(r"end\s+(.+)", s, re.IGNORECASE) + if begin: + stack.append(begin.group(1).strip().lower()) + if stack[-1] != "actions": + out.append(line) + elif end: + top = stack.pop() if stack else None + if top != "actions": + out.append(line) + elif "actions" not in stack and [b for b in stack if b != "model"]: + out.append(line) + return "\n".join(out) + '\nwriteModel({prefix=>"canon"})\n' + + +def _canonical_bngl(model_text, bng2): + work = tempfile.mkdtemp(prefix="bnggold_") + try: + (Path(work) / "in.bngl").write_text(_strip_to_model(model_text)) + result = subprocess.run( # noqa: S603 + [bng2, "in.bngl"], + cwd=work, + capture_output=True, + text=True, + timeout=120, + check=False, + ) + canon = Path(work) / "canon.bngl" + if result.returncode != 0 or not canon.is_file(): + raise RuntimeError( + f"BNG2.pl rejected the model:\n{result.stdout}{result.stderr}" + ) + return canon.read_text(encoding="utf-8", errors="replace") + finally: + shutil.rmtree(work, ignore_errors=True) + + +def _regenerate(): + from petab.v1.models.bngl_model import _locate_bng2 + + bng2 = _locate_bng2() + if bng2 is None: + sys.exit("No BNG2.pl found (set BNGPATH or put it on PATH).") + golden = {} + for model in _MODELS: + canon = _canonical_bngl(model.read_text(), bng2) + golden[model.name] = _reader_entities(parse_bngl(canon)) + _GOLDEN.write_text(json.dumps(golden, indent=1, sort_keys=True) + "\n") + print(f"wrote {_GOLDEN} ({len(golden)} models)") + + +if __name__ == "__main__": + _regenerate() diff --git a/tests/v1/test_model_bngl.py b/tests/v1/test_model_bngl.py new file mode 100644 index 00000000..b4641faf --- /dev/null +++ b/tests/v1/test_model_bngl.py @@ -0,0 +1,340 @@ +"""Tests for petab.v1.models.bngl_model""" + +from pathlib import Path + +import pytest + +from petab.v1.models.bngl_model import BnglEntities, BnglModel, parse_bngl + +#: A self-contained BNGL model exercising every entity kind read by the +#: PEtab layer: parameters, a molecule type, a seed species, an observable, +#: and a global function (the parabola measurement model). +EXAMPLE_BNGL = Path(__file__).parent / "bngl" / "parabola.bngl" + + +@pytest.fixture +def model() -> BnglModel: + return BnglModel.from_file(EXAMPLE_BNGL) + + +def test_parse_bngl(): + entities = parse_bngl(EXAMPLE_BNGL.read_text()) + assert isinstance(entities, BnglEntities) + assert entities.parameters == { + "v1": "5", + "v2": "5", + "v3": "5", + "k_cond": "1", + } + assert entities.observable_names == {"x"} + assert entities.function_names == {"y"} + assert entities.molecule_type_names == {"counter"} + assert entities.seed_species == {"counter()"} + assert entities.compartment_names == frozenset() + + +def test_parameter_ids_and_values(model): + assert set(model.get_parameter_ids()) == {"v1", "v2", "v3", "k_cond"} + assert model.get_parameter_value("v1") == 5.0 + assert dict(model.get_free_parameter_ids_with_values()) == { + "v1": 5.0, + "v2": 5.0, + "v3": 5.0, + "k_cond": 1.0, + } + + +def test_get_parameter_value_unknown_raises(model): + with pytest.raises(ValueError): + model.get_parameter_value("nope") + + +def test_expression_valued_parameter_is_not_evaluated(): + # A numeric RHS coerces to float; an expression RHS is confined to + # NotImplementedError rather than evaluated (that needs BNG2.pl). + entities = parse_bngl( + "begin parameters\n base 2\n k_on 2*base\nend parameters\n" + ) + model = BnglModel(entities, model_id="m") + assert model.get_parameter_value("base") == 2.0 + with pytest.raises(NotImplementedError): + model.get_parameter_value("k_on") + # The expression-valued parameter is still an enumerated entity, but it + # contributes no introspection-grade value. + assert dict(model.get_free_parameter_ids_with_values()) == {"base": 2.0} + + +# -- grammar hardening: block aliases + seed-species "$" clamp --------------- +# Kept in sync with PyBNF's sibling reader (pybnf/petab/_bngl.py, ADR-0026); +# these cases are the anchor that keeps the two block scanners from drifting. + + +def test_seed_species_dollar_clamp_is_stripped(): + # SeedSpeciesDefn = ["$"], Species, WS, MathExpression -- the "$" fixes the + # concentration but is not part of the species identity, so the enumerated + # state variable is the bare pattern (attached "$A()" and spaced "$ A()"). + entities = parse_bngl( + "begin seed species\n $A() 100\n $ B() 0\n C() 5\nend seed species\n" + ) + assert entities.seed_species == frozenset({"A()", "B()", "C()"}) + assert "$A()" not in entities.seed_species # the marker never leaks + + +def test_rejected_block_aliases_are_not_honored(): + # The grammar doc lists `molecules`/`rules` as aliases, but BNG2.pl 2.9.3 + # REJECTS both ("Could not process block type"); only `species` is real. To + # match the reference implementation the reader must NOT treat + # `begin molecules`/`begin rules` as their canonical blocks. + entities = parse_bngl("begin molecules\n A()\n B(x)\nend molecules\n") + assert entities.molecule_type_names == frozenset() + + +def test_seed_species_block_alias(): + # `begin species` is BNG's short alias for `begin seed species` -- and the + # "$" clamp is stripped under the alias spelling too. + entities = parse_bngl("begin species\n $A() 100\n B() 0\nend species\n") + assert entities.seed_species == frozenset({"A()", "B()"}) + + +def test_line_continuation_is_joined(): + # A trailing "\" continues the logical line (BNG2.pl readFile). Without + # joining, a continued parameter reads as the value "\"; the join + # concatenates directly -- no space -- so a token split across the break + # ("1e\"+"3" -> "1e3") rejoins, matching BNG2.pl. + entities = parse_bngl( + "begin parameters\n" + " minusb = \\\n(p4-1)/(p4*(1+p2))\n" # continued expression value + " r 1e\\\n3\n" # token split -> 1e3, no space + " a = 1+\\\n2+\\\n3\n" # chained continuation + "end parameters\n" + ) + assert entities.parameters["minusb"] == "(p4-1)/(p4*(1+p2))" + assert entities.parameters["r"] == "1e3" + assert entities.parameters["a"] == "1+2+3" + + +def test_backslash_in_comment_is_not_a_continuation(): + # BNG2.pl strips the comment before testing for a trailing "\", so a "\" + # inside a comment must not swallow the next line. + entities = parse_bngl( + "begin parameters\n k 1 # note \\\n j 2\nend parameters\n" + ) + assert entities.parameters == {"k": "1", "j": "2"} + + +def test_indexed_declarations(): + # Legacy .net-style leading index (LineLabel = {Digit}, WS): the index must + # not be read as the name. + entities = parse_bngl( + "begin parameters\n 1 L0 1\n 2 R0 2\nend parameters\n" + "begin seed species\n 1 A() 100\n 2 B() 50\nend seed species\n" + ) + assert entities.parameters == {"L0": "1", "R0": "2"} + assert entities.seed_species == frozenset({"A()", "B()"}) + + +def test_labeled_seed_species(): + # Named line label (LineLabel = Name, ":"): "CD14: CD14(...)" -- the label, + # which here even equals the molecule name, must not be read as the + # species. The label is stripped before the "$" clamp. + entities = parse_bngl( + "begin seed species\n" + " CD14: CD14(TLR4,MD2) v1\n" + " clamp: $MD2(x~0) v2\n" + "end seed species\n" + ) + assert entities.seed_species == frozenset({"CD14(TLR4,MD2)", "MD2(x~0)"}) + + +def test_line_label_does_not_over_strip(): + # A normal `name value` param and an `@compartment:` species must be left + # alone -- a compartment prefix carries "@", so it is not a bare label. + entities = parse_bngl( + "begin parameters\n NA = 6.02e23\n k1 1.0\nend parameters\n" + "begin seed species\n @PM:Rec() 100\nend seed species\n" + ) + assert entities.parameters == {"NA": "6.02e23", "k1": "1.0"} + assert entities.seed_species == frozenset({"@PM:Rec()"}) + + +def test_alias_does_not_shadow_the_canonical_block(): + # The `species` alias must not swallow the block whose name it is a + # substring of: `seed species` and `molecule types` stay distinct. + entities = parse_bngl( + "begin molecule types\n Counter()\nend molecule types\n" + "begin seed species\n $Counter() 1\nend seed species\n" + ) + assert entities.molecule_type_names == frozenset({"Counter"}) + assert entities.seed_species == frozenset({"Counter()"}) + + +def test_state_variable_ignores_the_clamp(): + # A clamped seed species is still a state variable under its bare id + # (is_state_variable drives CheckModel's species cross-checks). + model = BnglModel( + parse_bngl("begin seed species\n $A() 100\nend seed species\n"), + model_id="m", + ) + assert model.is_state_variable("A()") + assert not model.is_state_variable("$A()") + + +def test_has_entity_spans_full_declared_namespace(model): + # parameter, observable, global function, molecule type, seed species. + for entity in ("v1", "x", "y", "counter", "counter()"): + assert model.has_entity_with_id(entity) + # Prefixed PEtab IDs and unknowns are not model entities. + for non_entity in ("obs_x", "func_y", "nope"): + assert not model.has_entity_with_id(non_entity) + + +def test_symbol_allowed_is_the_paramlist_only(model): + # parameters u observables u global functions (the BNG ParamList). + for symbol in ("x", "y", "v1"): + assert model.symbol_allowed_in_observable_formula(symbol) + # A molecule type / seed species is an entity but not a formula symbol. + for non_symbol in ("counter", "counter()", "nope"): + assert not model.symbol_allowed_in_observable_formula(non_symbol) + + +def test_is_state_variable_is_seed_species_only(model): + assert model.is_state_variable("counter()") + assert not model.is_state_variable("v1") + assert not model.is_state_variable("x") + + +def test_valid_ids_for_condition_table_is_params_and_compartments(): + entities = parse_bngl( + "begin parameters\n k 1\nend parameters\n" + "begin compartments\n EC 3 1.0\nend compartments\n" + ) + model = BnglModel(entities, model_id="m") + assert set(model.get_valid_ids_for_condition_table()) == {"k", "EC"} + assert model.has_entity_with_id("EC") + # A compartment is an entity but not an observable-formula symbol. + assert not model.symbol_allowed_in_observable_formula("EC") + + +def test_invalid_model_id_raises(): + with pytest.raises(ValueError, match="not a valid identifier"): + BnglModel(parse_bngl(""), model_id="1nope") + + +def test_repr(model): + assert repr(model) == "" + + +def test_is_valid(model): + # A valid model validates: a real ``BNG2.pl --check`` where a BNG backend + # is locatable, or a graceful ``True`` fallback where it is not. + assert model.is_valid() is True + # A buffer-loaded model has no file to check and falls back to True. + assert BnglModel(parse_bngl(""), model_id="m").is_valid() is True + + +def test_is_valid_detects_broken_model_when_bng_available(tmp_path): + from petab.v1.models.bngl_model import _locate_bng2 + + if _locate_bng2() is None: + pytest.skip("BNG2.pl not available; is_valid falls back to True") + # An undefined parameter in the function body -> BNG2.pl --check fails. + broken = tmp_path / "broken.bngl" + broken.write_text( + "begin model\n" + " begin parameters\n k 1\n end parameters\n" + " begin functions\n f()=k*undefined_symbol\n end functions\n" + "end model\n" + ) + assert BnglModel.from_file(broken).is_valid() is False + + +def test_model_factory_routes_bngl(): + from petab.v1.models.model import model_factory + + model = model_factory(EXAMPLE_BNGL, "bngl") + assert isinstance(model, BnglModel) + assert model.type_id == "bngl" + + +def test_full_petab_validation_is_clean(tmp_path): + """A ``language: bngl`` problem loads via ``Problem.from_yaml`` and passes + every default validation task -- including the model-cross checks that + read the BnglModel (CheckModel, CheckObservablesDoNotShadowModelEntities, + CheckAllParametersPresentInParameterTable, CheckValidConditionTargets, + CheckInitialChangeSymbols). + """ + petab_v2 = pytest.importorskip("petab.v2") + from petab.v2 import Problem + from petab.v2.lint import ( + ValidationIssueSeverity, + default_validation_tasks, + ) + + # The BNGL model is the only genuinely BNGL-specific artifact; the PEtab + # tables are built with the v2 API so their format is guaranteed correct. + (tmp_path / "parabola.bngl").write_text(EXAMPLE_BNGL.read_text()) + + problem = Problem() + for param in ("v1", "v2", "v3"): + problem.add_parameter(param, estimate=True, lb=0, ub=10) + # observableFormula is the bare model name (x is an observable, y a global + # function); both are vouched for by the model, so neither is demanded as + # an output parameter in the parameter table. + problem.add_observable( + "obs_x", "x", noise_formula="0.1", noise_distribution="normal" + ) + problem.add_observable( + "func_y", "y", noise_formula="0.1", noise_distribution="normal" + ) + # A condition targets a model parameter that is *not* estimated -- a valid + # condition target (parameters u compartments) that exercises + # CheckValidConditionTargets against the BnglModel. + problem.add_condition("cond1", k_cond=2) + problem.add_experiment("exp1", 0, "cond1") + for t in (0.0, 1.0, 2.0): + problem.add_measurement( + "obs_x", time=t, measurement=1.0, experiment_id="exp1" + ) + problem.add_measurement( + "func_y", time=t, measurement=2.0, experiment_id="exp1" + ) + + petab_v2.write_parameter_df( + problem.parameter_df, tmp_path / "parameters.tsv" + ) + petab_v2.write_observable_df( + problem.observable_df, tmp_path / "observables.tsv" + ) + petab_v2.write_condition_df( + problem.condition_df, tmp_path / "conditions.tsv" + ) + petab_v2.write_experiment_df( + problem.experiment_df, tmp_path / "experiments.tsv" + ) + petab_v2.write_measurement_df( + problem.measurement_df, tmp_path / "measurements.tsv" + ) + + (tmp_path / "problem.yaml").write_text( + "format_version: 2.0.0\n" + "parameter_files: [parameters.tsv]\n" + "model_files:\n" + " parabola:\n" + " location: parabola.bngl\n" + " language: bngl\n" + "condition_files: [conditions.tsv]\n" + "experiment_files: [experiments.tsv]\n" + "observable_files: [observables.tsv]\n" + "measurement_files: [measurements.tsv]\n" + ) + + loaded = Problem.from_yaml(str(tmp_path / "problem.yaml")) + assert isinstance(loaded.model, BnglModel) + + errors = [ + (type(task).__name__, issue.message) + for task in default_validation_tasks + if (issue := task.run(loaded)) is not None + and issue.level == ValidationIssueSeverity.ERROR + ] + assert errors == []