Source code for pybnf.petab.export

"""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``), natural-log
Gaussian (``lnnormal`` -> PEtab ``log-normal``), 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 (
    PetabMappingRow,
    build_dose_response_conditions,
    build_experiment_conditions,
    build_preequilibrated_dose_response_conditions,
    build_preequilibration_conditions,
    is_species_target,
    species_target_id,
    surrogate_name,
    write_condition_table,
    write_experiment_table,
    write_mapping_table,
)
from ._measurement_params import measurement_params_for_replicate, 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.
# The explicit ``lnnormal`` member is the exact reverse mapping (ADR-0084).
_FAMILY_TOKEN_TO_PETAB_DISTRIBUTION = {
    'gaussian': 'normal', 'normal': 'normal', 'lnnormal': 'log-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, mapping_rows) = _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') if mapping_rows: write_mapping_table(mapping_rows, out_dir / 'mapping.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), has_mapping=bool(mapping_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). A **pre-equilibrated dose-response** (``preequilibrate:`` + ``parameter_scan``, ADR-0062 -- the preincubate -> wash -> dose-scan protocol) takes a fourth shape: N two-period Experiments, one per dose, whose ``time = 0`` measurement period applies BOTH a shared wash condition and a per-dose swept-parameter condition, built by :func:`~pybnf.petab.conditions.build_preequilibrated_dose_response_conditions`. Its measurements pivot exactly like a plain dose-response's (tagged ``<stem>_<i>`` at the scan time). A species ``setConcentration`` target in any condition (a wash/bolus, ADR-0062) is aliased through the **mapping table**: a BNGL species pattern is not a valid PEtab id, so :func:`_species_id_map` synthesizes a ``species_<...>`` id (``petabEntityId``) for it, the condition targets that id, and ``mapping_rows`` (``petab_id -> pattern``) is emitted as ``mapping.tsv``. Returns ``mapping_rows`` as its sixth element (empty for a job with no species condition, so ``mapping.tsv`` stays absent). """ 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) # Four PEtab experiment shapes (ADR-0046/0052/0062): 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); a PRE-EQUILIBRATED dose-response (a preincubate -> # wash -> dose-scan protocol, ADR-0062) is N two-period Experiments, one per dose, whose # measurement period applies both a shared wash condition and a per-dose condition. They build # independently and concatenate; a `preequilibrate:` field splits an experiment off the plain # time-course/dose-response buckets, and its `type` splits pre-equilibration (time course) from # a pre-equilibrated scan. pe_experiments = [exp for exp in experiments if exp['preequilibrate'] is not None and exp['type'] == 'time_course'] pdr_experiments = [exp for exp in experiments if exp['preequilibrate'] is not None and exp['type'] == 'parameter_scan'] 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' and exp['preequilibrate'] is None] conditions = _read_conditions(conf, models, registry) referenced = {exp['condition'] for exp in tc_experiments if exp['condition'] is not None} # A pre-equilibration / pre-equilibrated-scan experiment references its pre-equilibration # condition AND (optionally) its measurement (wash) condition NOT via the time-course # ``condition:`` path, so add both to ``referenced`` -- else _read_conditions drops them as # "unused" (ADR-0052/0062). for exp in pe_experiments + pdr_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.") # A species-target condition (setConcentration -- a wash/bolus, ADR-0062) exports to a PEtab v2 # condition whose target is a species *amount*: a BNGL pattern is not a valid PEtab id, so each # referenced species pattern is aliased to a synthesized ``species_<...>`` id via the mapping # table (petabEntityId -> the pattern), and the condition targets that id. Built once, globally. species_id_of = _species_id_map( var for name in referenced for var, _op, _val in conditions.get(name, []) if is_species_target(var)) # A species setConcentration is inline-only within a pre-equilibration protocol (ADR-0062, the # fitter's rule): it perturbs a species amount mid-protocol, not a mutant parameter block. A # plain time-course experiment applying a species condition has no such inline phase, so refuse # it with a clear message (the fitter rejects it too) rather than mis-emit. tc_species_conds = sorted( exp['condition'] for exp in tc_experiments if exp['condition'] is not None and any(is_species_target(var) for var, _o, _v in conditions.get(exp['condition'], []))) if tc_species_conds: raise NotImplementedError( f"Condition(s) {tc_species_conds} set a species amount (setConcentration) but are " f"applied to a plain time-course experiment. A species setConcentration is inline-only " f"within a pre-equilibration protocol (ADR-0062); apply it through a 'preequilibrate:' " f"experiment (as an incubate or a wash), not a bare 'condition:' time course.") 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}, species_id_of=species_id_of) condition_rows += pe_condition_rows experiment_rows += pe_experiment_rows experiment_to_id.update(pe_experiment_to_id) # Pre-equilibrated dose-response experiments (ADR-0062): N two-period Experiments per scan, a # -inf pre-equilibration period + a measurement period applying the shared wash condition and a # per-dose swept-parameter condition. The surrogate split (a fit-and-perturbed parameter) is not # yet combined with this multi-condition dose-period shape, so the shape requires an EMPTY M -- # neither the pre-equilibrated scans' own conditions nor any other experiment may fit-and-perturb # a parameter. Refuse the combination with a clear boundary rather than emit an under-pinned # problem. if pdr_experiments: pdr_fit_perturbed = sorted( var for exp in pdr_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) if surrogate_params or pdr_fit_perturbed: raise NotImplementedError( "A pre-equilibrated dose-response export (ADR-0062) requires that no parameter is " "both fit and perturbed by a condition (an empty surrogate set M): the surrogate " f"split is not yet combined with the multi-condition dose period. Offending fit " f"parameters: {sorted(set(surrogate_params) | set(pdr_fit_perturbed))}. Run the " "job natively, or keep the pre-equilibration/wash conditions to fixed parameters " "and species amounts.") pdr_condition_rows, pdr_experiment_rows, pdr_ids_by_name = \ build_preequilibrated_dose_response_conditions( [(exp['name'], exp['preequilibrate'], exp['condition'], _swept_param(exp), _dose_values(exp), exp['scan_time']) for exp in pdr_experiments], conditions, lambda v: _nominal_of(registry, v), species_id_of=species_id_of, existing_condition_ids={r.condition_id for r in condition_rows}) condition_rows += pdr_condition_rows experiment_rows += pdr_experiment_rows # 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 that replicate's slice of the # experiment's measurement_params sidecar (ADR-0083); a legacy four-column sidecar is # selected for every replicate and therefore keeps its original shared-token semantics. for replicate, data in enumerate(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=measurement_params_for_replicate( exp['measurement_params'], replicate)) # 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'] swept_param = _swept_param(exp) dr_conditions, dr_experiment_rows, experiment_ids = build_dose_response_conditions( stem, swept_param, _dose_values(exp), 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) # Pre-equilibrated dose-response measurements (ADR-0062): tagged <stem>_<i> at the scan time, # exactly like a plain dose-response, so the same pivot applies. The per-experiment experiment # ids come from the builder above; the swept-parameter column is the scan axis, not a measurement. for exp in pdr_experiments: model_id = Path(exp['model']).stem if multi_model else '' scan_time = exp['scan_time'] swept_param = _swept_param(exp) experiment_ids = pdr_ids_by_name[exp['name']] 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) # The species-amount mapping table (ADR-0062): one row per referenced species pattern, in # first-appearance order (petabEntityId -> the BNGL pattern). Empty for a job with no species # setConcentration condition, so mapping.tsv / problem.yaml mapping_files stay absent then. mapping_rows = [PetabMappingRow(pid, pattern) for pattern, pid in species_id_of.items()] return observable_rows, measurement_rows, condition_rows, experiment_rows, \ surrogate_params, mapping_rows 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: {key: token}}}``, where ``key`` is ``(replicate, time)`` in the ADR-0083 format or a shared bare time in the legacy format; ``None`` when absent). 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 measured AFTER an unmeasured equilibration # phase -> a PEtab two-period Experiment. A time-course measured phase gives a single # measurement period; a parameter_scan measured phase (a preincubate->wash->dose-scan # protocol, ADR-0062/#477) gives N two-period experiments, one per dose, with a # multi-condition measurement period -- both export routes below. # 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') # A PLAIN dose-response makes each dose its own condition (ADR-0046), so a named # condition on it has no export route. A PRE-EQUILIBRATED dose-response (ADR-0062), # by contrast, names its measurement (wash) condition, applied alongside the per-dose # condition in the measurement period -- that route exists, so allow it there. if fields.get('condition') is not None and preequilibrate is 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: # A species-target perturbation (setConcentration -- a wash/bolus, #474) has a BNGL # pattern target (contains '(') and possibly a param-expression value; its PEtab # export is deferred, so pass it through UNVALIDATED (no param/compartment check, no # float) -- the deferred-export guard in _build_experiments_and_conditions raises a # clear message for a *referenced* one, and an unused species condition is skipped. if '(' in var: muts.append((var, op, val)) continue 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).") try: muts.append((var, op, float(val))) except (TypeError, ValueError): # A parameter-valued perturbation (a per-condition estimated initial condition, # ADR-0076): the value names a free parameter, passed through as a string; the # builder emits it verbatim as the PEtab targetValue (mutation_target_value). muts.append((var, op, 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). The distinct ``lnnormal`` family maps exactly to ``log-normal``. """ 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; floor/scale ADR-0066, #479). Normalization (``peak`` / ``init`` / ``zero`` / ``unit`` / ``floor`` / ``scale``) 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). ``floor`` (``x + rho*max(x)``) is a whole-series offset and ``scale`` is an analytic per-series optimum profiled at scoring time -- both equally non-pointwise (``scale`` does have a natural PEtab home in estimated ``observableParameters`` / hierarchical scaling, a future export mapping, #479). Exporting would silently drop the transform 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), the whole-fit / legacy ``normalization`` value, and the compiled ``analytic_scale`` key (which a whole-fit ``scale`` reduces to with no ``normalization`` value left).""" targets = sorted(k[1] for k in conf if isinstance(k, tuple) and k[0] == 'normalization') whole_fit = conf.get('normalization') analytic_scale = conf.get('analytic_scale') if not targets and whole_fit is None and not analytic_scale: return if targets: detail = f"observable(s) {targets}" elif whole_fit is None and analytic_scale: cols = sorted({c for cols in analytic_scale.values() for c in cols}) detail = f"observable(s) {cols} (analytic per-series scaling)" 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/ADR-0066), a PyBNF " f"prediction transform PEtab v2 cannot express -- it has no observable operator for " f"peak/initial-value/z-score/floor normalization or analytic per-series scaling (a " f"whole-trajectory reduction, not a pointwise observable formula). Exporting would " f"silently score the raw, un-normalized columns instead. Remove the normalization " f"(normalizing your data and model output 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``; ``lnnormal`` is its exact native match). ``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 (use the distinct lnnormal family for that scale). " 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) def _swept_param(exp): """The swept-parameter (dose axis) header of a parameter_scan experiment's data (column 0).""" data0 = exp['datas'][0] return data0.indvar if data0.indvar is not None else _independent_variable(data0) def _dose_values(exp): """The dose values of a parameter_scan experiment -- its swept-axis column, as floats.""" data0 = exp['datas'][0] return [float(v) for v in data0[_swept_param(exp)]] def _species_id_map(patterns): """A ``{pattern: petab_id}`` map for the species ``setConcentration`` targets the job's conditions reference (ADR-0062), in first-appearance order. Each BNGL pattern is aliased to a synthesized PEtab id (:func:`~pybnf.petab.conditions.species_target_id`) for the mapping table; two distinct patterns that sanitize to the same id collide -> ``PybnfError``.""" mapping = {} by_id = {} for pattern in patterns: if pattern in mapping: continue pid = species_target_id(pattern) if pid in by_id: raise PybnfError( f"Species setConcentration targets {by_id[pid]!r} and {pattern!r} both sanitize " f"to the PEtab mapping id {pid!r}. Rename one species pattern so their PEtab " f"target ids stay distinct (ADR-0062).") by_id[pid] = pattern mapping[pattern] = pid return mapping # --------------------------------------------------------------------------- # 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 == 'prediction_formula': # A prediction-dependent sigma (PredictionFormulaSigma, ADR-0075): sigma scales with the # simulated output, e.g. the combined error model ``sd_abs + sd_rel*y``. It maps back to # a plain noiseFormula emitted VERBATIM -- the direct mirror of ``formula`` -- because the # importer reclassifies it: the same substituted expression re-imports as # ``prediction_formula`` iff it references a model entity (``y``), else ``formula`` # (_resolve_noise, import_.py). So the exporter carries no separate prediction arm: the # coefficients (``sd_abs`` / ``sd_rel``) are admitted as nuisances, and the model-entity # symbol stays a model reference in the noiseFormula (fit-preserving round trip, #502). return ('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 in ('formula', 'prediction_formula'): # A FormulaSigma expression (ADR-0044) or a prediction-dependent sigma (ADR-0075, # ``sd_abs + sd_rel*y``): its free-parameter coefficients are nuisances. Over-matches # the model-entity symbol (``y``) harmlessly -- the caller intersects with declared # free parameters, which a model entity is not, so only the coefficients are admitted. 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)} # A free parameter a condition perturbation references by value (a per-condition estimated # initial condition, ADR-0076): it binds no model entity of its own, so -- like a noise / # measurement-model nuisance -- it must be admitted as a model-unbound estimated parameter. for key, value in conf.items(): if not (isinstance(key, tuple) and len(key) == 2 and key[0] == 'condition'): continue _model_ref, perts = value for var, _op, val in perts: if '(' not in var and isinstance(val, str) and not _is_numeric_token(val): referenced.add(val) 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 # --------------------------------------------------------------------------- # The ``begin actions`` ... ``end actions`` block, capturing its inner body. PEtab drives # simulation from the measurement times / experiments, so the *simulation* actions are # dropped -- but ``generate_network`` is a network-definition / compilation directive, not a # simulation action, and it carries the finiteness cap (``max_stoich`` / ``max_agg`` / # ``max_iter``) that keeps a rule-based network finite (#485). See _strip_simulation_actions. _ACTIONS_BLOCK = re.compile( r'^[ \t]*begin\s+actions\b[^\n]*\n(?P<body>.*?)^[ \t]*end\s+actions\b[^\n]*\n?', flags=re.S | re.I | re.M) # A ``generate_network`` action line (the directive we keep). Anchored past leading indent so # a commented-out ``# generate_network(...)`` line does not match (it is documentation, not a # live directive), consistent with pset.py's ``BNGLModel`` scanner (#473). _GENERATE_NETWORK_LINE = re.compile(r'^[ \t]*generate_network\b', flags=re.I) def _strip_simulation_actions(match): """Rewrite one matched ``begin actions`` block, keeping only its network-definition directives (``generate_network``, which carries the model's finiteness cap -- #485) and dropping the simulation actions (``simulate*`` / ``parameter_scan`` / ``bifurcate`` / ...). Returns the empty string when nothing survives, so a simulation-only block disappears exactly as the whole-block strip did before. A kept line is emitted verbatim (its cap args intact) inside a minimal, column-0 ``begin actions`` / ``end actions`` wrapper. """ kept = [line for line in match.group('body').splitlines() if _GENERATE_NETWORK_LINE.match(line)] if not kept: return '' return 'begin actions\n' + '\n'.join(kept) + '\nend actions\n'
[docs] def clean_model_for_petab(text): """Return a PEtab-clean copy of a BNGL model: the ``begin actions`` block reduced to its network-definition directives (``generate_network``), its simulation actions dropped. 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 drops the *simulation* actions (PEtab drives simulation via the measurement times / experiments, not the model's own ``simulate`` calls) while keeping ``generate_network`` -- a network-definition / compilation directive, not a simulation action. That directive carries the model's finiteness cap (``max_stoich`` / ``max_agg`` / ``max_iter``); dropping it would silently turn a model that is finite only under the cap into one that network-generates unbounded, with no error or warning (#485). The exported model then carries its own cap, so ``import_.py`` (which copies the model byte-verbatim) round-trips it for free and any BNG2.pl / PyBNF consumer stays finite; a simulation-only block (no ``generate_network`` line) still disappears entirely. 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 _ACTIONS_BLOCK.sub(_strip_simulation_actions, text)
[docs] def write_problem_yaml(path, models, has_conditions=False, has_experiments=False, has_mapping=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). ``has_mapping`` adds the ``mapping_files`` entry for the species-amount mapping table (ADR-0062).""" 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') if has_mapping: parts.append('mapping_files:\n - mapping.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))