"""bngsim._net_reader — Pure-Python .net file parser for ModelBuilder.
Parses a BNG .net file into a dictionary of model components that can
be fed into ``ModelBuilder`` for programmatic model construction. This
enables users to load .net files into ModelBuilder, inspect/modify the
structure, and build models — all without requiring the C++ net file
loader.
This is the **recommended** pattern for users who want to:
- Load a .net file and modify it before building
- Extract model structure for analysis
- Use .net models as templates for programmatic construction
Example
-------
>>> from bngsim._net_reader import parse_net_file
>>> from bngsim._bngsim_core import ModelBuilder
>>> parsed = parse_net_file("model.net")
>>> builder = ModelBuilder()
>>> for name, value in parsed["parameters"]:
... builder.add_parameter(name, value)
>>> # ... add species, reactions, etc.
>>> model = builder.build()
Or use the convenience function:
>>> from bngsim import Model
>>> model = Model.from_net_via_builder("model.net")
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Any
from bngsim._codegen import _strip_fixed_marker
def _check_synthetic_rate_expr(expr: str) -> None:
t = expr.strip()
if re.search(r"[\+\-\*/\^]\s*$", t):
raise ValueError(f"invalid rate expression {expr!r}: ends with an operator")
if re.match(r"^\s*[\*/\^]", t):
raise ValueError(f"invalid rate expression {expr!r}: starts with invalid operator")
depth = 0
for c in t:
if c == "(":
depth += 1
elif c == ")":
depth -= 1
if depth < 0:
raise ValueError(f"invalid rate expression {expr!r}: unmatched ')'")
if depth != 0:
raise ValueError(f"invalid rate expression {expr!r}: unmatched '('")
[docs]
def parse_net_file(path: str | Path) -> dict[str, Any]:
"""Parse a BNG .net file into a structured dictionary.
Parameters
----------
path : str or Path
Path to the .net file.
Returns
-------
dict
Structured contents of the ``.net`` file, with keys::
parameters : list of (name, value, expression, is_expression)
species : list of (name, init_conc, is_fixed)
observables : list of (name, entries), entries = [(sp_idx0, factor), ...]
functions : list of (name, expression)
reactions : list of dict with keys reactants, products (0-based
species indices), type ("elementary"/"functional"),
rate_law (parameter or function name), stat_factor
"""
path = Path(path)
text = path.read_text()
# Parse each block
parameters = _parse_parameters(text)
species = _parse_species(text, parameters)
observables = _parse_observables(text)
functions = _parse_functions(text)
reactions = _parse_reactions(text, functions)
return {
"parameters": parameters,
"species": species,
"observables": observables,
"functions": functions,
"reactions": reactions,
}
[docs]
def build_model_from_parsed(parsed: dict[str, Any]):
"""Build a NetworkModel from parsed .net data via ModelBuilder.
Parameters
----------
parsed : dict
Output of ``parse_net_file()``.
Returns
-------
bngsim.Model
The constructed model.
"""
from bngsim._bngsim_core import ModelBuilder
from bngsim._model import Model
builder = ModelBuilder()
# Parameters
param_map = {} # name -> value (for resolving species ICs)
for name, value, expr, is_expr in parsed["parameters"]:
builder.add_parameter(name, value, expr, is_expr)
param_map[name] = value
# Species
for name, init_conc, is_fixed in parsed["species"]:
builder.add_species(name, init_conc, is_fixed)
# Observables
for name, entries in parsed["observables"]:
builder.add_observable(name, entries)
# Functions (track names for reaction rate resolution)
func_names: set[str] = set()
for name, expression in parsed["functions"]:
builder.add_function(name, expression)
func_names.add(name)
# Reactions
for i, rxn in enumerate(parsed["reactions"]):
rtype = rxn["type"]
rate_law = rxn["rate_law"]
if rtype == "elementary" and rate_law not in param_map:
if not rate_law.strip():
raise ValueError(
"elementary reaction has empty or whitespace-only rate_law "
"(not a parameter name)"
)
if rate_law in func_names:
rtype = "functional"
else:
_check_synthetic_rate_expr(rate_law)
collision_idx = i
auto_func = f"__net_reader_func_{collision_idx}"
while auto_func in func_names:
collision_idx += 1
auto_func = f"__net_reader_func_{collision_idx}"
builder.add_function(auto_func, rate_law)
func_names.add(auto_func)
rate_law = auto_func
rtype = "functional"
builder.add_reaction(
rxn["reactants"],
rxn["products"],
rtype,
rate_law,
rxn["stat_factor"],
)
core = builder.build()
return Model(_core=core)
# ─── Internal parsers ─────────────────────────────────────────────────
def _extract_block(text: str, block_name: str) -> str:
"""Extract content between 'begin <block>' and 'end <block>'."""
pattern = rf"begin\s+{block_name}\s*\n(.*?)end\s+{block_name}"
m = re.search(pattern, text, re.DOTALL)
return m.group(1) if m else ""
def _parse_parameters(text: str) -> list[tuple[str, float, str, bool]]:
"""Parse parameters block.
Returns list of (name, value, expression, is_expression).
"""
block = _extract_block(text, "parameters")
params = []
# Two-pass: first collect all, then evaluate expressions
raw_params = []
for line in block.strip().splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
# Format: index name value_or_expr # comment
parts = line.split("#")[0].strip().split()
if len(parts) < 3:
continue
_idx_str, name = parts[0], parts[1]
expr = " ".join(parts[2:])
raw_params.append((name, expr))
# Evaluate parameters in order (later ones can reference earlier ones)
ns: dict[str, Any] = {"__builtins__": {}}
import math
ns.update(
{
"pi": math.pi,
"e": math.e,
"exp": math.exp,
"log": math.log,
"log10": math.log10,
"sqrt": math.sqrt,
"pow": pow,
"abs": abs,
"sin": math.sin,
"cos": math.cos,
"tan": math.tan,
"asin": math.asin,
"acos": math.acos,
"atan": math.atan,
}
)
for name, expr in raw_params:
try:
value = float(eval(expr, ns))
except Exception:
value = 0.0
ns[name] = value
# Check if it's a pure number or an expression
try:
float(expr)
is_expr = False
except ValueError:
is_expr = True
params.append((name, value, expr, is_expr))
return params
def _parse_species(
text: str,
parameters: list[tuple[str, float, str, bool]],
) -> list[tuple[str, float, bool]]:
"""Parse species block.
Returns list of (name, init_conc, is_fixed).
"""
block = _extract_block(text, "species")
species = []
param_map = {name: val for name, val, _, _ in parameters}
for line in block.strip().splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
parts = line.split("#")[0].strip().split()
if len(parts) < 3:
continue
parts[0]
# `$` clamp marker may sit at index 0 or after a `@<compartment>::`
# prefix (BNG2.pl emits the latter for cBNGL models).
name, is_fixed = _strip_fixed_marker(parts[1])
ic_str = parts[2]
try:
init_conc = float(ic_str)
except ValueError:
# May be a parameter name
init_conc = param_map.get(ic_str, 0.0)
species.append((name, init_conc, is_fixed))
return species
def _parse_observables(text: str) -> list[tuple[str, list[tuple[int, float]]]]:
"""Parse groups (observables) block.
Returns list of (name, [(species_idx_0based, factor), ...]).
"""
block = _extract_block(text, "groups")
observables = []
for line in block.strip().splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
parts = line.split("#")[0].strip().split()
if len(parts) < 2:
continue
parts[0]
name = parts[1]
entries = []
for token in parts[2:]:
for sub in token.split(","):
sub = sub.strip()
if not sub:
continue
if "*" in sub:
factor_s, idx_s = sub.split("*", 1)
entries.append((int(idx_s) - 1, float(factor_s)))
else:
entries.append((int(sub) - 1, 1.0))
observables.append((name, entries))
return observables
def _parse_functions(text: str) -> list[tuple[str, str]]:
"""Parse functions block.
Returns list of (name, expression).
"""
block = _extract_block(text, "functions")
functions = []
for line in block.strip().splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
# Format: index name() expression
# or: index name() expression #comment
line = line.split("#")[0].strip()
parts = line.split(None, 2)
if len(parts) < 3:
continue
parts[0]
name_with_parens = parts[1]
expression = parts[2]
# Strip trailing () from name
name = name_with_parens.rstrip("()")
functions.append((name, expression))
return functions
def _parse_reactions(
text: str,
functions: list[tuple[str, str]],
) -> list[dict]:
"""Parse reactions block.
Returns list of dicts with reactants, products, type, rate_law, stat_factor.
"""
block = _extract_block(text, "reactions")
func_names = {name for name, _ in functions}
reactions = []
for line in block.strip().splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
line = line.split("#")[0].strip()
parts = line.split()
if len(parts) < 4:
continue
parts[0]
# Find the rate law — it's the last token before any comment
# Format: idx reactants products rate_law [stat_factor]
# reactants and products are comma-separated species indices
# We need to parse: idx r1,r2 p1,p2 rate_law
# The reactant and product fields are 1-based species indices
reactant_str = parts[1]
product_str = parts[2]
rate_law = parts[3]
# Parse stat_factor if present (not common)
stat_factor = 1.0
# Parse reactants (1-based → 0-based, 0 means null/creation)
reactants = []
for tok in reactant_str.split(","):
tok = tok.strip()
if tok and tok != "0":
reactants.append(int(tok) - 1)
# Parse products (1-based → 0-based, 0 means null/degradation)
products = []
for tok in product_str.split(","):
tok = tok.strip()
if tok and tok != "0":
products.append(int(tok) - 1)
# Determine type: if rate_law is a function name → functional
rtype = "functional" if rate_law in func_names else "elementary"
reactions.append(
{
"reactants": reactants,
"products": products,
"type": rtype,
"rate_law": rate_law,
"stat_factor": stat_factor,
}
)
return reactions