"""bngsim.convert — model format converters (GH #211).
Public surface:
* :func:`sbml_to_net` — convert an SBML model to a BioNetGen ``.net`` network
(GH #215). Network channel only; simulation protocol (events) is a SED-ML
sidecar's job.
* :func:`net_to_sbml` — convert a BioNetGen ``.net`` network to SBML (GH #216),
the reverse direction. SBML can carry amount/concentration semantics the
plain ``.net`` text cannot, so this is the more faithful half.
* :func:`write_net` / :func:`write_sbml` — serialize an already-loaded
:class:`bngsim.Model` to ``.net`` / SBML text (the reusable serializers).
* :func:`read_omex` / :func:`write_omex` — read/write a COMBINE archive
(``.omex``): the standard zip container bundling SBML + SED-ML + a manifest
(GH #219). :func:`net_to_omex` packages a ``.net`` end-to-end.
* :func:`validate_structural_l1` — structural-equivalence (L1) check between a
source model and its conversion.
* :class:`ConversionReport` — structured result of a conversion.
Example
-------
>>> import bngsim
>>> report = bngsim.convert.sbml_to_net("model.xml", "model.net")
>>> report.ok
True
>>> back = bngsim.convert.net_to_sbml("model.net", "roundtrip.xml")
>>> back.ok
True
"""
from __future__ import annotations
import tempfile
import warnings
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any
from bngsim.convert._bngl_writer import bngl_capability_report, write_bngl
from bngsim.convert._net_writer import (
SYNTHETIC_PREFIX,
capability_report,
write_net,
)
from bngsim.convert._omex import (
OmexArchive,
OmexEntry,
read_omex,
write_omex,
)
from bngsim.convert._protocol import (
Experiment,
ProtocolSpec,
StateChange,
combine_protocols,
parse_bngl_protocol,
write_bngl_protocol,
)
from bngsim.convert._sbml_writer import sbml_capability_report, write_sbml
from bngsim.convert._sedml import (
default_protocol,
read_sedml,
read_sedml_protocol,
write_sedml,
write_sedml_protocol,
)
from bngsim.convert._validate import (
ConversionValidationReport,
LevelResult,
_dynamic_stoich_signatures,
_max_rhs_delta,
_stoich_signatures,
grade_conversion,
validate_conversion,
)
if TYPE_CHECKING:
from bngsim._eval_spec import EvaluationSpec
from bngsim._model import Model
__all__ = [
"sbml_to_net",
"sbml_to_bngl",
"net_to_sbml",
"write_net",
"write_sbml",
"write_bngl",
"bngl_capability_report",
"validate_structural_l1",
"validate_roundtrip",
"validate_conversion",
"grade_conversion",
"read_sedml",
"write_sedml",
"read_sedml_protocol",
"write_sedml_protocol",
"default_protocol",
"parse_bngl_protocol",
"write_bngl_protocol",
"combine_protocols",
"ProtocolSpec",
"Experiment",
"StateChange",
"read_omex",
"write_omex",
"net_to_omex",
"omex_to_net",
"OmexArchive",
"OmexEntry",
"ConversionReport",
"ConversionValidationReport",
"LevelResult",
"StructuralReport",
]
# ─── Structured results ───────────────────────────────────────────────────
@dataclass
class StructuralReport:
"""Result of an L1 structural-equivalence check (counts + topology)."""
passed: bool
n_species: tuple[int, int]
n_reactions: tuple[int, int]
n_parameters: tuple[int, int]
n_observables: tuple[int, int]
mismatches: list[str] = field(default_factory=list)
def summary(self) -> str:
verdict = "PASS" if self.passed else "FAIL"
return (
f"L1 structural {verdict}: "
f"species {self.n_species[0]}→{self.n_species[1]}, "
f"reactions {self.n_reactions[0]}→{self.n_reactions[1]}, "
f"parameters {self.n_parameters[0]}→{self.n_parameters[1]}, "
f"observables {self.n_observables[0]}→{self.n_observables[1]}"
+ ("" if self.passed else " — " + "; ".join(self.mismatches))
)
@dataclass
class ConversionReport:
"""Structured result of a network-channel conversion (either direction)."""
source: str
out_path: str | None
output_text: str
n_species: int
n_reactions: int
n_parameters: int
n_observables: int
dropped: list[str] = field(default_factory=list)
lossy: list[str] = field(default_factory=list)
structural: StructuralReport | None = None
max_rhs_delta: float | None = None
rhs_faithful: bool | None = None
validation: ConversionValidationReport | None = None
protocol: ProtocolSpec | None = None # parsed from a source .bngl, when given
bngl_out: str | None = None # path of the emitted .bngl actions block, when written (GH #222)
# Direction-flavored aliases so each call site reads naturally.
@property
def net_text(self) -> str:
"""The produced text (SBML→.net direction reads this name)."""
return self.output_text
@property
def sbml_text(self) -> str:
"""The produced text (.net→SBML direction reads this name)."""
return self.output_text
@property
def ok(self) -> bool:
"""True when the conversion produced output and every check passed.
When a full L0–L4 gate ran (``validate="full"``) its verdict is
authoritative — every hard gate (L0–L3) must have passed; L4 never
blocks. When the ``"L2"`` RHS-identity self-check ran, :attr:`rhs_faithful`
(the round-tripped network reproduces the source ODE right-hand side) is
the authoritative reaction-level gate: the writer may legitimately rewrite
a reactant-independent functional law as per-species flux, changing the
reaction count/topology, so only species conservation is gated
structurally there. Otherwise (``"L1"`` counts-only) the full structural
equivalence decides.
"""
if self.validation is not None:
return self.validation.ok
if self.rhs_faithful is not None:
if (
self.structural is not None
and self.structural.n_species[0] != self.structural.n_species[1]
):
return False
return self.rhs_faithful
if self.structural is not None:
return self.structural.passed
return True
def summary(self) -> str:
lines = [
f"Converted {self.source}"
+ (f" → {self.out_path}" if self.out_path else " (in memory)"),
f" network: {self.n_species} species, {self.n_reactions} reactions, "
f"{self.n_parameters} parameters, {self.n_observables} observables",
]
for note in self.dropped:
lines.append(f" dropped: {note}")
for note in self.lossy:
lines.append(f" lossy: {note}")
if self.structural is not None:
lines.append(" " + self.structural.summary())
if self.max_rhs_delta is not None:
lines.append(f" numerical: max|Δ dy/dt| = {self.max_rhs_delta:.2e}")
if self.validation is not None:
for lv in self.validation.levels:
lines.append(" " + lv.summary())
if self.bngl_out is not None:
n_exp = len(self.protocol.experiments) if self.protocol is not None else 0
lines.append(
f" actions: {self.bngl_out} ({n_exp} experiment{'' if n_exp == 1 else 's'})"
)
return "\n".join(lines)
# ─── L1 structural validation ─────────────────────────────────────────────
# The low-level signature/RHS primitives (_stoich_signatures,
# _dynamic_stoich_signatures, _max_rhs_delta) are defined in convert._validate
# and imported above so the framework and this surface share one definition.
def validate_structural_l1(source_model: Model, net_model: Model) -> StructuralReport:
"""Compare a source model and its ``.net`` reconstruction at level **L1**.
L1 (#211c) is structural equivalence: same counts and topology. Concretely
we compare ``#species``, ``#reactions``, ``#parameters``, ``#observables``
and the multiset of per-reaction reactant/product index sets. Functions are
excluded — the writer may synthesize helper functions to reproduce
propensities the text format cannot otherwise express.
"""
mismatches: list[str] = []
def _n_params(m: Model) -> int:
# Exclude writer-synthesized helper functions' shadow parameters — they
# are .net encoding overhead, not source structure.
return sum(1 for n in m.param_names if not n.startswith(SYNTHETIC_PREFIX))
n_sp = (source_model.n_species, net_model.n_species)
n_rxn = (source_model.n_reactions, net_model.n_reactions)
n_par = (_n_params(source_model), _n_params(net_model))
n_obs = (source_model.n_observables, net_model.n_observables)
for label, (a, b) in (
("species", n_sp),
("reactions", n_rxn),
("parameters", n_par),
("observables", n_obs),
):
if a != b:
mismatches.append(f"{label} count {a} != {b}")
sig_a = _stoich_signatures(source_model)
sig_b = _stoich_signatures(net_model)
if sig_a != sig_b:
only_a = sig_a - sig_b
only_b = sig_b - sig_a
if only_a:
mismatches.append(f"{sum(only_a.values())} reaction topolog(ies) only in source")
if only_b:
mismatches.append(f"{sum(only_b.values())} reaction topolog(ies) only in output")
return StructuralReport(
passed=not mismatches,
n_species=n_sp,
n_reactions=n_rxn,
n_parameters=n_par,
n_observables=n_obs,
mismatches=mismatches,
)
# ─── Orchestrator ─────────────────────────────────────────────────────────
[docs]
def sbml_to_net(
sbml_path: str | Path,
out_path: str | Path | None = None,
*,
validate: str | None = "L2",
strict: bool = True,
sedml: str | Path | None = None,
t_span: tuple[float, float] = (0.0, 100.0),
n_points: int = 101,
rhs_tol: float = 1e-6,
) -> ConversionReport:
"""Convert an SBML model to a BioNetGen ``.net`` network.
Parameters
----------
sbml_path : str | Path
Path to the source SBML ``.xml``.
out_path : str | Path | None
Where to write the ``.net``. If None, the text is returned in the report
but no file is written.
validate : {"L1", "L2", "full", None}
``"L2"`` (default) runs the lightweight structural check **plus** a direct
ODE-RHS identity self-check — it reloads the ``.net`` and confirms it
reproduces the source right-hand side (the project's faithfulness
measure) at the initial and a nonlinear-probe state, no integration. This
is what closes the GH #223 silent-loss hole: a network whose constructs
were emitted as constants the ``.net`` cannot drive (assignment/rate-rule
forcing the writer can't carry) diverges here and is flagged
(``strict=True`` raises; ``strict=False`` warns and records
:attr:`ConversionReport.rhs_faithful` False). ``"L1"`` runs only the
structural-equivalence check (counts + topology). ``"full"`` runs the
complete L0–L4 conversion-validation ladder (GH #217) and gates on the
hard levels L0–L3 — "convert *and prove faithful*"; the L4 symbolic check
is recorded but never blocks. ``None`` skips validation. A ``"full"``
gate's verdict is attached as :attr:`ConversionReport.validation` and
decides :attr:`ConversionReport.ok`.
strict : bool
Raise :class:`bngsim.ConversionError` on constructs the ``.net`` text
format cannot carry faithfully. ``False`` downgrades to a
:class:`bngsim.ConversionWarning` and emits a best-effort network.
sedml : str | Path | None
The SED-ML simulation protocol that accompanies the SBML (the COMBINE
sidecar). When given, its time course is parsed and the ``"full"`` gate's
L3 numerical comparison runs over the model's *own* horizon — the exact
mirror of :func:`net_to_sbml`'s ``bngl=`` (avoids the blanket-grid
stiff-hang and exercises the trajectory the modeller actually ran). The
parsed :class:`~bngsim.convert.ProtocolSpec` is attached to the report.
t_span, n_points : tuple[float, float], int
Fallback time grid for the ``"full"`` gate's L3 comparison when no
``sedml`` protocol horizon is available (ignored for other ``validate``
values).
rhs_tol : float
Scale-relative tolerance for the ``"full"`` gate's L2 ODE-RHS identity.
Returns
-------
ConversionReport
"""
from bngsim._exceptions import ConversionError, ConversionWarning, ModelError
from bngsim._model import Model
sbml_path = Path(sbml_path)
model = Model.from_sbml(sbml_path)
protocol = None
if sedml is not None:
protocol = read_sedml_protocol(sedml)
caps = capability_report(model)
header = f"sbml2net: {sbml_path.name}"
net_text = write_net(model, out_path, strict=strict, header=header, model_name=sbml_path.name)
structural: StructuralReport | None = None
rhs_faithful: bool | None = None
max_rhs_delta: float | None = None
validation: ConversionValidationReport | None = None
if validate in ("L1", "L2"):
# Reload through the .net reader to validate the writer↔reader round-trip.
# A best-effort (strict=False) emission of an already-lossy model can write
# a .net the reader cannot load (e.g. a function referencing a rateOf
# csymbol — caught by capability_report above); record that as not-faithful
# rather than propagating the loader error.
try:
if out_path is not None:
net_model = Model.from_net(out_path)
else:
with tempfile.NamedTemporaryFile("w", suffix=".net", delete=False) as tmp:
tmp.write(net_text)
tmp_path = tmp.name
try:
net_model = Model.from_net(tmp_path)
finally:
Path(tmp_path).unlink(missing_ok=True)
except ModelError:
if not caps["lossy"]:
raise # a faithful model that still won't reload is a real defect
return ConversionReport(
source=str(sbml_path),
out_path=str(out_path) if out_path is not None else None,
output_text=net_text,
n_species=model.n_species,
n_reactions=model.n_reactions,
n_parameters=model.n_parameters,
n_observables=model.n_observables,
dropped=caps["dropped"],
lossy=caps["lossy"],
rhs_faithful=False,
protocol=protocol,
)
structural = validate_structural_l1(model, net_model)
if validate == "L2":
# Direct ODE-RHS identity: does the reloaded .net reproduce the source
# right-hand side? Species order is preserved across the conversion, so
# the comparison is index-aligned. Catches forcing the flat .net cannot
# carry (assignment/rate-rule constructs frozen to constants) that the
# structural counts miss — the GH #223 silent-loss class.
max_rhs_delta = _max_rhs_delta(model, net_model)
rhs_faithful = max_rhs_delta <= rhs_tol
if not rhs_faithful:
note = (
f"the round-tripped .net does not reproduce the source ODE "
f"right-hand side (max scale-relative |Δ dy/dt| = "
f"{max_rhs_delta:.2e} > {rhs_tol:.0e}) — a construct was emitted "
"as a constant the flat .net cannot drive (e.g. assignment-rule "
"or time-dependent forcing); the network is not faithful"
)
if strict:
raise ConversionError(
f"{sbml_path.name}: {note}. Pass strict=False "
"(--allow-lossy) to emit the best-effort network anyway."
)
warnings.warn(
f"{sbml_path.name}: lossy conversion — {note}",
ConversionWarning,
stacklevel=2,
)
elif validate == "full":
validation = grade_conversion(
"sbml2net",
model,
net_text,
levels="all",
strict=strict,
caps=caps,
source=str(sbml_path),
source_stem=sbml_path.stem,
t_span=t_span,
n_points=n_points,
rhs_tol=rhs_tol,
protocol=protocol,
)
elif validate is not None:
raise ValueError(f"unknown validate={validate!r}; expected None, 'L1', 'L2', or 'full'")
return ConversionReport(
source=str(sbml_path),
out_path=str(out_path) if out_path is not None else None,
output_text=net_text,
n_species=model.n_species,
n_reactions=model.n_reactions,
n_parameters=model.n_parameters,
n_observables=model.n_observables,
dropped=caps["dropped"],
lossy=caps["lossy"],
structural=structural,
max_rhs_delta=max_rhs_delta,
rhs_faithful=rhs_faithful,
validation=validation,
protocol=protocol,
)
# ─── SBML→cBNGL orchestrator (GH #224) ────────────────────────────────────
def sbml_to_bngl(
sbml_path: str | Path,
out_path: str | Path | None = None,
*,
strict: bool = True,
validate: str | None = None,
rhs_tol: float = 1e-6,
t_span: tuple[float, float] = (0.0, 100.0),
n_points: int = 101,
) -> ConversionReport:
"""Convert an SBML model to a compartmental BNGL (``.bngl``) model block.
Mirrors :func:`sbml_to_net`, but targets **cBNGL** so static compartment
volumes are recovered (GH #224 deliverable 1): a non-unit-volume model that
plain ``.net`` refuses round-trips faithfully through
``BNG2.pl generate_network`` → :func:`bngsim.Model.from_net`. The emitted text
is the ``begin model`` … ``end model`` block; when the source SBML carries
**fixed-time events** they are translated into a trailing ``begin actions``
block (#224 phase 2 — ``simulate`` phases with ``setConcentration`` state
changes at each fire time). State-triggered events (and non-constant trigger
times / delays / assignment values) have no actions form and are refused
fail-loud.
Parameters
----------
sbml_path : str | Path
Path to the source SBML ``.xml``.
out_path : str | Path | None
Where to write the ``.bngl``. If None, the text is returned in the report
but no file is written.
strict : bool
Raise :class:`bngsim.ConversionError` on constructs deliverable 1 cannot
carry — state-triggered events, live/time-varying volumes,
cross-compartment reactant reactions, Michaelis–Menten kinetics,
amount-valued non-unit species (see
:func:`bngsim.convert.bngl_capability_report`). ``False`` downgrades to a
:class:`bngsim.ConversionWarning` and emits a best-effort model.
validate : {None, "bng2"}
``None`` (default): faithfulness rests on the capability check (no fatal
``lossy`` notes) — fast, no external tool. ``"bng2"``: additionally run the
**BNG2.pl round-trip oracle** — flatten the emitted ``.bngl`` via
``BNG2.pl generate_network``, reload through :func:`bngsim.Model.from_net`,
and compare the ODE right-hand side to the source's (probed at several t>0
instants). Sets :attr:`ConversionReport.rhs_faithful` / ``max_rhs_delta``;
a divergence raises :class:`bngsim.ConversionError` under ``strict`` (else
warns). Needs ``BNG2.pl`` on ``$BNGPATH`` or ``PATH`` (raises if absent,
times out, or the ``.bngl`` does not build — faithfulness unprovable). This
is the authoritative cBNGL gate; there is no in-tree cBNGL reader by design.
rhs_tol : float
Scale-relative RHS tolerance for ``validate="bng2"`` (default 1e-6).
t_span, n_points : tuple[float, float], int
The horizon for the events→actions ``simulate`` phases (the trailing
phase runs to ``t_span[1]``; ignored when the model has no events).
Returns
-------
ConversionReport
With ``validate="bng2"``, :attr:`rhs_faithful` / :attr:`max_rhs_delta`
carry the oracle verdict; otherwise both are None and ``ok`` reflects the
capability check (no fatal ``lossy`` notes).
"""
from bngsim._exceptions import ConversionError, ConversionWarning
from bngsim._model import Model
from bngsim.convert._bngl_writer import _EVENT_GENERIC
from bngsim.convert._events import sbml_events_to_protocol
if validate not in (None, "bng2"):
raise ValueError(f"unknown validate={validate!r}; expected None or 'bng2'")
sbml_path = Path(sbml_path)
model = Model.from_sbml(sbml_path)
# Classify the source SBML's events (the loaded Model exposes only a count):
# fixed-time events become a BNGL actions block; state-triggered / non-constant
# events are the specific refusals the capability report then carries.
protocol = None
event_override: Any = _EVENT_GENERIC
if int(getattr(model._core, "n_events", 0) or 0):
protocol, event_override = sbml_events_to_protocol(
sbml_path, model, t_span=t_span, n_points=n_points
)
# event_override is now [] when all events were carried into actions,
# else the specific state-triggered / non-constant refusals.
caps = bngl_capability_report(model, event_override=event_override)
bngl_text = write_bngl(
model,
out_path,
strict=strict,
model_name=sbml_path.name,
protocol=protocol,
event_override=event_override,
)
rhs_faithful: bool | None = None
max_rhs_delta: float | None = None
if validate == "bng2":
# Authoritative cBNGL gate: round-trip the emitted .bngl through BNG2.pl and
# compare the reloaded ODE RHS to the source's. Skip when capability already
# found the model lossy (strict would have raised in write_bngl; a lenient
# best-effort .bngl is not expected to build faithfully).
from bngsim.convert._bng2 import roundtrip_rhs_delta
if not caps["lossy"]:
max_rhs_delta, _ = roundtrip_rhs_delta(model, bngl_text, stem=sbml_path.stem)
rhs_faithful = max_rhs_delta <= rhs_tol
if not rhs_faithful:
note = (
f"the BNG2.pl round-trip does not reproduce the source ODE "
f"right-hand side (max scale-relative |Δ dy/dt| = "
f"{max_rhs_delta:.2e} > {rhs_tol:.0e}); the cBNGL is not faithful"
)
if strict:
raise ConversionError(f"{sbml_path.name}: {note}.")
warnings.warn(
f"{sbml_path.name}: lossy conversion — {note}",
ConversionWarning,
stacklevel=2,
)
return ConversionReport(
source=str(sbml_path),
out_path=str(out_path) if out_path is not None else None,
output_text=bngl_text,
n_species=model.n_species,
n_reactions=model.n_reactions,
n_parameters=model.n_parameters,
n_observables=model.n_observables,
dropped=caps["dropped"],
lossy=caps["lossy"],
max_rhs_delta=max_rhs_delta,
rhs_faithful=rhs_faithful,
protocol=protocol,
)
# ─── net→SBML round-trip validation ───────────────────────────────────────
def validate_roundtrip(
source_model: Model, reloaded_model: Model, *, rhs_tol: float = 1e-6
) -> StructuralReport:
"""Validate a ``.net``→SBML conversion by reloading and comparing.
The structural gate is **species count + reaction count + reaction
topology** plus an **ODE right-hand-side** numerical check — the
equivalence that actually matters and that seeds #217's L2
round-trip-identity gate. Observable and parameter counts are *recorded but
not gated*: ``from_net`` honors a model's explicit ``begin groups`` while
``from_sbml`` auto-reports every species as an observable (and stores a
summing rule as a time-varying parameter), so a strict count comparison
across the two loaders would flag a benign labeling difference, not a
conversion defect.
"""
mismatches: list[str] = []
n_sp = (source_model.n_species, reloaded_model.n_species)
n_rxn = (source_model.n_reactions, reloaded_model.n_reactions)
n_par = (source_model.n_parameters, reloaded_model.n_parameters)
n_obs = (source_model.n_observables, reloaded_model.n_observables)
if n_sp[0] != n_sp[1]:
mismatches.append(f"species count {n_sp[0]} != {n_sp[1]}")
if n_rxn[0] != n_rxn[1]:
mismatches.append(f"reactions count {n_rxn[0]} != {n_rxn[1]}")
# Topology over *dynamic* species only: a fixed ($) species is a reactant in
# the .net but `from_sbml` folds a constant boundary species into the rate
# law (0-reactant functional reaction), so comparing it would flag a benign
# cross-loader representation difference. Species order is preserved through
# the round-trip, so index→species agrees across both models.
sig_a = _dynamic_stoich_signatures(source_model)
sig_b = _dynamic_stoich_signatures(reloaded_model)
if sig_a != sig_b:
only_a = sig_a - sig_b
only_b = sig_b - sig_a
if only_a:
mismatches.append(f"{sum(only_a.values())} reaction topolog(ies) only in source")
if only_b:
mismatches.append(f"{sum(only_b.values())} reaction topolog(ies) only in output")
if not mismatches:
delta = _max_rhs_delta(source_model, reloaded_model)
if not (delta <= rhs_tol):
mismatches.append(f"ODE RHS differs (max scale-relative |Δ| = {delta:.2e})")
return StructuralReport(
passed=not mismatches,
n_species=n_sp,
n_reactions=n_rxn,
n_parameters=n_par,
n_observables=n_obs,
mismatches=mismatches,
)
def net_to_sbml(
net_path: str | Path,
out_path: str | Path | None = None,
*,
validate: str | None = "L1",
strict: bool = True,
bngl: str | Path | None = None,
t_span: tuple[float, float] = (0.0, 100.0),
n_points: int = 101,
rhs_tol: float = 1e-6,
) -> ConversionReport:
"""Convert a BioNetGen ``.net`` network to SBML (Level 3 Version 2).
The reverse of :func:`sbml_to_net`. SBML can express amount/concentration
semantics and non-unit compartment volumes that the plain ``.net`` text
cannot, so this is the more faithful half of the round-trip: each kinetic
law is scaled by its reaction's compartment volume, so static non-unit
volumes (including cross-compartment reactions) round-trip RHS-exact (see
:func:`bngsim.convert.write_sbml`).
Parameters
----------
net_path : str | Path
Path to the source ``.net`` network.
out_path : str | Path | None
Where to write the ``.xml``. If None, the text is returned in the report
but no file is written.
validate : {"L1", "full", None}
``"L1"`` (default) reloads the emitted SBML and runs
:func:`validate_roundtrip` (structural + ODE-RHS equivalence). ``"full"``
runs the complete L0–L4 conversion-validation ladder (GH #217) and gates
on the hard levels L0–L3 — "convert *and prove faithful*"; the L4
symbolic check is recorded but never blocks. ``None`` skips validation. A
``"full"`` gate's verdict is attached as
:attr:`ConversionReport.validation` and decides
:attr:`ConversionReport.ok`.
strict : bool
Raise :class:`bngsim.ConversionError` on constructs SBML cannot carry
faithfully (live/time-varying volumes, ``tfun`` table functions, …).
``False`` downgrades to a :class:`bngsim.ConversionWarning` and emits a
best-effort document.
bngl : str | Path | None
The source ``.bngl`` the ``.net`` was generated from. When given, its
``simulate`` protocol is parsed and the ``"full"`` gate's L3 numerical
comparison runs over the model's *own* horizon (avoiding the blanket-grid
stiff-hang and exercising the trajectory the modeller actually ran). The
parsed :class:`~bngsim.convert.ProtocolSpec` is attached to the report.
t_span, n_points : tuple[float, float], int
Fallback time grid for the ``"full"`` gate's L3 comparison when no
``bngl`` protocol horizon is available (ignored for other ``validate``
values).
rhs_tol : float
Scale-relative tolerance for the ``"full"`` gate's L2 ODE-RHS identity.
Returns
-------
ConversionReport
"""
from bngsim._model import Model
net_path = Path(net_path)
model = Model.from_net(net_path)
protocol = None
if bngl is not None:
from bngsim.convert._protocol import parse_bngl_protocol
protocol = parse_bngl_protocol(bngl, strict=strict)
caps = sbml_capability_report(model)
sbml_text = write_sbml(model, out_path, strict=strict, model_id=net_path.stem)
structural: StructuralReport | None = None
max_delta: float | None = None
validation: ConversionValidationReport | None = None
if validate == "L1":
if out_path is not None:
sbml_model = Model.from_sbml(out_path)
else:
with tempfile.NamedTemporaryFile("w", suffix=".xml", delete=False) as tmp:
tmp.write(sbml_text)
tmp_path = tmp.name
try:
sbml_model = Model.from_sbml(tmp_path)
finally:
Path(tmp_path).unlink(missing_ok=True)
structural = validate_roundtrip(model, sbml_model)
max_delta = _max_rhs_delta(model, sbml_model)
elif validate == "full":
validation = grade_conversion(
"net2sbml",
model,
sbml_text,
levels="all",
strict=strict,
caps=caps,
source=str(net_path),
source_stem=net_path.stem,
t_span=t_span,
n_points=n_points,
rhs_tol=rhs_tol,
protocol=protocol,
)
elif validate is not None:
raise ValueError(f"unknown validate={validate!r}; expected None, 'L1', or 'full'")
return ConversionReport(
source=str(net_path),
out_path=str(out_path) if out_path is not None else None,
output_text=sbml_text,
n_species=model.n_species,
n_reactions=model.n_reactions,
n_parameters=model.n_parameters,
n_observables=model.n_observables,
dropped=caps["dropped"],
lossy=caps["lossy"],
structural=structural,
max_rhs_delta=max_delta,
validation=validation,
protocol=protocol,
)
# ─── OMEX packaging orchestrator ──────────────────────────────────────────
_GATE_TO_VALIDATE = {"none": None, "L1": "L1", "full": "full"}
def net_to_omex(
net_path: str | Path,
out_path: str | Path,
*,
bngl: str | Path | None = None,
protocol: EvaluationSpec | None = None,
gate: str = "full",
include_source: bool = True,
provenance: bool = True,
created: str | None = None,
t_span: tuple[float, float] = (0.0, 100.0),
n_points: int = 101,
strict: bool = True,
) -> ConversionReport:
"""Package a BioNetGen ``.net`` network as a COMBINE archive (``.omex``).
Converts the network to SBML (the BioModels-standard model carrier, GH #216),
bundles a SED-ML simulation protocol (GH #218), and writes both plus a
generated ``manifest.xml`` into one ``.omex`` zip — the standard, *verified
faithful* consumer container (GH #211, Option 3).
The protocol carried depends on what is supplied, in precedence order:
1. an explicit ``protocol`` :class:`~bngsim.EvaluationSpec` → a single uniform
time course (GH #218);
2. a source ``bngl`` → its **whole** actions block (every ``simulate`` /
``parameter_scan`` with its accumulated overrides) via
:func:`~bngsim.convert.write_sedml_protocol` — the modeller's actual
experiment, not a synthesized default;
3. neither → a default uniform time course over every observable.
Parameters
----------
net_path : str | Path
Source ``.net`` network.
out_path : str | Path
Destination ``.omex`` archive.
bngl : str | Path | None
The source ``.bngl`` the ``.net`` was generated from. Supplies the real
simulation protocol carried into the SED-ML *and* the L3 horizon for the
gate (see :func:`net_to_sbml`).
protocol : EvaluationSpec | None
An explicit single-run protocol; takes precedence over ``bngl``.
gate : {"full", "L1", "none"}
How hard to validate the ``.net``→SBML conversion before packaging
(default ``"full"`` — the OMEX is the faithful-deliverable container, so
it ships the L0–L4 verdict). The verdict is attached to the returned
report; :attr:`ConversionReport.ok` is False (CLI exit 1) on a hard-gate
failure.
include_source : bool
When True (default), also bundle the **original source files** into the
archive for provenance/completeness: the ``.net`` (a secondary, non-master
model entry) and — when given — the rule-based ``.bngl`` (a ``source``
entry; ``from_net`` cannot read rule-based BNGL, so it is provenance, not a
dispatchable model). The SBML stays the ``master`` curated entry, so this
is non-breaking for SBML-only consumers and lets a published archive carry
the modeller's actual rule-based formulation, not just the flattened SBML.
COMBINE/BioModels explicitly accept such supporting files.
provenance : bool
When True (default), record how the archive was produced: a COMBINE-standard
``metadata.rdf`` (``dcterms`` creator = ``bngsim <version>``, created date,
description) that BioModels/COMBINE tools read, plus a human/machine-readable
``bngsim-conversion.json`` carrying the full faithfulness verdict (gate level
and per-level L0–L4 result, ``ok`` / ``rhs_faithful`` / ``max_rhs_delta``,
dropped/lossy notes, source→target, counts). Makes the *verified-faithful*
claim auditable by anyone who opens the archive.
created : str | None
Timestamp (ISO-8601 / W3CDTF) stamped into the provenance. ``None`` (default)
uses the current UTC time; pass a fixed string for reproducible (byte-stable)
archives.
t_span, n_points : tuple[float, float], int
Time grid for the default protocol / the gate's L3 fallback when no
``bngl`` horizon is available.
strict : bool
Passed through to the ``.net``→SBML conversion and the ``.bngl`` parse
(refuse vs. best-effort on unrepresentable constructs).
Returns
-------
ConversionReport
The underlying ``.net``→SBML report (with the L0–L4 ``validation`` and the
parsed ``protocol``), ``out_path`` pointing at the ``.omex`` archive.
"""
from bngsim.convert._omex import write_omex
from bngsim.convert._sedml import default_protocol as _default_protocol
from bngsim.convert._sedml import write_sedml, write_sedml_protocol
if gate not in _GATE_TO_VALIDATE:
raise ValueError(f"unknown gate={gate!r}; expected one of {sorted(_GATE_TO_VALIDATE)}")
net_path = Path(net_path)
out_path = Path(out_path)
# Reuse the faithful network channel (gated); keep the SBML in memory. The
# bngl is parsed once here and reused for both the gate horizon and the SED-ML.
report = net_to_sbml(
net_path,
out_path=None,
validate=_GATE_TO_VALIDATE[gate],
strict=strict,
bngl=bngl,
t_span=t_span,
n_points=n_points,
)
sbml_text = report.output_text
parsed_protocol = report.protocol
# The SED-ML protocol references the SBML model inside the archive.
sbml_location = "./model.xml"
loc = sbml_location.lstrip("./")
if protocol is not None:
sedml_text = write_sedml(protocol, model_source=loc)
elif parsed_protocol is not None and not parsed_protocol.is_empty:
sedml_text = write_sedml_protocol(parsed_protocol, model_source=loc)
else:
# No real protocol available — fabricate a runnable default, but warn
# loudly and mark it as synthesized so it is never mistaken for the
# modeller's protocol (GH #211 fidelity).
why = (
"the .bngl carried no simulate action"
if bngl is not None
else "no .bngl protocol source was supplied"
)
from bngsim._exceptions import ConversionWarning
warnings.warn(
f"no simulation protocol available ({why}); bundling a "
"bngsim-generated DEFAULT uniform time course "
f"(t={t_span[0]:g}..{t_span[1]:g}, {n_points} points, ODE) — this is a "
"runnable placeholder, NOT the modeller's protocol",
ConversionWarning,
stacklevel=2,
)
default = _default_protocol(
net_path,
model_source=loc,
model_format="sbml",
t_span=t_span,
n_points=n_points,
)
sedml_text = write_sedml(default, model_source=loc, synthesized_default=True)
# Bundle the original source files for provenance (the SBML stays master): the
# flattened .net as a secondary model entry, and the rule-based .bngl — the
# modeller's actual formulation — as a `source` entry. So a BioModels deposit
# carries the real BNGL, not just the SBML projection.
from bngsim.convert._protocol import _looks_like_path, _read_text
source_net: bytes | None = None
net_location = "./model.net"
extra: list[tuple[str, str | bytes | Path, str | None]] = []
if include_source:
from bngsim.convert._omex import _FMT_BNGL
source_net = net_path.read_bytes()
net_location = "./" + net_path.name
if bngl is not None:
bngl_text = _read_text(bngl)
bngl_name = Path(bngl).name if _looks_like_path(bngl) else "model.bngl"
extra.append(("./" + bngl_name, bngl_text, _FMT_BNGL))
# Provenance: how this archive was produced + the faithfulness verdict, both as
# a COMBINE-standard metadata.rdf (tools read it) and a bngsim-conversion.json
# (humans/auditors read it). Makes the "verified faithful" claim portable.
metadata_rdf: str | None = None
if provenance:
import json as _json
from datetime import datetime, timezone
from bngsim import __version__ as _version
from bngsim.convert._omex import _FMT_JSON, build_metadata_rdf
ts = created or datetime.now(timezone.utc).isoformat(timespec="seconds")
if bngl is None:
protocol_source = None
elif _looks_like_path(bngl):
protocol_source = Path(bngl).name
else:
protocol_source = "<inline bngl>"
record = {
"tool": "bngsim",
"version": _version,
"created": ts,
"conversion": {
"direction": "net_to_omex",
"source": net_path.name,
"source_format": "bngnet",
"target_format": "sbml",
"protocol_source": protocol_source,
"gate": gate,
"ok": report.ok,
"rhs_faithful": report.rhs_faithful,
"max_rhs_delta": report.max_rhs_delta,
"validation": (
[
{
"level": lv.level,
"name": lv.name,
"status": lv.status,
"detail": lv.detail,
}
for lv in report.validation.levels
]
if report.validation is not None
else None
),
"dropped": report.dropped,
"lossy": report.lossy,
},
"counts": {
"species": report.n_species,
"reactions": report.n_reactions,
"parameters": report.n_parameters,
"observables": report.n_observables,
},
}
extra.append(("./bngsim-conversion.json", _json.dumps(record, indent=2), _FMT_JSON))
metadata_rdf = build_metadata_rdf(
creator=f"bngsim {_version}",
created=ts,
description=(
f"Generated by bngsim {_version}: BioNetGen .net → SBML "
f"(gate={gate}, {'PASS' if report.ok else 'FAIL'}). "
"See bngsim-conversion.json for the faithfulness verdict."
),
)
write_omex(
out_path,
sbml=sbml_text,
net=source_net,
sedml=sedml_text,
metadata=metadata_rdf,
sbml_location=sbml_location,
net_location=net_location,
extra=extra or None,
master="sbml",
)
return ConversionReport(
source=str(net_path),
out_path=str(out_path),
output_text=sbml_text,
n_species=report.n_species,
n_reactions=report.n_reactions,
n_parameters=report.n_parameters,
n_observables=report.n_observables,
dropped=report.dropped,
lossy=report.lossy,
validation=report.validation,
protocol=parsed_protocol,
)
def omex_to_net(
omex_path: str | Path,
out_path: str | Path | None = None,
*,
gate: str = "full",
strict: bool = True,
t_span: tuple[float, float] = (0.0, 100.0),
n_points: int = 101,
actions_out: str | Path | None = None,
write_actions: bool = True,
) -> ConversionReport:
"""Unpack a COMBINE archive (``.omex``) to a BioNetGen ``.net`` network.
The reverse of :func:`net_to_omex`: read the archive's master SBML model and
its SED-ML protocol sidecar, convert the SBML to a ``.net`` (GH #215), and use
the carried protocol to drive the gate's L3 horizon — the model's *own*
integrable time range, exactly as :func:`net_to_omex` uses the source ``.bngl``
going the other way. This closes the round-trip container symmetry: a ``.omex``
written by :func:`net_to_omex` reads back to an equivalent network here.
Multi-experiment / multi-file protocols (GH #222). The archive's **whole**
protocol is recovered, not just the master SED-ML entry's first experiment:
every ``uniformTimeCourse`` / ``repeatedTask`` from **every** SED-ML file is
composed (via :func:`~bngsim.convert.combine_protocols`) into one ordered
:class:`~bngsim.convert.ProtocolSpec` and attached to the report. When more
than one SED-ML file is present a note is emitted (the master alone no longer
silently wins). The composed protocol is also emitted as a **``.bngl`` actions
block** alongside the ``.net`` (``write_actions``) — the natural BNGL form of a
multi-phase experiment (``simulate`` / ``setConcentration`` / ``setParameter``
/ ``resetConcentrations``), restoring the symmetry with :func:`net_to_omex`,
which already carries the whole protocol forward. The gate's L3 horizon still
uses the **representative** experiment (the first deterministic run — see
:meth:`ProtocolSpec.primary_experiment`); gating every experiment's horizon is
left as a deliberate non-goal (it risks a stiff-grid hang for no added
faithfulness signal over the representative run).
Parameters
----------
omex_path : str | Path
Source ``.omex`` archive (SBML model + SED-ML protocol + manifest).
out_path : str | Path | None
Where to write the ``.net``. If None, the text is returned in the report
but no file is written.
gate : {"full", "L1", "none"}
How hard to validate the SBML→``.net`` conversion before accepting it
(default ``"full"`` — the OMEX is the *verified faithful* container, so the
unpack ships the L0–L4 verdict, mirroring :func:`net_to_omex`'s default).
The verdict is attached to the returned report; :attr:`ConversionReport.ok`
is False (CLI exit 1) on a hard-gate failure.
strict : bool
Passed through to the SBML→``.net`` conversion (refuse vs. best-effort on
constructs plain ``.net`` cannot carry — events, live/AR volumes, MM).
t_span, n_points : tuple[float, float], int
Fallback L3 grid when the archive carries no SED-ML horizon.
actions_out : str | Path | None
Where to write the ``.bngl`` actions block. ``None`` (default) derives it
from ``out_path`` (``<stem>.bngl``); ignored when ``write_actions`` is
False or the archive carries no experiment.
write_actions : bool
Emit the composed protocol as a ``.bngl`` actions block (default True). No
file is written when the archive carries no protocol, or when neither
``actions_out`` nor ``out_path`` gives a destination.
Returns
-------
ConversionReport
The SBML→``.net`` report (with the L0–L4 ``validation``, the **composed**
multi-experiment SED-ML ``protocol``, and ``bngl_out`` pointing at the
emitted actions block when written), ``out_path`` pointing at the written
``.net`` (or None when only the in-memory text was requested).
"""
from bngsim._exceptions import ConversionError, ConversionWarning
if gate not in _GATE_TO_VALIDATE:
raise ValueError(f"unknown gate={gate!r}; expected one of {sorted(_GATE_TO_VALIDATE)}")
omex_path = Path(omex_path)
with read_omex(omex_path) as archive:
model_entry = archive.master_model_entry()
if model_entry is None:
raise ConversionError(
f"{omex_path.name}: archive has no model entry to convert to .net"
)
if model_entry.kind != "sbml":
raise ConversionError(
f"{omex_path.name}: master model is a {model_entry.kind!r}, not SBML; "
"omex_to_net unpacks the SBML→.net direction (the archive already "
"carries a network — extract it with bngsim-omex unpack)"
)
sbml_path = archive.path_of(model_entry)
sed_entry = archive.master_sedml_entry()
sedml_path = archive.path_of(sed_entry) if sed_entry is not None else None
report = sbml_to_net(
sbml_path,
out_path,
validate=_GATE_TO_VALIDATE[gate],
strict=strict,
sedml=sedml_path,
t_span=t_span,
n_points=n_points,
)
# Compose the WHOLE protocol — every experiment from every SED-ML file —
# not just the master entry's first run that drove the gate horizon.
sed_entries = archive.sedml_entries()
if len(sed_entries) > 1:
warnings.warn(
f"{omex_path.name}: archive carries {len(sed_entries)} SED-ML files; "
"composing every experiment from all of them into one protocol "
"(previously only the master/first file was used)",
ConversionWarning,
stacklevel=2,
)
full_protocol = archive.load_full_protocol() if sed_entries else report.protocol
if full_protocol is not None and not full_protocol.is_empty:
report.protocol = full_protocol
if write_actions:
dest = actions_out
if dest is None and out_path is not None:
dest = Path(out_path).with_suffix(".bngl")
if dest is not None:
from bngsim.convert._protocol import write_bngl_protocol
write_bngl_protocol(full_protocol, dest)
report.bngl_out = str(dest)
# Re-label the source so the report reads as an OMEX unpack, not a bare SBML.
report.source = str(omex_path)
return report