"""PEtab v2 exporter: a PyBNF/BNGL job -> PEtab v2 artifacts (#407/#423; ADR-0025/27/28).
The **exporter-first** direction of the PEtab interop (ADR-0025): a working PyBNF
BNGL job and a native ``.conf`` are *read* and serialized to a PEtab v2 problem
(``parameters.tsv`` / ``observables.tsv`` / ``measurements.tsv`` / ``conditions.tsv`` /
``experiments.tsv`` / ``problem.yaml`` + a PEtab-clean copy of the model), rather than
*generating* BNGL from a declarative PEtab spec (the harder importer direction, deferred).
The reverse asset mappings live beside their importer twins
(``parameters.petab_parameter_row``, ``observables.petab_observable_row``,
``measurements.measurement_rows_from_data``, ``conditions.build_*``); this module is the
*disposable* glue: it reads the job (the stdlib ``ploop`` config parser, a focused BNGL
block reader, and :class:`pybnf.data.Data` for the ``.exp``) and writes the files.
**Why a function is the measurement model.** A fitted ``.exp`` column matches a BNGL
**observable** *or* a **function** (PyBNF forces ``print_functions=>1``), and the
function is usually the measurement model. So an observable column exports to
``observableFormula = <name>`` and a function column to ``observableFormula =
<name>`` too -- always the *bare model name*, with the function carried verbatim in
the model file (ADR-0025). PEtab ids are prefixed (``obs_``/``func_`` for
observables, the unprefixed model name for parameters) to keep the PEtab-id namespace
disjoint from the model-entity namespace.
**New-era only (ADR-0028/0031): export is transcription.** PEtab v2 interop is a new-era
feature, and the exporter reads *only* the new-era surface -- both the objective
(``objective`` / ``noise_model``, never the retired ``objfunc``, no implicit default;
ADR-0031) **and** the data linkage (``model:`` / ``experiment:`` / ``data:`` /
``condition:`` / ``observable:``; ADR-0028). An ``experiment:`` *is* a PEtab Experiment
(experimentId = the experiment name) carrying its ``data:`` replicates as measurement
rows; a ``condition:`` *is* a PEtab Condition; an ``observable:`` renames a data column
before classification. ``export_job`` **refuses** a legacy (edition 1) job
(:func:`_require_modern_edition`) and a legacy data linkage -- ``model = X : Y.exp`` /
``mutant`` / ``param_scan`` (:func:`_require_new_era_data`) -- rather than reverse-mapping
it. The gate is on the exporter alone; the fitter still runs legacy confs unchanged.
**Conditions/experiments (ADR-0027/0028).** A ``condition:`` referenced by an experiment
becomes a PEtab Condition/Experiment via the surrogate-base ``<p>__REF`` rename of a
fit-and-perturbed parameter (see :mod:`pybnf.petab.conditions`,
:func:`~pybnf.petab.conditions.build_experiment_conditions`); a shared condition emits its
rows once. A dose-response (parameter scan) experiment takes the dual shape (ADR-0046):
each dose becomes a Condition setting the swept parameter + an Experiment, measured at the
scan time (``inf`` for the steady-state default => PEtab time=inf, or a finite ``t_end:``).
The objective and prior surfaces map to PEtab as far as PEtab v2 can express them
(ADR-0023/0031 reversed): the Gaussian/Laplace likelihoods with a ``_SD``-column,
fixed, or column-mean sigma (``chi_sq``/``sos``/``sod``/``ave_norm_sos``), and the
``uniform``/``log-uniform``/``normal``/``laplace`` prior families with their log forms.
A BNGL (``.bngl``) or SBML (``.xml``) model is exported in its own native language
(ADR-0040): a BNGL model PEtab-cleaned, an SBML model carried byte-verbatim with its
observables emitted as ``observableFormula`` expressions from the conf measurement-model
layer (the mirror of the ADR-0036 import). A job may declare **more than one model**
(ADR-0041): each ``experiment:`` names the model it simulates, that model's id is stamped
on the experiment's measurement rows (the ``modelId`` link), free parameters bind across
the union of every model's ids, and ``problem.yaml`` lists every model in its own language
(BNGL + SBML may mix). Everything else raises ``NotImplementedError`` (the boundary is in
code, not silent): an objective PEtab cannot represent (``neg_bin*`` -- removed from v2;
``lognormal`` -- log10 vs PEtab natural log; a free-parameter or relative sigma;
``direct_pass``/``kl``/``wasserstein``); the no-prior ``var``/``logvar``; a ``.con``/``.prop``
Constraint; an Antimony (``.ant``) model. The
oracle is petab's full ``default_validation_tasks`` via ``Problem.from_yaml`` + the native
``BnglModel`` loader (ADR-0026), wired into the tests; see ADR-0025/0027/0028/0036/0040.
"""
import logging
import re
from dataclasses import dataclass, field
from pathlib import Path
import numpy as np
from .. import edition
from ..data import Data
from ..objective import _OBJECTIVE_DESUGAR
from ..parse import ploop
from ..printing import PybnfError
from ..pset import FreeParameter
from ._bngl import parse_model as parse_bngl_model
from ._sbml import parse_model as parse_sbml_model
from .conditions import (
build_dose_response_conditions,
build_experiment_conditions,
build_preequilibration_conditions,
surrogate_name,
write_condition_table,
write_experiment_table,
)
from ._measurement_params import read_measurement_params
from .formula import bngl_body_to_petab_math
from .measurements import (
dose_response_measurement_rows,
measurement_rows_from_data,
write_measurement_table,
)
from .observables import petab_observable_row, write_observable_table
from .parameters import (
EXPORTABLE_PRIOR_KEYWORDS,
petab_parameter_row,
write_parameter_table,
)
logger = logging.getLogger(__name__)
# A noise-model family token (ADR-0031's noise_model grammar / ``_OBJECTIVE_DESUGAR``)
# -> the PEtab v2 noiseDistribution it maps to (ADR-0023 reversed). The LINEAR Gaussian
# / Laplace likelihoods map (``normal`` is the Gaussian alias); the other families are
# explicit boundaries handled in ``_resolve_noise``: ``neg_bin`` was removed from PEtab
# v2, and PyBNF's ``lognormal`` is log10 whereas PEtab's ``log-normal`` is natural log (a
# deferred sigma scale-conversion).
_FAMILY_TOKEN_TO_PETAB_DISTRIBUTION = {
'gaussian': 'normal', 'normal': 'normal', 'laplace': 'laplace'}
# Free-parameter declaration keywords (the ``(keyword, name)`` tuple keys ``ploop``
# emits). Only ``uniform_var`` exports in chunk 1; the rest raise.
_VAR_DECL = re.compile(r'(_var$|^var$|^logvar$)')
# A legacy ``<name>__FREE`` bind-by-id marker. New-era BNGL binds free parameters by id
# (ADR-0034), so a model carrying this token was not modernized; the exporter refuses it
# rather than ship a ``v1__FREE`` symbol into PEtab (where it would dangle).
_FREE_TOKEN = re.compile(r'\w+__FREE')
# A per-measurement placeholder in a noiseFormula (``noiseParameter1`` / ``observableParameter2``):
# its presence marks a row-varying ``PerMeasurementFormulaSigma`` sigma whose per-row value comes
# from the binding-table sidecar, distinct from a constant ``FormulaSigma`` (ADR-0045). Mirrors
# ``objective._PLACEHOLDER_IN_FORMULA`` / ``import_._PLACEHOLDER``.
_PLACEHOLDER = re.compile(r'(?:observable|noise)Parameter\d')
# ---------------------------------------------------------------------------
# The exporter driver
# ---------------------------------------------------------------------------
[docs]
def export_job(conf_path, out_dir, inline_functions=False):
"""Export the PyBNF job at ``conf_path`` to a PEtab v2 problem in ``out_dir``.
Reads the job's data/conditions/observables from the **new-era surface** (ADR-0028):
a ``model:`` declaration, named ``experiment:`` lines carrying their ``data:`` files
(a PEtab Experiment; multiple files = replicates), ``condition:`` perturbations (a
PEtab Condition), and ``observable:`` column-header overrides. Writes
``parameters.tsv``, ``observables.tsv``, ``measurements.tsv``, a PEtab-clean copy of
the BNGL model, ``problem.yaml``, and -- when some experiment applies a condition --
``conditions.tsv`` / ``experiments.tsv``. Returns the ``out_dir`` path.
``inline_functions`` (default ``False``) is the opt-in expression mode (ADR-0035):
when set, a fitted **function** column emits its body as an ``observableFormula``
expression (translated to PEtab math, requires the ``pybnf[petab]`` extra) instead of
the bare model name, producing a model-portable problem and the round-trip oracle the
importer's synthesis is graded against. The default stays bare-name, lossless,
byte-stable, and ``petab``-free; an observable column is never inlined.
New-era only (ADR-0028 Chunk 5c, "refuse legacy everything"): a job that binds data
the legacy way (``model = X : Y.exp`` / ``mutant`` / ``param_scan``) is refused, as is
a legacy-edition job (:func:`_require_modern_edition`). Raises ``NotImplementedError``
at every documented boundary and ``PybnfError`` for a malformed/unsupported job (see
the module docstring).
"""
conf_path = Path(conf_path)
out_dir = Path(out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
conf = _read_conf_dict(conf_path)
_require_modern_edition(conf)
models = _resolve_models(conf)
languages = {mf: _model_language(mf) for mf in models}
_require_new_era_data(conf, models)
noise = _resolve_noise(conf)
per_obs_noise = _resolve_per_observable_noise(conf)
_reject_cumulative(conf)
_reject_normalization(conf)
free_params = _free_parameters_from_conf(conf)
# An estimated (`fit`) sigma exports as a bare-id noiseFormula naming an estimated PEtab
# parameter (#439), so its noise scale must be a DECLARED free parameter (with bounds/prior
# to write). The legacy whole-fit `chi_sq_dynamic` synthesizes an IMPLICIT `sigma__FREE` the
# user never declared, so it has nothing to write -- still a boundary.
_declared_free = {fp.name for fp in free_params}
for _dist, _verb, _arg in [noise, *per_obs_noise.values()]:
if _verb == 'fit' and _arg not in _declared_free:
raise NotImplementedError(
f"The objective's estimated sigma '{_arg}' is an implicit noise parameter "
f"(e.g. chi_sq_dynamic's sigma__FREE) that is not a declared free parameter, so "
f"it has no bounds/prior to export as a PEtab estimated parameter. Declare it as "
f"a free parameter (as a per-observable `fit` noise scale does) to export it. "
f"ADR-0021/0023, #439.")
# A registry of per-language model views (ADR-0040/0041): a job may mix BNGL + SBML,
# each read once and threaded through the language-agnostic classification below.
registry = {mf: _read_model(mf, conf_path.parent / mf, languages[mf]) for mf in models}
# Observation-layer nuisance free parameters (ADR-0034/0044/0045): a measurement scale, a
# noise coefficient, or a row-varying per-row sigma -- a free parameter referenced by a
# measurement-model / noise formula or a binding-table token but NOT a model entity. The
# bind-by-id check admits them as unbound estimated parameters (else they read as typos);
# the rest must still bind to a model id.
model_ids = set().union(*(set(v.parameters) for v in registry.values()))
nuisances = (_referenced_nuisance_symbols(conf, conf_path, noise, per_obs_noise)
& {fp.name for fp in free_params}) - model_ids
free_to_model = _resolve_free_to_model(free_params, registry, models, nuisances)
fit_model_params = set(free_to_model.values()) & model_ids
(observable_rows, measurement_rows, condition_rows, experiment_rows,
surrogate_params) = _export_new_era(
conf, conf_path, models, registry, noise, per_obs_noise, fit_model_params,
inline_functions)
parameter_rows = _parameter_rows(
free_params, free_to_model, surrogate_params, registry, models)
write_parameter_table(parameter_rows, out_dir / 'parameters.tsv')
write_observable_table(observable_rows, out_dir / 'observables.tsv')
write_measurement_table(measurement_rows, out_dir / 'measurements.tsv')
if condition_rows:
write_condition_table(condition_rows, out_dir / 'conditions.tsv')
if experiment_rows:
write_experiment_table(experiment_rows, out_dir / 'experiments.tsv')
# Each model is emitted in its own native language (ADR-0040): a BNGL model is
# PEtab-cleaned (drop 'begin actions'); an SBML model is carried byte-verbatim (the
# measurement model lives in the observables table, never a model-file edit -- ADR-0036).
for mf in models:
view, language = registry[mf], languages[mf]
model_text = clean_model_for_petab(view.text) if language == 'bngl' else view.text
(out_dir / Path(mf).name).write_text(model_text)
# One model_files entry per model (ADR-0041), modelId = the file stem, in declaration
# order (so a re-export reproduces the same problem.yaml model_files ordering).
model_yaml = [(Path(mf).stem, Path(mf).name, languages[mf]) for mf in models]
write_problem_yaml(out_dir / 'problem.yaml', model_yaml,
has_conditions=bool(condition_rows),
has_experiments=bool(experiment_rows))
return out_dir
# ---------------------------------------------------------------------------
# New-era surface reading (ADR-0028): export becomes transcription
# ---------------------------------------------------------------------------
def _has_new_era_data(conf):
"""True iff the job binds data via the new-era ``experiment:`` surface (ADR-0028).
A fully new-era conf introduces data through ``('experiment', name)`` entries (a PEtab
Experiment carrying its ``data:``), never the legacy ``model = X : Y.exp`` linkage.
"""
return any(isinstance(k, tuple) and len(k) == 2 and k[0] == 'experiment'
for k in conf)
def _require_new_era_data(conf, models):
"""Refuse a legacy data linkage -- the PEtab v2 exporter reads only the new-era data
surface (ADR-0028 Chunk 5c, "refuse legacy everything").
The exporter is already new-era-gated on the *objective* (``_require_modern_edition``);
now that ADR-0028's data surface exists, the data linkage is held to the same standard.
A job must introduce data through named ``experiment:`` lines (a PEtab Experiment
carrying its ``data:``); the legacy filename->suffix binding (``model = X : Y.exp``),
``mutant`` lines, and ``param_scan`` actions are refused rather than silently read.
Mixing the two (a ``mutant``/``param_scan`` line, or data on any ``model =`` line,
alongside ``experiment:``) is refused too, so a legacy line is never silently dropped.
The gate is on the *exporter* only; the fitter still runs legacy confs unchanged.
"""
legacy_data = any(conf.get(mf) for mf in models) # model = X : Y.exp data list
legacy_features = [k for k in ('mutant', 'param_scan') if k in conf]
if not _has_new_era_data(conf):
raise NotImplementedError(
"The PEtab v2 exporter reads the new-era data surface; this job binds data "
"the legacy way (model = X : Y.exp / mutant / param_scan), which is refused "
"(ADR-0028, 'refuse legacy everything'). Re-author it on the new-era surface: "
"declare the model with 'model:', bind data through a named 'experiment:' (one "
"'data:' file per replicate), and write perturbations as 'condition:' lines. "
"The fitter still runs the legacy form; only export requires the new surface.")
if legacy_data or legacy_features:
legacy = (['data on the legacy model = X : Y.exp line'] if legacy_data else []) \
+ [f"a '{k}' line" for k in legacy_features]
raise NotImplementedError(
f"This job mixes the new-era 'experiment:' surface with legacy data linkage "
f"({', '.join(legacy)}). Use the new-era surface exclusively -- move all data "
f"into 'experiment:'/'data:' and all perturbations into 'condition:' -- so no "
f"legacy line is silently ignored on export (ADR-0028, #423).")
def _export_new_era(conf, conf_path, models, registry, noise, per_obs_noise,
fit_model_params, inline_functions=False):
"""Read a job's data/conditions/observables from the **new-era surface** (ADR-0028).
Export is *transcription*: an ``experiment:`` is a PEtab Experiment (experimentId =
the experiment name) carrying its ``data:`` replicates as measurement rows; an
``observable:`` line renames a data column to a model entity before classification.
``models`` is the ordered list of model files and ``registry`` their per-language views
(ADR-0041): each experiment names the model it simulates, that model's id is stamped on
its measurement rows' ``modelId`` (omitted when the job is single-model), and a column
is classified against its experiment's model. ``noise`` is the whole-fit base and
``per_obs_noise`` the ``{column: (dist, verb, arg)}`` per-observable overrides
(ADR-0021/0045): a column with an override takes its own sigma source, the rest the base.
``inline_functions`` is threaded to :func:`_observable_rows` (ADR-0035 inlining). Returns
``(observable_rows, measurement_rows, condition_rows, experiment_rows, surrogate_params)``.
A ``condition:`` referenced by an experiment becomes a PEtab Condition (the
surrogate-base machinery of ADR-0027, generalized by
:func:`~pybnf.petab.conditions.build_experiment_conditions`): a fit-and-perturbed
parameter is renamed to ``<p>__REF`` in the parameter table and pinned in every
experiment's Condition. A **parameter-scan** (dose-response) experiment takes the
dual shape (ADR-0046): each dose of its swept-axis ``.exp`` becomes a Condition
setting the swept parameter + an Experiment, measured at the scan time (``inf`` for the
steady-state default => PEtab time=inf, or a finite ``t_end:``), via
:func:`~pybnf.petab.conditions.build_dose_response_conditions` +
:func:`~pybnf.petab.measurements.dose_response_measurement_rows`. With no referenced
conditions the surrogate set is empty, so a single wildtype time course is byte-identical
to the chunk-1 base.
A **pre-equilibration** experiment (``preequilibrate:``, ADR-0052) takes a third shape: a
two-period Experiment (a ``time = -inf`` steady-state period under the pre-equilibration
condition + a ``time = 0`` period under the measurement condition), built by
:func:`~pybnf.petab.conditions.build_preequilibration_conditions`. Its measurements are
tagged exactly like a time course's (the data grid at times >= 0; the equilibration period
carries no measurements).
"""
experiments = _read_experiments(conf, conf_path, models)
overrides = _read_observable_overrides(conf)
all_datas = [d for exp in experiments for d in exp['datas']]
_apply_observable_overrides(all_datas, overrides)
measurement_models = _read_measurement_models(conf)
observable_rows, column_to_observable_id = _observable_rows(
experiments, registry, noise, per_obs_noise, inline_functions, measurement_models)
# Three PEtab experiment shapes (ADR-0046/0052): a time course is one Experiment over a
# referenced Condition; a dose-response (parameter_scan) is N Conditions (each sets the swept
# parameter to one dose) + N Experiments measured at the scan time (inf for steady state); a
# pre-equilibration experiment is a two-period Experiment (a -inf steady-state period + a
# time=0 measurement period -- ADR-0052). They build independently and concatenate. A
# pre-equilibration experiment is a time course measured after equilibrating, so it is split
# off the time-course bucket by its ``preequilibrate`` field.
pe_experiments = [exp for exp in experiments if exp['preequilibrate'] is not None]
tc_experiments = [exp for exp in experiments
if exp['type'] == 'time_course' and exp['preequilibrate'] is None]
dr_experiments = [exp for exp in experiments if exp['type'] == 'parameter_scan']
conditions = _read_conditions(conf, models, registry)
referenced = {exp['condition'] for exp in tc_experiments if exp['condition'] is not None}
# A pre-equilibration experiment references its pre-equilibration condition AND (optionally)
# its measurement condition NOT via the time-course ``condition:`` path, so add both to
# ``referenced`` explicitly -- else _read_conditions drops them as "unused" (ADR-0052, #441).
for exp in pe_experiments:
referenced.add(exp['preequilibrate'])
if exp['condition'] is not None:
referenced.add(exp['condition'])
undefined = referenced - set(conditions)
if undefined:
raise PybnfError(
f"Experiment(s) reference undefined condition(s) {sorted(undefined)}; define "
f"each with a 'condition:' line.")
unused = set(conditions) - referenced
if unused:
# An unused condition emits no PEtab rows (the fitter would not apply it either);
# skip it with a debug log rather than warning (ADR-0028 Chunk 5, decision 4).
logger.debug("Conditions defined but referenced by no experiment (skipped): %s",
sorted(unused))
# The surrogate set M (ADR-0027) is problem-global: removing a fit-and-perturbed param from
# the parameter table makes its model name a pure condition target, so EVERY condition in
# EVERY experiment shape must re-pin it. A pre-equilibration condition that perturbs a fit
# param therefore contributes to M too (#443); thread that contribution into the time-course
# builder's M via extra_surrogate, so the time-course/wildtype conditions re-pin it as well.
pe_surrogate = {
var for exp in pe_experiments
for c in ([exp['preequilibrate']]
+ ([exp['condition']] if exp['condition'] is not None else []))
for var, _op, _val in conditions[c] if var in fit_model_params}
condition_rows, experiment_rows, surrogate_params, experiment_to_id = \
build_experiment_conditions(
[(exp['name'], exp['condition']) for exp in tc_experiments],
conditions, fit_model_params, lambda v: _nominal_of(registry, v),
extra_surrogate=pe_surrogate)
# Pre-equilibration experiments -> two-period Experiments (ADR-0052): a -inf steady-state
# period under the pre-equilibration condition + a time=0 period under the measurement
# condition. They share the problem-global M (every period re-pins M -- #443): a
# fit-parameter perturbation in a pre-equilibration period emits its surrogate op and every
# other period re-pins the base; a wash-out re-pins M via the synthesized cond_wildtype base.
# existing_condition_ids dedups a condition shared with a time course and the wildtype base.
if pe_experiments:
pe_condition_rows, pe_experiment_rows, pe_experiment_to_id = \
build_preequilibration_conditions(
[(exp['name'], exp['preequilibrate'], exp['condition'])
for exp in pe_experiments],
conditions, lambda v: _nominal_of(registry, v),
surrogate=surrogate_params,
existing_condition_ids={r.condition_id for r in condition_rows})
condition_rows += pe_condition_rows
experiment_rows += pe_experiment_rows
experiment_to_id.update(pe_experiment_to_id)
# Per-point numeric noiseParameters are emitted only when a column's sigma comes from a
# data column (the read_exp_file placeholder source); a fixed / column-mean / formula sigma
# is carried inline in noiseFormula, so the measurement export must not read _SD then (it
# would leave a noiseParameters override with no placeholder to bind to). With per-observable
# overrides the suffix is **per column** (ADR-0045): each column uses its own sigma source
# (its override, else the whole-fit base) to decide whether it reads a _SD companion.
def _sd_suffix_for(col):
_dist, verb, arg = per_obs_noise.get(col, noise)
return arg if verb == 'read_exp_file' else None
sd_suffix = {col: _sd_suffix_for(col) for col in column_to_observable_id}
# The modelId link (ADR-0041): each experiment stamps its model's stem onto its
# measurement rows. Single-model -> '' (the column is dropped on write, byte-stable).
multi_model = len(models) > 1
measurement_rows = []
# A pre-equilibration experiment's measurements are tagged exactly like a time course's
# (the data grid at times >= 0 under its experimentId): the -inf equilibration period
# carries no measurements, and PEtab resolves the data times into the time=0 period (ADR-0052).
for exp in tc_experiments + pe_experiments:
eid = experiment_to_id[exp['name']]
model_id = Path(exp['model']).stem if multi_model else ''
# Each replicate Data contributes its own rows under the one experiment (PEtab
# models replicates as repeated rows -- no need to pre-stack as config.py does). A
# row-varying placeholder's per-row token comes from the experiment's measurement_params
# sidecar (ADR-0045), shared across the replicate Datas (each measures the same cells).
for data in exp['datas']:
cmap = {c: o for c, o in column_to_observable_id.items() if c in data.cols}
measurement_rows += measurement_rows_from_data(
data, cmap, experiment_id=eid, sd_suffix=sd_suffix, model_id=model_id,
measurement_params=exp['measurement_params'])
# Dose-response (ADR-0046): each dose row of the swept-axis .exp becomes its own Condition
# (setting the swept parameter) + Experiment, and the observable columns become measurements
# at the scan time (inf => steady state). The swept-parameter column (the data's indvar) is
# the scan axis, not a measurement, so it is dropped from the column map.
for exp in dr_experiments:
stem = exp['name']
model_id = Path(exp['model']).stem if multi_model else ''
scan_time = exp['scan_time']
data0 = exp['datas'][0]
swept_param = data0.indvar if data0.indvar is not None else _independent_variable(data0)
dose_values = [float(v) for v in data0[swept_param]]
dr_conditions, dr_experiment_rows, experiment_ids = build_dose_response_conditions(
stem, swept_param, dose_values, scan_time)
condition_rows += dr_conditions
experiment_rows += dr_experiment_rows
for data in exp['datas']:
cmap = {c: o for c, o in column_to_observable_id.items()
if c in data.cols and c != swept_param}
measurement_rows += dose_response_measurement_rows(
data, cmap, experiment_ids, scan_time, sd_suffix=sd_suffix, model_id=model_id)
return observable_rows, measurement_rows, condition_rows, experiment_rows, \
surrogate_params
def _read_experiments(conf, conf_path, models):
"""Read + resolve the new-era ``experiment:`` entries from the raw ``ploop`` dict.
Each ``('experiment', name)`` entry is ``{'data': [files], 'condition': c?, 'model':
mf?, 'type': t?, 'method': m?, 't_end': t?, 'preequilibrate': p?, 'measurement_params':
mp?}``. ``models`` is the ordered list of the job's model files. Returns a list (declaration
order) of dicts ``{'name', 'condition', 'model': model_file, 'datas': [Data, ...], 'type',
'scan_time', 'preequilibrate': cond?, 'measurement_params': table?}`` -- the ``data:`` files
read as individual
:class:`~pybnf.data.Data` replicates (PEtab models replicates as repeated measurement
rows, so they are not pre-stacked), each experiment's resolved model
(:func:`_resolve_experiment_model`, ADR-0041), the inferred ``type`` (``'time_course'`` or
``'parameter_scan'``), the dose-response ``scan_time`` (``inf`` for the steady-state
default, a finite ``t_end:`` otherwise; ``None`` for a time course -- ADR-0046), and its
row-varying per-measurement binding table read from the ``measurement_params:`` sidecar
(``{column: {placeholder: {time: token}}}``, or ``None`` when none -- ADR-0045). Raises:
* the ambiguous-model error if an experiment names no model but the job has more than
one (mirrors ``config.py::_resolve_experiment_model``);
* a not-yet-supported boundary for a parameter_scan that also names a ``condition:``
(a dose-response already makes each dose its own condition -- ADR-0046), or a
``preequilibrate:`` parameter_scan (a scan after equilibration has no export route);
* a constraint refusal for non-``.exp`` data: BPSL ``.con``/``.prop`` constraints are
PyBNF-native with no core-PEtab representation, so an experiment carrying them cannot
be exported (the fitter still runs it -- ADR-0028 addendum).
"""
stem_to_model = {Path(mf).stem: mf for mf in models}
experiments = []
for key, fields in conf.items():
if not (isinstance(key, tuple) and len(key) == 2 and key[0] == 'experiment'):
continue
name = key[1]
model_file = _resolve_experiment_model(name, fields.get('model'), models,
stem_to_model)
data_files = fields.get('data', [])
if not data_files:
raise PybnfError(f"Experiment '{name}' declares no 'data:' files.")
non_exp = [f for f in data_files if not f.endswith('.exp')]
if non_exp:
raise NotImplementedError(
f"Experiment '{name}' carries BPSL constraint data ({non_exp}). PyBNF "
f"constraints (.con/.prop) are a native qualitative-fitting feature with no "
f"core-PEtab v2 representation, so this experiment cannot be exported "
f"(ADR-0028). The fitter still runs it; only export is refused. Drop the "
f"constraint file(s) from 'data:' to export the quantitative measurements "
f"alone.")
datas = [Data(file_name=str(conf_path.parent / f)) for f in data_files]
exp_type = _experiment_type(name, datas[0], fields.get('type'))
preequilibrate = fields.get('preequilibrate')
# A pre-equilibration experiment (ADR-0052) is a time course measured AFTER an
# unmeasured steady-state equilibration phase -> a PEtab two-period Experiment (#441).
# A scan after equilibration has no export route (mirrors the fitter's refusal).
if preequilibrate is not None and exp_type == 'parameter_scan':
raise NotImplementedError(
f"Experiment '{name}' combines pre-equilibration (preequilibrate:) with a "
f"parameter_scan. A pre-equilibration experiment is a time course measured "
f"after equilibrating to steady state; a dose-response after equilibration has "
f"no PEtab export route (ADR-0052/0046, #441).")
# A parameter_scan (dose-response) experiment's measurement time is its scan endpoint
# (ADR-0046): inf for the steady-state default (PEtab time=inf), or a finite ``t_end:``.
# A time course derives its grid from the data, so ``t_end:`` is inert there.
scan_time = None
if exp_type == 'parameter_scan':
t_end = fields.get('t_end')
scan_time = float(t_end) if t_end is not None else float('inf')
if fields.get('condition') is not None:
raise NotImplementedError(
f"Experiment '{name}' is a parameter_scan that also names a condition "
f"('{fields['condition']}'). A dose-response already makes each dose its "
f"own condition (ADR-0046); combining it with a named condition has no "
f"export route yet.")
measurement_params = None
mp_file = fields.get('measurement_params')
if mp_file:
measurement_params = read_measurement_params(conf_path.parent / mp_file)
experiments.append({'name': name, 'condition': fields.get('condition'),
'model': model_file, 'datas': datas, 'type': exp_type,
'scan_time': scan_time, 'preequilibrate': preequilibrate,
'measurement_params': measurement_params})
return experiments
def _resolve_experiment_model(name, ref, models, stem_to_model):
"""The model file an experiment simulates (ADR-0041), the export peer of
``config.py::_resolve_experiment_model``.
With an explicit ``model:`` ref, resolve it by filename stem (the ``model_files`` key);
an unknown ref is a typo. With no ref, default to the sole model when the job declares
exactly one; under more than one model an unnamed experiment is ambiguous -- the
exporter requires the ``model:`` field rather than guessing which model produced the
data (matching the fitter's rule).
"""
if ref is not None:
stem = Path(ref).stem
if stem not in stem_to_model:
raise PybnfError(
f"Experiment '{name}' names model '{ref}', but the job declares no model "
f"with id '{stem}' (declared model ids: {sorted(stem_to_model)}).")
return stem_to_model[stem]
if len(models) == 1:
return models[0]
raise PybnfError(
f"Experiment '{name}' does not name a model, but the job declares {len(models)} "
f"models ({models}). Add 'model: <file>' to the experiment to say which model it "
f"simulates (ADR-0041).")
def _experiment_type(name, data, explicit_type):
"""Infer ``'time_course'`` vs ``'parameter_scan'`` from a ``Data``'s independent
variable (``time`` => time_course; otherwise the indvar names a swept parameter =>
parameter_scan), unless ``type:`` states it. Mirrors
``config.py::_infer_experiment_type`` (a scan exports each dose as a steady-state
Condition/Experiment -- ADR-0046)."""
if explicit_type is not None:
t = explicit_type.lower()
if t in ('time_course', 'timecourse'):
return 'time_course'
if t in ('parameter_scan', 'param_scan', 'parameterscan'):
return 'parameter_scan'
raise PybnfError(
f"Experiment '{name}' has unrecognized type '{explicit_type}' (use "
f"'time_course').")
indvar = data.indvar if data.indvar is not None else _independent_variable(data)
return 'time_course' if indvar.lower() == 'time' else 'parameter_scan'
def _read_observable_overrides(conf):
"""The new-era ``observable: <entity>, column: <header>`` overrides as
``{entity: header}`` (ADR-0028 Chunk 4) -- the renames applied before classification."""
return {k[1]: v for k, v in conf.items()
if isinstance(k, tuple) and len(k) == 2 and k[0] == 'observable'}
def _read_measurement_models(conf):
"""The new-era ``observable: <id>, formula: <expr>`` measurement models as
``{id: formula}`` (ADR-0036). A measurement model is a PEtab observableFormula evaluated
post-simulation by the observation layer; on export its ``.exp`` column classifies as the
measurement model (not a model entity), and its formula is emitted as the
``observableFormula`` verbatim -- the inverse of the importer's measurement-model line, so
an expression observable round-trips export -> import -> re-export."""
return {k[1]: v for k, v in conf.items()
if isinstance(k, tuple) and len(k) == 2 and k[0] == 'measurement'}
def _apply_observable_overrides(datas, overrides):
"""Rename each ``<header>`` data column (and its ``<header>_SD`` companion) to the
model ``<entity>`` across all ``datas`` so the column classifies against a model
observable/function -- mirroring ``config.py::_load_observables``. Global: a ``Data``
lacking a header is skipped (it just does not measure that observable); a header
present in **no** ``Data`` is a typo -> ``PybnfError`` (listing the columns present)."""
for entity, header in overrides.items():
found = False
for data in datas:
if header in data.cols:
data.rename_column(header, entity)
found = True
sd = f'{header}_SD'
if sd in data.cols:
data.rename_column(sd, f'{entity}_SD')
found = True
if not found:
present = sorted({c for data in datas for c in data.cols})
raise PybnfError(
f"Observable override 'observable: {entity}, column: {header}' names data "
f"column '{header}', but no experimental data file contains it (columns "
f"present: {present}). Check for a typo in the column name.")
def _read_conditions(conf, models, registry):
"""Read + validate the new-era ``condition:`` entries from the raw ``ploop`` dict.
Each ``('condition', name)`` entry is ``(model_ref_or_None, [(var, op, val_str), ...])``
(a named set of parameter perturbations -- a PyBNF Mutant = a PEtab Condition). Returns
``{condition_name: [(var, op, float(val)), ...]}``. A PEtab condition is model-agnostic
(no modelId column; ADR-0041), so a perturbation target must be a parameter / compartment
of **some** model in the job (the union); an explicit ``model:`` ref, when given, must
name a declared model. The single-model job validates against its one model exactly as
before."""
union_params = set().union(*(set(v.parameters) for v in registry.values()))
union_comparts = set().union(*(set(v.compartment_names) for v in registry.values()))
stem_to_model = {Path(mf).stem: mf for mf in models}
conditions = {}
for key, value in conf.items():
if not (isinstance(key, tuple) and len(key) == 2 and key[0] == 'condition'):
continue
name = key[1]
model_ref, perts = value
if model_ref is not None and Path(model_ref).stem not in stem_to_model:
raise PybnfError(
f"Condition '{name}' is declared for model '{model_ref}', but the job "
f"declares no model with id '{Path(model_ref).stem}' (declared model ids: "
f"{sorted(stem_to_model)}).")
muts = []
for var, op, val in perts:
if var not in union_params and var not in union_comparts:
raise PybnfError(
f"Condition '{name}' perturbs '{var}', which is not a parameter or "
f"compartment of any model in the job (a PEtab condition target must "
f"be a model entity).")
muts.append((var, op, float(val)))
conditions[name] = muts
return conditions
# ---------------------------------------------------------------------------
# Scope resolution (the documented boundaries)
# ---------------------------------------------------------------------------
def _resolve_models(conf):
"""Return the job's model files in declaration order (ADR-0041).
A BNGL (``.bngl``) or SBML (``.xml``) model is exported in its own native language
(ADR-0040, dispatched by :func:`_model_language`); any other extension raises there. A
job may declare one or many models; the new-era ``model:`` declarations accumulate into
``conf['model']`` in declaration order (a legacy ``model = X : Y`` job has none -- it is
refused downstream by :func:`_require_new_era_data` -- so fall back to the model set).
The model id is the file **stem** (the ``model_files`` key); two files sharing a stem
would collide on that key and the output filename, so a stem collision raises."""
ordered = list(dict.fromkeys(conf.get('model', [])))
if not ordered:
ordered = sorted(conf.get('models', set()))
if not ordered:
raise PybnfError(
"The job declares no model; add a 'model: <file>' declaration (ADR-0028).")
stems = {}
for mf in ordered:
_model_language(mf) # validate the extension is a supported language
stem = Path(mf).stem
if stem in stems:
raise PybnfError(
f"Models '{stems[stem]}' and '{mf}' share the file stem '{stem}', which "
f"would collide on the PEtab modelId and the exported filename. Rename one "
f"so each model has a distinct stem (ADR-0041).")
stems[stem] = mf
return ordered
def _require_modern_edition(conf):
"""Refuse a legacy-edition job: PEtab v2 interop is a new-era (``edition >= 2``)
feature (Bill's call, ADR-0031). A legacy conf names its objective with the retired
``objfunc`` key and binds data through the filename->suffix linkage; the exporter
reads only the modern surface, so it requires the conf to have opted into the new era
rather than reverse-mapping legacy syntax. Gates the *exporter* only -- the fitter
still runs legacy confs unchanged."""
ed = edition.resolve_edition(conf.get('edition'))
if not edition.is_modern(ed):
raise NotImplementedError(
"The PEtab v2 exporter requires a new-era config (edition >= 2); this job is "
f"legacy (edition {ed}). PEtab v2 interop is a new-era feature: add "
f"'edition = {edition.CURRENT_EDITION}' and name the objective on the modern "
"surface ('objective = <name>' or 'noise_model = <family>, ...') instead of "
"the legacy 'objfunc' key (ADR-0031, #423).")
def _resolve_noise(conf):
"""The job's whole-fit noise model as ``(noiseDistribution, sigma_verb, sigma_arg)``.
Modern-only (``export_job`` has already required ``edition >= 2``): the objective is
named on the ADR-0031 surface -- a whole-fit ``noise_model = <family>, ...`` line or
the named ``objective`` token -- with **no legacy** ``objfunc`` and **no implicit
default**, mirroring ``config.py``'s modern ``_load_obj_func`` branch. The resolved
objective is reduced to one ``(family, {param: (verb, arg)}, location)`` tuple and
reversed to PEtab.
The result is the **whole-fit base** applied to every column that has no per-observable
``noise_model <obs> = ...`` override; the overrides are resolved separately by
:func:`_resolve_per_observable_noise` (the additive seam, ADR-0021/0045) and a column
with one uses its own sigma source instead.
Raises ``NotImplementedError`` at every PEtab boundary, never a silent default:
* a column-joint ``profile_objective`` (``kl`` / ``wasserstein``) -- it scores the
whole column's shape, not a per-observation likelihood, so it has no PEtab
observable-noise representation;
* no objective, or more than one global objective key (no implicit default);
* a ``mean``-centered noise model -- PEtab takes the prediction as the median for
every family;
* an objective with no per-point noise model (``score`` / unknown token);
* a family PEtab v2 cannot express (``neg_bin`` -- removed; ``lognormal`` -- log10 vs
PEtab natural log, a deferred sigma scale-conversion).
"""
if conf.get('profile_objective') is not None:
raise NotImplementedError(
f"profile_objective = {conf['profile_objective']!r} is a column-joint "
f"objective (kl / wasserstein): it scores the whole column's shape, not a "
f"per-observation likelihood, so it has no PEtab observable-noise "
f"representation (ADR-0031, #423).")
whole_fit = conf.get(('noise_model', None))
has_objective = conf.get('objective') is not None
if whole_fit is not None and has_objective:
raise PybnfError(
"Specify exactly one global objective: this job has both a whole-fit "
"'noise_model = ...' line and an 'objective = ...' key.")
if whole_fit is not None:
family_token, fields, location = whole_fit # modern whole-fit line
elif has_objective:
token = conf['objective']
if token not in _OBJECTIVE_DESUGAR:
raise NotImplementedError(
f"objective = {token!r} has no per-point PEtab noise model: 'score' (no "
f"likelihood) and any unknown token are not PEtab observable noise. "
f"Per-point objectives: {sorted(_OBJECTIVE_DESUGAR)} (ADR-0031, #423).")
family_token, fields, location = _OBJECTIVE_DESUGAR[token](conf)
else:
raise NotImplementedError(
"No objective is named. A new-era (edition >= 2) job must name its objective "
"explicitly -- there is no implicit default. Set 'objective = <name>' or "
"'noise_model = <family>, ...' (ADR-0031, #423).")
return _reduce_noise_spec(family_token, fields, location, 'the whole-fit noise model')
def _resolve_per_observable_noise(conf):
"""The per-observable ``noise_model <obs> = ...`` overrides as ``{column:
(noiseDistribution, sigma_verb, sigma_arg)}`` (ADR-0021/0045) -- the additive companion to
:func:`_resolve_noise`'s whole-fit base.
Each ``('noise_model', <col>)`` key (``<col>`` not ``None``) is the parsed
``(family_token, {param: (verb, arg)}, location)`` spec for one observable **column** (the
model entity / measurement-model column the objective scores), reduced through the same
:func:`_reduce_noise_spec` boundaries as the whole-fit case (a ``mean`` location or a family
PEtab v2 cannot express raises). Empty when the job declares no override -- then every column
takes the whole-fit base and the export is byte-identical to the pre-per-observable output.
The inverse of the importer's :func:`~pybnf.petab.import_._per_observable_directives`, the
config side that consumes these (``objective._build_noise_overrides``)."""
overrides = {}
for key, value in conf.items():
if isinstance(key, tuple) and len(key) == 2 and key[0] == 'noise_model' \
and key[1] is not None:
column = key[1]
family_token, fields, location = value
overrides[column] = _reduce_noise_spec(
family_token, fields, location, f"the 'noise_model {column}' override")
return overrides
def _reject_cumulative(conf):
"""Fail loud if the job declares a ``cumulative`` prediction transform (ADR-0051, #418).
The cumulative->incident differencing is a PyBNF *prediction* transform with no PEtab v2
representation -- PEtab observables/measurements have no row-coupled cumulative-counts
operator. Exporting would silently drop it and emit a problem that scores the raw
cumulative columns, a different objective. Refuse instead (the project's fail-loud-over-
silently-wrong stance), naming the offending observables."""
cumulative = sorted(k[1] for k in conf
if isinstance(k, tuple) and k[0] == 'cumulative')
if cumulative:
raise NotImplementedError(
f"Observable(s) {cumulative} declare a cumulative->incident prediction transform "
f"('cumulative', #418), which PEtab v2 cannot express -- it has no row-coupled "
f"cumulative-counts observable operator. Exporting would silently score the raw "
f"cumulative columns instead. Remove the 'cumulative' flag (and difference the data "
f"to per-interval increments yourself) to export to PEtab.")
def _reject_normalization(conf):
"""Fail loud if the job declares any normalization (ADR-0053, #444).
Normalization (``peak`` / ``init`` / ``zero`` / ``unit``) is a PyBNF *prediction*
transform -- a whole-trajectory reduction of a predicted observable before scoring -- with
no PEtab v2 representation: PEtab observable formulas are pointwise, so they cannot express
"divide by this trajectory's peak / initial value" (the scale comes from the trajectory
itself, not the model state at one point). Exporting would silently drop it and emit a
problem that scores the raw, un-normalized columns, a different objective. Refuse instead
(the fail-loud-over-silently-wrong stance, like :func:`_reject_cumulative`), naming what is
normalized -- the per-observable ``('normalization', target)`` keys (ADR-0053) and the
whole-fit / legacy ``normalization`` value alike."""
targets = sorted(k[1] for k in conf
if isinstance(k, tuple) and k[0] == 'normalization')
whole_fit = conf.get('normalization')
if not targets and whole_fit is None:
return
if targets:
detail = f"observable(s) {targets}"
elif isinstance(whole_fit, dict):
detail = f"data file(s) {sorted(whole_fit)}"
else:
detail = f"the whole fit ('{whole_fit}')"
raise NotImplementedError(
f"This job normalizes {detail} (the 'normalization' key, ADR-0053), a PyBNF prediction "
f"transform PEtab v2 cannot express -- it has no observable operator for "
f"peak/initial-value/z-score normalization (a whole-trajectory reduction, not a "
f"pointwise observable formula). Exporting would silently score the raw, un-normalized "
f"columns instead. Remove the normalization (normalizing your data and model output "
f"equivalently yourself) to export to PEtab.")
def _reduce_noise_spec(family_token, fields, location, where):
"""Reduce one parsed noise spec ``(family_token, {param: (verb, arg)}, location)`` to
``(noiseDistribution, sigma_verb, sigma_arg)``, raising the PEtab boundaries shared by the
whole-fit base (:func:`_resolve_noise`) and the per-observable overrides
(:func:`_resolve_per_observable_noise`): a ``mean``-centered location (PEtab is median-only)
and a family PEtab v2 cannot express (``neg_bin`` removed; ``lognormal`` is log10 vs PEtab's
natural ``log-normal``). ``where`` names the spec in the error message."""
if location == 'mean':
raise NotImplementedError(
f"{where} is mean-centered (location = mean); PEtab v2 takes the prediction as "
f"the distribution median for every noise family, so mean centering has no PEtab "
f"representation (ADR-0031, #423). Use median.")
distribution = _FAMILY_TOKEN_TO_PETAB_DISTRIBUTION.get(family_token.lower())
if distribution is None:
raise NotImplementedError(
f"{where}: the '{family_token}' noise family cannot be expressed in PEtab v2: "
f"neg_bin was removed from v2, and PyBNF's lognormal is log10 while PEtab's "
f"log-normal is natural log (the sigma scale-conversion is a later chunk). "
f"ADR-0023/0031, #423.")
(_param, (verb, arg)), = fields.items()
return distribution, verb, arg
def _independent_variable(data):
"""The header of a wide :class:`~pybnf.data.Data`'s column 0 (``time`` or the swept axis)."""
return min(data.cols, key=data.cols.get)
# ---------------------------------------------------------------------------
# Observable + parameter rows
# ---------------------------------------------------------------------------
def _observable_rows(experiments, registry, noise, per_obs_noise, inline_functions=False,
measurement_models=None):
"""Classify each fitted column across all experiments as a model observable, a model
function, or a conf measurement model, and map it to a PEtab observable row.
A column is classified against the model of **each** experiment that measures it
(ADR-0041): the observables table has no per-model namespace, so a column shared across
models must classify identically in each (the same kind, hence the same observableId and
formula) -- a column that is an observable in one model and a function in another is a
real conflict and raises. A column is gathered once, in first-appearance order across the
experiments' (override-renamed) ``datas``, so the observables table covers the whole job.
``noise`` is the whole-fit base ``(noiseDistribution, sigma_verb, sigma_arg)`` from
:func:`_resolve_noise` and ``per_obs_noise`` the ``{column: (dist, verb, arg)}``
per-observable overrides (ADR-0021/0045): each column's noise (its family + sigma source)
is its override if present, else the base, resolved across every experiment's data (it can
depend on the column's data, e.g. a ``column_mean`` sigma).
``inline_functions`` (ADR-0035) emits a **function** column's body as an
``observableFormula`` expression instead of the bare name -- the opt-in path that
generates the importer's round-trip oracle; the default keeps every column bare.
``measurement_models`` (``{id: formula}``, ADR-0036) are conf-declared measurement models
(model-agnostic): a column matching one is emitted with that formula as its
``observableFormula`` and its id verbatim (the inverse of the importer's ``observable: ...
formula:`` line)."""
measurement_models = measurement_models or {}
all_datas = [d for exp in experiments for d in exp['datas']]
# Gather the fitted columns in first-appearance order, each tagged with the model
# file(s) that measure it (distinct, declaration order). A measurement-model column is
# model-agnostic (it never touches a model's entities), so its models list is inert.
columns = []
column_models = {}
for exp in experiments:
mf = exp['model']
for data in exp['datas']:
indvar = data.indvar if data.indvar is not None else _independent_variable(data)
for col in sorted(data.cols, key=data.cols.get):
if col == indvar or col.endswith('_SD'):
continue
if col not in column_models:
columns.append(col)
column_models[col] = []
if mf not in column_models[col]:
column_models[col].append(mf)
observable_rows = []
column_to_observable_id = {}
for col in columns:
classes = [(mf, _classify_column(col, registry[mf], mf, measurement_models,
inline_functions))
for mf in column_models[col]]
distinct = {kind_formula for _mf, kind_formula in classes}
if len(distinct) != 1:
detail = '; '.join(f"{mf} -> {kind}" for mf, (kind, _f) in classes)
raise PybnfError(
f"Exp column '{col}' classifies inconsistently across the models that "
f"measure it ({detail}). PEtab's observables table has no per-model "
f"namespace, so a column shared across models must mean the same observable "
f"in each (ADR-0041).")
kind, formula = classes[0][1]
# A column's noise is its per-observable override if one is declared, else the
# whole-fit base (ADR-0021/0045); the override carries its own family + sigma source.
distribution, verb, arg = per_obs_noise.get(col, noise)
noise_source = _noise_source_for_column(verb, arg, col, all_datas)
row = petab_observable_row(col, kind, distribution, noise_source,
observable_formula=formula)
observable_rows.append(row)
column_to_observable_id[col] = row.observable_id
if not observable_rows:
raise PybnfError(
"The job's experiment data has no fittable observable/function columns "
"(only an independent variable and/or _SD columns).")
return observable_rows, column_to_observable_id
def _classify_column(col, model, model_file, measurement_models, inline_functions):
"""Classify one fitted column against one model view, returning ``(kind, formula)``
(the formula is ``None`` unless it is a measurement model or an inlined function body).
A conf-declared measurement model wins first (it is model-agnostic, ADR-0036); else the
column must be a model observable or function (its bare name being the
``observableFormula``). A column matching nothing in this model raises -- naming the
model so a multi-model job points at the right namespace (ADR-0041)."""
if col in measurement_models:
return 'measurement', measurement_models[col] # the conf observableFormula, verbatim
if col in model.observable_names:
return 'observable', None
if col in model.function_names:
formula = _inlined_formula(col, 'function', model, model_file) \
if inline_functions else None
return 'function', formula
raise PybnfError(
f"Exp column '{col}' matches no observable, function, or measurement model in model "
f"'{model_file}' (its observables: {sorted(model.observable_names)}; functions: "
f"{sorted(model.function_names)}; measurement models: {sorted(measurement_models)}).")
def _inlined_formula(col, kind, model, model_file):
"""The ``observableFormula`` for a column under inlining mode (ADR-0035), or ``None``.
Only a **function** column is inlined (an observable is a model species/group, not an
algebraic expression, so it stays bare); its captured body is translated to PEtab math
by :func:`~pybnf.petab.formula.bngl_body_to_petab_math`. A function with an empty body
-- a forward declaration, or a function *of arguments* (only zero-arg global functions
are the BNGL measurement-model convention) -- cannot be inlined and raises rather than
emitting a bare-name formula that silently contradicts the requested mode. Reached only
for a BNGL model (SBML has no functions -- :class:`_SbmlModelView.function_names` is
empty), so ``model`` here is always a :class:`~pybnf.petab._bngl.BnglEntities`.
"""
if kind != 'function':
return None
body = model.function_bodies.get(col, '')
if not body:
raise NotImplementedError(
f"Function '{col}' in model '{model_file}' has no inlinable body (a forward "
f"declaration or a function with arguments); only a zero-arg global function "
f"'{col}() = <body>' can be inlined as an observableFormula (ADR-0035). Export "
f"without inline_functions to reference it by bare name.")
return bngl_body_to_petab_math(body, model)
def _noise_source_for_column(verb, arg, col, datas):
"""The PEtab noise representation for one fitted column, from the desugared sigma
source verb (ADR-0021 reversed) -- evaluated across every experiment's ``datas``:
* ``read_exp_file`` (the ``_SD`` data column) -> a per-point placeholder, fed by the
measurements' ``noiseParameters``. Every ``Data`` carrying the column must also
carry its ``<col><suffix>`` companion (else a measurement row would lack the noise
value its declared placeholder binds to).
* ``fix_at`` -> a constant noiseFormula (the fixed sigma).
* ``column_mean`` -> a constant noiseFormula = the column's mean across all data.
* ``formula`` -> the expression noiseFormula verbatim (a ``FormulaSigma``, ADR-0044/0045):
a PEtab-math expression over free-parameter ids + constants. The expression's symbols are
PEtab parameter ids (exported as estimated parameters); a noise nuisance that is not a
model parameter is still a deferred boundary (it would fail the model-id binding check,
shared with the ``fit`` sigma), so the whole-fit ``formula`` export covers an expression
over model parameters. When the ``formula`` expression still carries a per-measurement
**placeholder** (``noiseParameter*``), the sigma is row-varying
(``PerMeasurementFormulaSigma``, ADR-0045): it returns ``('per_measurement', expr)``, the
noiseFormula emitted verbatim with its placeholder and the per-row token supplied by the
measurements' ``noiseParameters`` column (the binding-table sidecar).
* ``fit`` -> ``('free_param', id)``: a free-parameter (estimated) sigma -> a bare-id
noiseFormula naming the noise parameter ``id``, declared estimated in the parameter table
and admitted as an observation-layer nuisance (not a model entity -- #439). The importer
reads it back to a ``fit`` source (ADR-0044), so a per-observable estimated sigma
round-trips.
A relative sigma (``relative``) is still a deferred boundary: it is a ``noiseFormula``
expression (the sympy layer, mirroring the importer's expression boundary).
"""
holders = [data for data in datas if col in data.cols]
if verb == 'formula':
return ('per_measurement', arg) if _PLACEHOLDER.search(arg) else ('formula', arg)
if verb == 'read_exp_file':
sd_col = col + arg
if any(sd_col not in data.cols for data in holders):
raise NotImplementedError(
f"Observable column '{col}': the objective reads its noise from the "
f"'{sd_col}' data column, but a data file carrying '{col}' has no such "
f"column. A constant or free-parameter sigma without per-point data is a "
f"separate path (ADR-0023, #423).")
return ('placeholder', None)
if verb == 'fix_at':
return ('constant', float(arg))
if verb == 'column_mean':
return ('constant', float(np.average(np.concatenate([d[col] for d in holders]))))
if verb == 'fit':
# A free-parameter (estimated) sigma -> a bare-id noiseFormula naming the noise
# parameter (declared estimated in parameters.tsv; admitted as an observation-layer
# nuisance by _referenced_nuisance_symbols, NOT a model entity -- #439). The importer
# reads a bare-id noiseFormula back to a 'fit' source (ADR-0044), so a per-observable
# estimated sigma (the per_observable_noise example / Boehm's sd_*) round-trips.
return ('free_param', arg)
raise NotImplementedError(
f"Observable column '{col}': the '{verb}' sigma source is a later export chunk "
f"-- a relative sigma is a noiseFormula expression (the sympy layer, mirroring "
f"the importer boundary). ADR-0021/0023, #423.")
def _resolve_free_to_model(free_params, registry, models, nuisances=()):
"""Validate each free parameter binds to a model parameter id (or is an admitted
observation-layer nuisance); return the identity map.
New-era binds free parameters **by id** (ADR-0034): a free parameter's name *is* the
model parameter it drives -- no ``__FREE`` marker. The id space is the **union** of every
model's parameter ids (a BNGL ``begin parameters`` id, or an SBML global parameter id --
ADR-0040/0041); a free parameter present in at least one model binds (the same id means
the same knob, so a multi-model job binds it across all models that carry it). This is the
exporter's analogue of the new-era config typo check
(:meth:`config._check_variable_correspondence_modern`, which unions the same way): a free
parameter matching no model parameter id is a typo (or a ``fit`` sigma, which the exporter
rejects separately at column classification).
``nuisances`` are free parameters that bind to no model id but are legitimate
**observation-layer** nuisances (a measurement scale, a noise coefficient, a row-varying
per-row sigma -- ADR-0044/0045), gathered by :func:`_referenced_nuisance_symbols` from the
measurement-model / noise formulae and the binding-table tokens. They pass through as
estimated parameters with no model binding (the export peer of config widening the
measurement-model namespace + the ``_per_measurement_free_params`` orphan union); only a
free parameter that is neither a model id nor a referenced nuisance is a typo.
The exporter never builds a ``Configuration``, so it validates against the views'
``parameters`` directly. Returns ``{name: name}`` -- the identity map the rest of the
exporter threads as ``free_to_model``.
"""
model_ids = set().union(*(set(v.parameters) for v in registry.values()))
free_to_model = {}
for fp in free_params:
if fp.name not in model_ids and fp.name not in nuisances:
legacy_hint = ''
if fp.name.endswith('__FREE'):
legacy_hint = (
f" The '__FREE' marker is legacy-edition only (ADR-0034); declare "
f"the bare parameter id '{fp.name[:-len('__FREE')]}' instead.")
where = (f"model '{models[0]}'" if len(models) == 1
else f"any of the job's {len(models)} models ({models})")
raise PybnfError(
f"Free parameter '{fp.name}' matches no parameter id in {where}.",
f"Under edition >= 2 a BNGL free parameter binds to a model parameter by "
f"id (the SBML/PEtab convention; ADR-0034/0041), so '{fp.name}' must be one "
f"of the models' parameter ids: {sorted(model_ids)}.{legacy_hint}")
free_to_model[fp.name] = fp.name
return free_to_model
# A bare identifier in a measurement-model / noise formula: scanned to find which free
# parameters a formula references (over-matches model entities + placeholders, harmlessly --
# only the intersection with declared free parameters is used). Dependency-free (no petab).
_FORMULA_SYMBOL = re.compile(r'[A-Za-z_]\w*')
def _referenced_nuisance_symbols(conf, conf_path, noise, per_obs_noise):
"""The names referenced as observation-layer nuisances by the job's measurement-model and
noise surfaces (ADR-0034/0044/0045) -- the candidates :func:`_resolve_free_to_model` admits
as model-unbound estimated parameters. The union of:
* the symbols of every ``observable: <id>, formula: <expr>`` measurement-model formula
(an ``observableParameters`` scale/offset substituted in -- ADR-0044 -- reads as a free
symbol, e.g. ``scaling`` in ``scaling*x``);
* the symbols of every ``formula``-verb ``noiseFormula`` (whole-fit or per-observable),
e.g. ``slope`` in ``0.05*slope + 0.1`` (a ``FormulaSigma`` noise coefficient);
* the id of every ``fit``-verb sigma (whole-fit or per-observable), e.g. ``b_y`` in
``noise_model y = laplace, scale = fit b_y`` (a ``FreeParameterSigma`` estimated scale --
#439): an estimated noise parameter is an observation-layer nuisance, not a model entity;
* the non-numeric (parameter-id) tokens of every experiment's ``measurement_params:``
binding-table sidecar, e.g. the per-row ``sd_lo`` / ``s_lo`` (a row-varying estimated
sigma / scale -- ADR-0045).
Over-matches model entities and placeholders, harmlessly: the caller intersects with the
declared free parameters, so only a genuine free-parameter nuisance is admitted (an
unreferenced free parameter that is not a model id stays a typo)."""
referenced = set()
for formula in _read_measurement_models(conf).values():
referenced |= set(_FORMULA_SYMBOL.findall(formula))
for _dist, verb, arg in [noise, *per_obs_noise.values()]:
if verb == 'formula':
referenced |= set(_FORMULA_SYMBOL.findall(arg))
elif verb == 'fit':
referenced.add(arg) # an estimated noise scale (FreeParameterSigma), #439
for key, fields in conf.items():
if not (isinstance(key, tuple) and len(key) == 2 and key[0] == 'experiment'):
continue
mp_file = fields.get('measurement_params')
if not mp_file:
continue
table = read_measurement_params(conf_path.parent / mp_file)
for by_placeholder in table.values():
for by_time in by_placeholder.values():
referenced |= {tok for tok in by_time.values() if not _is_numeric_token(tok)}
return referenced
def _is_numeric_token(token):
"""Whether a binding-table token is a numeric literal (inlined) vs a parameter id (an
estimated nuisance -- the export peer of ``config._is_numeric_token``)."""
try:
float(token)
return True
except (TypeError, ValueError):
return False
def _parameter_rows(free_params, free_to_model, surrogate_params, registry, models):
"""Map each free parameter to a row; a fit-and-mutated one renamed to ``<p>__REF``."""
union_ids = set().union(*(set(v.parameters) for v in registry.values()))
parameter_rows = []
for fp in free_params:
model_param = free_to_model[fp.name]
if model_param in surrogate_params:
ref = surrogate_name(model_param)
if ref in union_ids:
raise PybnfError(
f"The surrogate-base name '{ref}' for fit-and-mutated parameter "
f"'{model_param}' clashes with an existing parameter in the job's "
f"models. Rename that model parameter.")
parameter_id = ref
else:
parameter_id = model_param
parameter_rows.append(petab_parameter_row(fp, parameter_id=parameter_id))
return parameter_rows
def _nominal_of(registry, var):
"""A fixed parameter's numeric nominal value across the job's models (ADR-0041), or
``None``: the value from the first model view that declares ``var`` (a fixed target's
nominal is read from whichever model defines it, since a PEtab condition is
model-agnostic). A free target never reaches here (the surrogate path handles it)."""
for view in registry.values():
if var in view.parameters:
return _numeric_nominal(view, var)
return None
def _numeric_nominal(model, var):
"""A fixed parameter's numeric nominal value, or ``None`` (expression/unknown RHS).
Works for both model views: a BNGL ``parameters`` value is the raw RHS string (a number
floats, an expression raises ``ValueError`` -> ``None``); an SBML view's value is already
a float or ``None`` (a value-less parameter -> ``None`` via the ``TypeError`` guard)."""
rhs = model.parameters.get(var)
if rhs is None:
return None
try:
return float(rhs)
except (ValueError, TypeError):
return None
# ---------------------------------------------------------------------------
# Reading the job (the disposable input half of the seam)
# ---------------------------------------------------------------------------
def _read_conf_dict(conf_path):
"""Parse a ``.conf`` to the raw ``ploop`` dict (no model loading, no BNG)."""
with open(conf_path) as fh:
return ploop(fh.readlines())
def _free_parameters_from_conf(conf):
"""Build ``FreeParameter`` objects from the config's ``(keyword, name)`` entries."""
free_params = []
for key, value in conf.items():
if not (isinstance(key, tuple) and len(key) == 2
and isinstance(key[0], str) and isinstance(key[1], str)):
continue
keyword, name = key
if not _VAR_DECL.search(keyword):
continue
if keyword not in EXPORTABLE_PRIOR_KEYWORDS:
raise NotImplementedError(
f"Free parameter '{name}' is a '{keyword}'; the exporter writes the "
f"PEtab prior families {sorted(EXPORTABLE_PRIOR_KEYWORDS)}. The "
f"no-prior 'var'/'logvar' point-start keywords have no PEtab prior "
f"representation (a flat improper prior is not a PEtab probability "
f"family; ADR-0025, #423).")
# p1/p2 are the family's governing values (bounds for the Uniform families,
# loc/scale or shape/scale for the two-parameter location families); a 3rd token
# is the native ``bounded`` flag, inert for the location families. A one-parameter
# unbounded family (exponential/chisquare/rayleigh, #417) carries only p1.
p2 = float(value[1]) if len(value) >= 2 else None
free_params.append(FreeParameter(name, keyword, float(value[0]), p2))
if not free_params:
raise PybnfError(
"No exportable free parameters found in the config (expected one of "
f"{sorted(EXPORTABLE_PRIOR_KEYWORDS)}).")
return free_params
@dataclass(frozen=True)
class _SbmlModelView:
"""An SBML model presented through the attribute surface the exporter's shared
classification reads (the :class:`~pybnf.petab._bngl.BnglEntities` subset), with SBML
semantics (ADR-0040 -- the export mirror of the ADR-0036 import dispatch):
* ``observable_names`` are the SBML **species** (the trajectory's bare-name output
columns). SBML has no BNGL-style observables, so a bare-name ``observableFormula``
names a species directly -- the inverse of the importer mapping a bare-name formula
back to a species column.
* ``function_names`` / ``function_bodies`` are **empty**: SBML has no global BNGL
functions, so an SBML observable is never inlined; an expression observable is carried
in the conf measurement-model layer (``observable: <id>, formula: <expr>``) and
classified via ``measurement_models``.
* ``parameters`` maps each global parameter id to its numeric nominal (or ``None``) --
the free-parameter binding set, the condition-target set, and the nominal source.
* ``compartment_names`` are the SBML compartments (also a valid condition target).
"""
text: str
parameters: dict
observable_names: frozenset
compartment_names: frozenset
function_names: frozenset = frozenset()
function_bodies: dict = field(default_factory=dict)
def _model_language(model_file):
"""The PEtab model language for a model file, by extension: ``.bngl`` -> ``'bngl'``,
``.xml`` -> ``'sbml'``. Mirrors ``config.py``'s model-suffix dispatch (which also maps
``.xml`` to its SBML backends)."""
if model_file.endswith('.bngl'):
return 'bngl'
if model_file.endswith('.xml'):
return 'sbml'
raise NotImplementedError(
f"Model '{model_file}' has an unrecognized extension; the exporter emits BNGL "
f"('.bngl') and SBML ('.xml') models (ADR-0025/0040). An Antimony ('.ant') model "
f"would need an Antimony->SBML conversion first -- a later chunk.")
def _read_model(model_file, path, language):
"""Read the job's model into the entity view the exporter consumes, per ``language``.
BNGL -> :class:`~pybnf.petab._bngl.BnglEntities`; SBML -> :class:`_SbmlModelView`
(the same attribute surface, SBML semantics). Both expose ``text`` (verbatim source),
``parameters`` (the bindable ids + nominals), ``observable_names`` (bare-name observable
columns), ``function_names``/``function_bodies`` (BNGL only), and ``compartment_names``
-- everything the language-agnostic classification reads. The dispatch is the export
peer of the importer's :func:`~pybnf.petab.import_._model_namespace`.
"""
text = Path(path).read_text(encoding='utf-8', errors='replace')
if language == 'sbml':
ent = parse_sbml_model(text)
return _SbmlModelView(
text=text,
# Every global parameter id -> its nominal (float or None); the binding set is
# the key set, the nominal source is the value (a value-less parameter -> None).
parameters={p: ent.parameter_values.get(p) for p in ent.parameter_names},
observable_names=ent.species_names,
compartment_names=ent.compartment_names)
return parse_bngl_model(text)
# ---------------------------------------------------------------------------
# Emitting the PEtab-clean model and problem.yaml
# ---------------------------------------------------------------------------
[docs]
def clean_model_for_petab(text):
"""Return a PEtab-clean copy of a BNGL model: the ``begin actions`` block stripped.
New-era BNGL binds free parameters **by id** (ADR-0034), so the source model already
carries bare parameter ids with real nominal values -- exactly what PEtab estimates.
"PEtab-clean" therefore collapses to dropping the ``begin actions`` block (PEtab
drives simulation via the measurement times / experiments, not the model's own
actions); the reaction network and the ``begin functions`` block -- which carry the
measurement model -- are carried verbatim. A fit-and-mutated parameter keeps its model
name (``v1``) here as a plain nominal-valued parameter (always overridden by its
Condition); only the parameter *table* carries the surrogate ``v1__REF`` (ADR-0027).
A legacy ``<name>__FREE`` marker in the model **code** is **rejected**: new-era binds by
id, so a model still carrying one was not modernized, and shipping it would dangle an
undefined ``v1__FREE`` symbol in PEtab. The error names the bind-by-id contract rather
than letting the PEtab oracle reject it opaquely. The scan ignores ``#`` line comments --
a comment may legitimately mention the retired ``KD1__FREE`` form as a counter-example
(``receptor_v2.bngl`` does), which is documentation, not a dangling binding.
"""
code = re.sub(r'#[^\n]*', '', text) # strip line comments before the marker scan
if _FREE_TOKEN.search(code):
raise PybnfError(
"This BNGL model carries a legacy '__FREE' marker, but PEtab export is a "
"new-era feature where free parameters bind by id (ADR-0034). Declare the "
"model's fit parameters as bare ids with nominal values (e.g. 'v1 0.5', not "
"'v1 v1__FREE') and list them as free parameters in the .conf.")
return re.sub(r'^[ \t]*begin\s+actions\b.*?^[ \t]*end\s+actions\b[^\n]*\n?',
'', text, flags=re.S | re.I | re.M)
[docs]
def write_problem_yaml(path, models, has_conditions=False, has_experiments=False):
"""Write a PEtab v2 ``problem.yaml`` referencing the tables and the model(s).
``models`` is a list of ``(model_id, location, language)`` tuples (ADR-0041): one entry
for a single-model job (byte-identical to the pre-multi-model output), or N entries in
declaration order for a multi-model job. ``language`` is each model's PEtab language
(``bngl`` or ``sbml``, ADR-0040) -- emitted verbatim so every model declares its own
native language (a BNGL + SBML mix is just two entries)."""
parts = [
'format_version: 2.0.0\n',
'parameter_files:\n - parameters.tsv\n',
'observable_files:\n - observables.tsv\n',
'measurement_files:\n - measurements.tsv\n',
]
if has_conditions:
parts.append('condition_files:\n - conditions.tsv\n')
if has_experiments:
parts.append('experiment_files:\n - experiments.tsv\n')
parts.append('model_files:\n')
for model_id, location, language in models:
parts += [
f' {model_id}:\n',
f' location: {location}\n',
f' language: {language}\n',
]
Path(path).write_text(''.join(parts))