PyBNF PEtab v2 interoperability (pybnf.petab)

The pybnf.petab package is PyBNF’s adapter to and from the PEtab v2 standard: a PEtab problem (problem.yaml plus its parameter, observable, measurement, and condition tables) and a native PyBNF .conf produce the same internal FreeParameter / Prior / NoiseModel / MeasurementModel objects, so a problem imports, fits, and exports back without loss (ADR-0004). The tables map one-to-one onto the submodules below; import and export are the two whole-problem entry points.

This is the module reference; for the task-oriented guide – importing a problem, exporting a job, linting, and what round-trips – see PEtab interoperability. The package requires the optional pybnf[petab] extra.

Parameters

PEtab v2 parameters table -> FreeParameter (issue #407, Step 1).

This is the first, self-contained step of the PEtab v2 problem importer – the two-adapter proof the M2 refactor anticipated (ADR-0004): a native .conf and a PEtab problem should land on the same internal FreeParameter / Prior objects. If they do, the abstractions are right; where PEtab forces a special case, we learn where they are wrong.

Two deliberately separated layers (the “neutral seam”):

  • The TSV reader (read_parameter_table) – the disposable half: a dependency-free csv parse of parameters.tsv into PetabParameterRow records. When the later importer chunks pull in the petab library for the SBML model and the observableFormula sympy layer, this is swapped for petab’s parameter_df reader with no change below.

  • The mapping (free_parameter_from_row) – the asset: a PetabParameterRow -> FreeParameter, driven by the prior-family registry (ADR-0010). It synthesizes the equivalent legacy *_var keyword and builds the FreeParameter through its ordinary constructor, so the importer lands on a bit-identical object to the native config path – the strongest form of the two-adapter proof – rather than a parallel mapping table.

PEtab v2 specifics this encodes (current spec, not the v1 shape):

  • There is no parameterScale column (removed in v2); everything is in linear space, and the parameter’s PyBNF Scale is derived from the prior family instead (a log-* prior -> Log10).

  • Priors are priorDistribution / priorParameters (renamed from v1’s objectivePrior*); a single prior, used for the objective only.

  • log-normal / log-laplace parameters are the location/scale of the natural log of the parameter; PyBNF’s log families parameterize in log10, so we convert (mu, sigma) -> (mu/ln10, sigma/ln10). The resulting distribution over theta is identical – PyBNF’s scale lives in the sampling parameterization, so there is no change-of-variables term to add (ADR-0003).

  • The full v2 prior catalog maps (#417): besides uniform / normal / laplace, the five families that were a catalog gap – cauchy (loc, scale), gamma (shape, scale), exponential (scale), chisquare (dof), rayleigh (scale) – now each have a registered PyBNF prior family (ADR-0010). The parameterizations are verified against petab’s own v1.distributions classes (gamma is shape+scale, not shape+rate; exponential’s parameter is the scale, not the rate). PEtab defines no log- form for these five.

  • Bounds truncate the prior. A Uniform prior truncates exactly (we intersect the box). For an unbounded-support family, two-sided finite bounds map to a truncated prior on a finite reflecting box (ADR-0020, TruncatedPrior); a one-sided truncation – one finite wall and the other side covering the family’s natural domain (an infinite bound, or a log form’s theta <= 0) – maps to a half-bounded box, a single reflecting wall (ADR-0047, #432). Both directions are the ub->inf limit of the two-sided fold; the open side is the family’s natural support endpoint.

Gaps are surfaced as NotImplementedError with clear messages so the boundary is documented in code, not silent: estimate = false fixed parameters (those become model constants, handled by a later importer chunk, not here).

class pybnf.petab.parameters.PetabParameterRow(parameter_id: str, estimate: bool, lower_bound: float | None = None, upper_bound: float | None = None, nominal_value: float | None = None, prior_distribution: str | None = None, prior_parameters: tuple[float, ...] = ())[source]

One row of a PEtab v2 parameters table, in PyBNF’s neutral vocabulary.

The dependency-free seam between the (disposable) TSV reader and the (asset) registry-driven mapping: the mapping never depends on how the row was read, so a future petab-library adoption feeds it by constructing these from Problem.parameter_df records.

lower_bound / upper_bound / nominal_value are None when the column is absent or blank; prior_distribution is None for an estimated parameter with no explicit prior (PEtab v2 defaults that to a uniform over the bounds). prior_parameters is the parsed semicolon-delimited tuple.

pybnf.petab.parameters.free_parameter_from_row(row)[source]

Map one estimated PEtab v2 parameters row to a FreeParameter.

Finite bounds truncating an unbounded-support family map to a bounded FreeParameter – two finite walls to a two-sided box (ADR-0020), one finite wall plus an open side to a half-bounded box (ADR-0047, #432). The full v2 prior catalog maps (uniform / normal / laplace / cauchy / gamma / exponential / chisquare / rayleigh, #417). Raises NotImplementedError at the remaining PEtab/PyBNF boundary (estimate=false fixed parameters) and PybnfError for malformed rows (unknown prior type, wrong parameter count, reversed bounds).

pybnf.petab.parameters.free_parameters_from_file(path)[source]

Read parameters.tsv at path and map it to FreeParameter objects.

pybnf.petab.parameters.free_parameters_from_table(rows)[source]

Map the estimated rows of a parameters table to FreeParameter objects.

estimate=false rows are skipped (they are fixed model constants, not free parameters), so this returns one FreeParameter per estimated row.

pybnf.petab.parameters.petab_parameter_row(free_parameter, parameter_id=None)[source]

Map a PyBNF FreeParameter back to a PetabParameterRow.

The exact reverse of free_parameter_from_row(): a native .conf free parameter and a PEtab row land on the same object, so this read backwards is the two-adapter proof in the export direction. parameter_id defaults to the free parameter’s name – new-era binds a free parameter to its model parameter by id (ADR-0034), so the name is the parameterId; a caller that has resolved the model parameter name authoritatively (the exporter, which renames a fit-and-mutated parameter to its <p>__REF surrogate) passes it explicitly.

Exports the whole proper-prior catalog – the reverse of ADR-0019’s import map:

  • uniform_var – the bounds are [p1, p2] and no priorDistribution is written (PEtab v2 defaults an estimated, prior-less parameter to uniform-over-bounds, so the row round-trips).

  • loguniform_var – the same linear bounds, but priorDistribution is stated (log-uniform; PEtab’s default uniform is linear) with priorParameters = (p1, p2).

  • normal_var / laplace_var / cauchy_var / gamma_var and the log forms of normal/laplace – a two-parameter prior: priorParameters are (p1, p2) ((loc, scale) / (shape, scale)), in natural log for the log families (PyBNF parameterizes in log10, so they are scaled back by ln 10).

  • exponential_var / chisquare_var / rayleigh_var – a one-parameter prior: priorParameters is a single (p1,) (the scale, or chisquare’s dof).

A truncated parameter writes its reflecting box as the truncating bounds: two finite walls (two-sided, ADR-0020) or one finite wall with the open side as an explicit infinity (half-bounded, ADR-0047). An unbounded one writes blank bounds. The no-prior var / logvar point-start keywords and the log forms of the five catalog families (no PEtab log- spelling) raise NotImplementedError – surfaced in code, not mis-exported.

pybnf.petab.parameters.read_parameter_table(path)[source]

Read a PEtab v2 parameters.tsv into PetabParameterRow records.

Dependency-free (stdlib csv). Unknown extra columns (e.g. parameterName) are tolerated and ignored.

pybnf.petab.parameters.write_parameter_table(rows, path)[source]

Write parameter rows to path as a PEtab v2 parameters.tsv.

Always writes parameterId/estimate/lowerBound/upperBound. The prior columns (priorDistribution/priorParameters) are appended only when some row carries an explicit prior, so a plain uniform_var job keeps the four-column chunk-1 shape (PEtab v2 defaults a prior-less estimated parameter to uniform-over-bounds). An unbounded location-scale family writes blank bounds; nominalValue is optional in PEtab v2 and omitted while unused.

Observables and noise

PEtab v2 observables table (noise half) -> (NoiseModel, SigmaSource) (issue #407; depends on #410, ADR-0021; scales per ADR-0022).

The observables chunk of the PEtab v2 importer. It mirrors pybnf.petab.parameters exactly – the same two-adapter proof (ADR-0019): a native noise_model .conf line and a PEtab observables row land on the same internal (NoiseModel, SigmaSource) pair where the native surface can express it. PyBNF’s noise engine (ADR-0021) is richer than PEtab v2’s noise vocabulary, so PEtab maps onto a subset of it.

Two deliberately separated layers (the “neutral seam”, as in parameters.py):

  • The TSV reader (read_observable_table) – the disposable half: a dependency-free csv parse of observables.tsv into PetabObservableRow records. When the later observableFormula chunk pulls in the petab library, this is swapped for petab’s observable_df reader with no change below.

  • The mapping (noise_model_from_row) – the asset: a PetabObservableRow -> (NoiseModel, SigmaSource), built through the ordinary Gaussian / Laplace constructors.

Scope: the noise half only. The PEtab v2 spec (verified against the current spec, not the v1 shape – see the note below) gives the noise model through two columns:

  • noiseDistribution – a single column carrying both the distribution family and the scale its noise is additive on. PEtab v2 allows exactly normal / log-normal / laplace / log-laplace (default normal). PEtab’s log is the natural log, so the log forms map to LN (ADR-0022), not LOG10.

  • noiseFormula – the sigma-source (the noise distribution’s scale parameter): a number -> ConstantSigma, a bare noise-parameter id -> FreeParameterSigma.

The mapping (all with the prediction taken as the distribution’s median – PEtab v2 specifies this for every noise distribution; the location axis, ADR-0011):

noiseDistribution

PyBNF (NoiseModel)

normal

Gaussian(LINEAR, MEDIAN)

log-normal

Gaussian(LN, MEDIAN) (natural log)

laplace

Laplace(LINEAR, MEDIAN)

log-laplace

Laplace(LN, MEDIAN) (natural log)

Spec note (PEtab v2 vs v1). PEtab v2 removed the separate observableTransformation column (lin / log / log10) and folded it into noiseDistribution as the log- prefixes above; and v2’s log is natural (base e), with no log10 form. (An earlier draft of this adapter encoded the v1 shape; this is the corrected v2 mapping.)

The two-adapter equivalence is exact for the linear families and ``laplace``, structural for the natural-log families. laplace matches the native laplace token exactly (Laplace(LINEAR, MEDIAN)), and normal matches the native normal token exactly too (Gaussian(LINEAR, MEDIAN)): native normal now also defaults to MEDIAN (ADR-0031). The natural-log families have no native ``.conf`` token – the native lognormal token is log10, and the native surface has no natural-log family – so log-normal / log-laplace are validated structurally and against the kernels’ analytic NLL rather than a native config line. That PyBNF can represent natural-log noise the native surface does not expose is fine: the engine is the superset, the native grammar a convenience.

The noise mapping is complete for PEtab v2: every one of the four noiseDistribution values maps with no gaps (the Laplace kernel landed in #410, the LN scale in ADR-0022). The only deferred capability is a non-trivial noiseFormula expression – the sympy layer where the petab library earns its keep – surfaced as an explicit NotImplementedError. Malformed rows (an unknown noiseDistribution spelling – e.g. a future PEtab value – a missing noiseFormula, a blank observableId) raise PybnfError.

The observableFormula (the model-output expression) and the observablePlaceholders / noisePlaceholders columns are the deferred sibling half – a separate, later chunk that adopts the petab sympy layer. observable_formula is recorded on PetabObservableRow so that chunk reuses this reader, but the noise asset neither reads nor validates it (real observableFormula values are non-trivial expressions, so coupling that boundary here would make the noise asset raise on nearly every real PEtab problem).

class pybnf.petab.observables.PetabObservableRow(observable_id: str, observable_formula: str | None = None, noise_formula: str | None = None, noise_distribution: str | None = None, observable_placeholders: str | None = None, noise_placeholders: str | None = None)[source]

One row of a PEtab v2 observables table, in PyBNF’s neutral vocabulary.

The dependency-free seam between the (disposable) TSV reader and the (asset) mapping: the mapping never depends on how the row was read, so a future petab-library adoption feeds it by constructing these from Problem.observable_df records.

noise_distribution is None when the column is absent or blank; the mapping applies the PEtab v2 default (normal). observable_formula – the model-output expression – is recorded for the deferred observableFormula chunk but is not consumed by the noise mapping (this is the noise half only).

pybnf.petab.observables.noise_model_from_row(row)[source]

Map one PEtab v2 observables row’s noise half to an (NoiseModel, SigmaSource) pair (ADR-0021, ADR-0023).

noiseDistribution selects the family and the additive scale together (normal -> Gaussian(LINEAR), log-normal -> Gaussian(LN), laplace -> Laplace(LINEAR), log-laplace -> Laplace(LN)); the prediction is the median (PEtab default). noiseFormula becomes the sigma-source: a number -> ConstantSigma, a bare noise-parameter id -> FreeParameterSigma.

Raises NotImplementedError for a non-trivial noiseFormula expression (the deferred sympy layer) and PybnfError for a malformed row (unknown noiseDistribution spelling, missing noiseFormula).

pybnf.petab.observables.noise_models_from_file(path)[source]

Read observables.tsv at path and map it to the noise override map.

pybnf.petab.observables.noise_models_from_table(rows)[source]

Map an observables table to a {observable_id: (NoiseModel, SigmaSource)} override map – exactly the LikelihoodObjective(overrides=...) map the native noise_model lines produce (ADR-0021), the two-adapter proof at table level.

pybnf.petab.observables.petab_observable_row(model_name, kind, noise_distribution, noise_source, observable_formula=None)[source]

Map one fitted BNGL column to a PetabObservableRow (ADR-0025).

model_name is the BNGL observable/function name (an .exp column header); kind is 'observable' or 'function'. The observableId is the prefixed name (obs_<name> / func_<name>). kind == 'measurement' is a conf-declared measurement model (ADR-0036): the observableId is model_name verbatim (no prefix – it is already a PEtab id) and observable_formula (the conf formula) is required.

observableFormula is the bare model name by default: a BNGL function (including a function of a function) is carried verbatim in the model file and evaluated there, so the table only references it by name and the export is lossless and petab-free. The exporter’s opt-in inlining mode (ADR-0035) overrides it for a function column by passing observable_formula (the function body translated to PEtab math), so the emitted problem carries its own measurement model and round-trips against the importer’s synthesis. An observable column is never inlined (an observable is a model species/group, not an algebraic expression), so callers pass observable_formula only for kind == 'function'.

noise_distribution is the PEtab family the job’s objective maps to (ADR-0023 reversed: gaussian -> normal, laplace -> laplace). noise_source is the PEtab representation of the objective’s sigma source (ADR-0021 reversed), one of:

  • ('placeholder', None) – a per-point _SD data column: a declared noise placeholder (noiseFormula = noisePlaceholders = noiseParameter1_<id>) whose per-point value the measurements’ noiseParameters column supplies (chi_sq).

  • ('constant', value) – a fixed sigma written inline as a numeric noiseFormula with no placeholder: a fix_at constant (sos -> 1, sod -> 1) or an observable’s column mean (ave_norm_sos).

  • ('formula', expr) – an expression sigma (FormulaSigma, ADR-0044/0045): the PEtab-math noiseFormula over free-parameter ids + constants, emitted verbatim with no placeholder. The inverse of the importer’s expression-noiseFormula classification, so a whole-fit FormulaSigma round-trips.

  • ('free_param', id) – a free-parameter (estimated) sigma (FreeParameterSigma, #439): the bare noise-parameter id as the noiseFormula, no placeholder, declared estimated in the parameter table and an observation-layer nuisance (not a model entity). The importer reads a bare-id noiseFormula back to a fit source (ADR-0044), so a per-observable estimated sigma round-trips.

  • ('per_measurement', expr) – a row-varying placeholder sigma (PerMeasurementFormulaSigma, ADR-0045): the noiseFormula expression carries a per-measurement placeholder (noiseParameter1_<id>) whose value differs row to row, supplied by the measurements’ noiseParameters column. The noiseFormula is emitted verbatim and the placeholder(s) it references declared in noisePlaceholders – the inverse of the importer routing a row-varying noiseParameters id to the binding table.

pybnf.petab.observables.read_observable_table(path)[source]

Read a PEtab v2 observables.tsv into PetabObservableRow records.

Dependency-free (stdlib csv). noisePlaceholders is recorded (it marks a named noiseFormula placeholder whose value the measurements’ noiseParameters column supplies – a per-observable estimated sigma, ADR-0037). Other unknown extra columns (e.g. observableName, observablePlaceholders) are tolerated and ignored.

pybnf.petab.observables.write_observable_table(rows, path)[source]

Write observable rows to path as a PEtab v2 observables.tsv.

The optional observablePlaceholders column (a row-varying observable scale’s declared placeholder, ADR-0045) is appended only when some row carries one, so a job with none stays byte-identical to the pre-row-varying output (the byte-equal export round-trip oracle).

Measurements

PEtab v2 measurements table, both directions (issue #407; ADR-0025/the importer read path).

PyBNF stores a dataset as a wide pybnf.data.Data (column 0 the independent variable, the other columns named after model observables/functions, optional _SD noise columns); PEtab stores it long (one row per measured point). This module pivots between the two, on the neutral PetabMeasurementRow seam shared with the other PEtab tables:

  • exportmeasurement_rows_from_data (wide Data -> neutral rows) + write_measurement_table (rows -> TSV, the disposable half).

  • importread_measurement_table (TSV -> rows, the disposable half) + data_from_measurement_rows (long rows -> the wide Data replicates per (experiment, model), the exact inverse of measurement_rows_from_data: it groups rows by (experimentId, modelId) (the model->data link, ADR-0041), deals repeated (observable, time) rows into replicate grids (ADR-0039), pivots long->wide, and rebuilds each observable’s _SD companion column from the per-point noiseParameters).

Dose-response (ADR-0046). A parameter_scan .exp – whose independent axis (column 0) is a swept parameter, not time – maps the other way: its swept axis lives in the PEtab conditions/experiments tables (each dose a Condition setting the swept parameter + an Experiment, measured at a constant time – inf for steady state). measurement_rows_from_data still refuses such an .exp (export routes it through dose_response_measurement_rows instead), and reconstruct_dose_responses is the import inverse: it detects those experiment groups and rebuilds each as a single swept-axis Data.

class pybnf.petab.measurements.PetabMeasurementRow(observable_id: str, time: float, measurement: float, experiment_id: str = '', model_id: str = '', noise_parameters: float | None = None, noise_parameter_id: str | None = None, observable_parameters: tuple = ())[source]

One row of a PEtab v2 measurements table, in PyBNF’s neutral vocabulary.

experiment_id is '' for a base time-course with no condition changes (PEtab’s “model as is”); model_id is the optional model->data link (ADR-0041) – the modelId of the model that produced the row ('' for a single-model job, where the column is omitted). noise_parameters is the per-point numeric noise value (the _SD cell) feeding the observable’s single declared noise placeholder, or None when the column carries no number. noise_parameter_id is the alternative: a parameter id in the noiseParameters column (Boehm’s sd_pSTAT5A_rel) – a PEtab placeholder override that, when constant across an observable’s rows, is a per-observable estimated sigma (ADR-0021/0037); exactly one of the two is set (or neither, for a blank cell).

observable_parameters is the semicolon-split tokens of the observableParameters column (the n-th token binds observableParameter${n}_${observableId} in the observableFormula / noiseFormula). Each token is a number or a parameter id; when the tuple is constant across an observable’s rows it reduces to a per-observable scale/offset (substituted into the formula, ADR-0044), else it is the deferred row-varying frontier. () for a blank/absent cell.

pybnf.petab.measurements.data_from_measurement_rows(rows, observable_id_to_column, sd_suffix='_SD', indvar='time')[source]

Pivot long measurement rows back to the wide Data replicates per (experiment, model) – the inverse of measurement_rows_from_data().

observable_id_to_column maps a PEtab observableId (e.g. obs_x) to the model column header it measures (e.g. x); its iteration order fixes the wide column order, so a re-export classifies columns in the same order the original export did (the byte-equal round trip). Rows are grouped by (experimentId, modelId); within a group the sorted-unique time values become column 0 and each measured observable becomes a value column, NaN-filled where a (time, observable) cell is absent (the forward pivot skips NaN, so this restores the ragged grid). When any row in the group carries a noiseParameters value, each value column gets a <col><sd_suffix> companion rebuilt from those per-point values (the _SD source a chi_sq re-export reads back); a group with no noiseParameters (a fixed / column-mean sigma objective) gets no _SD columns.

Why group on ``(experimentId, modelId)`` (ADR-0041): two wildtype experiments on different models both carry experimentId = '' (PEtab’s “model as is”); the modelId is what distinguishes them. Grouping on the pair keeps them separate without needing synthesized experimentIds; a single-model job carries modelId = '' on every row, so its grouping is identical to keying on experimentId alone.

Replicates. PEtab models replicates as repeated (experiment, model, observable, time) rows with no replicate index. They are dealt across grids in first-seen order: the k-th occurrence of a cell goes to the k-th Data (the first grid is the full one; later grids hold only the cells that repeat). This is the exact inverse of the forward export’s for data in exp['datas'] stacking (ADR-0039), so a homogeneous-grid replicate set re-exports to byte-identical long rows; PyBNF’s summing objective scores the dealt grids exactly as it scored the source .exp files (the partition PEtab never recorded does not affect the fit). Replicate dealing runs within one (experimentId, modelId) group, so the two groupings compose.

Returns {(experiment_id, model_id): [Data, ...]} – a list of replicate grids per (experiment, model) (length 1 for the common no-replicate case), experiment_id being '' for the “model as is” base time course and model_id '' for a single-model job. Raises PybnfError if a row names an observableId absent from the map.

pybnf.petab.measurements.dose_response_measurement_rows(data, column_to_observable_id, experiment_ids, scan_time, sd_suffix='_SD', model_id='')[source]

Pivot a dose-response (swept-axis) wide Data to long rows.

The dual of measurement_rows_from_data() for a Parameter Scan .exp whose independent axis (column 0) is the swept parameter, not time. Each data row is one measured dose mapped to its own experiment (experiment_ids[i], aligned with the data row order), and the measurement time is the scan’s fixed scan_time – a scalar, inf for a steady-state scan (PEtab time=inf) or a finite t_end: for a fixed-endpoint scan (ADR-0046), not a data column. column_to_observable_id and the <col><sd_suffix> noise companion behave as in the time-course pivot (sd_suffix=None disables per-point noise); the swept-parameter column 0 is not in the map, so it is never emitted as a measurement. model_id is the optional model->data link (ADR-0041), stamped on every row ('' for a single-model job).

pybnf.petab.measurements.measurement_param_bindings(rows, observable_id_to_column, row_varying_noise=(), row_varying_obs=())[source]

Per-experiment per-measurement binding tables for the row-varying observables (ADR-0045) – the source the importer writes to the sidecar TSV.

Returns {(experiment_id, model_id): {column: {placeholder: {time: token}}}}, collecting both row-varying frontiers into the one sidecar shape:

  • noise (row_varying_noise) – each measurement row’s noiseParameters id binds noiseParameter1_<observable_id> at that row’s time (the PerMeasurementFormulaSigma source);

  • observable (row_varying_obs) – the n-th observableParameters token binds observableParameter${n}_<observable_id> at that row’s time (the PerMeasurementModel scale/offset).

The table is keyed by the experimental-data column (observable_id_to_column[oid] – the model entity / materialized measurement-model column the objective compares, what the per-point evaluator looks up at eval), not the PEtab observableId, and grouped by (experiment_id, model_id) to match data_from_measurement_rows() so each experiment gets its own sidecar. Two replicate rows at the same (observable, time) share the token (last-wins; a per-replicate-varying token is out of scope). The two placeholder families (noise vs observable) coexist under one column with distinct placeholder keys.

pybnf.petab.measurements.measurement_rows_from_data(data, column_to_observable_id, experiment_id='', sd_suffix='_SD', model_id='', measurement_params=None)[source]

Pivot one experiment’s wide Data to long measurement rows.

column_to_observable_id maps a Data column header (a model observable/function name, e.g. x) to its PEtab observableId (e.g. obs_x); only those columns become measurements. For each such column its <col><sd_suffix> companion (if present) supplies the per-point noiseParameters value. sd_suffix is either a single suffix applied to every column, or a {column: suffix_or_None} map (the per-column form the per-observable noise export needs – ADR-0021/0045): each column reads its own _SD companion (or none). None (a column’s suffix, or the whole argument) disables per-point numeric noise (noiseParameters left blank) – used when the objective’s sigma source is not a data column (a fixed / column-mean / formula sigma carried inline in noiseFormula), so a stray _SD column does not produce a noiseParameters override with no placeholder to bind to. model_id is the optional model->data link (ADR-0041): the modelId of the model the experiment simulates, stamped on every row ('' for a single-model job, where the column is omitted on write). NaN cells are skipped (a ragged long table round trips through PyBNF’s NaN-skipping objective). Rows are grouped by observable, then ordered by the independent variable as it appears in data.

measurement_params (ADR-0045) is this experiment’s per-measurement binding table {column: {placeholder: {time: token}}} (the measurement_params: sidecar), the source of a row-varying placeholder’s per-row token. For a column whose sidecar carries noiseParameter1_<id> the row’s token becomes its noiseParameters id (a PerMeasurementFormulaSigma noise source); for observableParameter{n}_<id> the n-th token becomes the row’s observableParameters tuple (a PerMeasurementModel scale/offset). The default (None / absent) leaves both blank, so a non-row-varying export is byte-identical.

Raises NotImplementedError if the independent variable is not time (a dose-response / parameter_scan .exp – a later export chunk).

pybnf.petab.measurements.noise_parameter_ids_by_observable(rows)[source]

{observable_id: parameter_id} for observables whose noiseParameters is a single parameter id constant across all of that observable’s rows (ADR-0037).

A constant-per-observable parameter-id placeholder is exactly PyBNF’s native per-observable estimated sigma (noise_model <obs> = <family>, sigma = fit <id>, ADR-0021): the importer emits one such line per entry. Observables whose noiseParameters is numeric (per-point _SD) or blank are simply absent from the map.

A row-varying id (differing across the observable’s rows) is no longer an error: it routes to the per-measurement binding table (row_varying_noise_ids() / measurement_param_bindings(), ADR-0045) and is excluded here. An id/numeric mix is still deferred (raises in _classify_noise_ids()).

pybnf.petab.measurements.observable_parameters_by_observable(rows)[source]

{observable_id: (token, ...)} for observables whose observableParameters tuple is constant across all of that observable’s rows (ADR-0044) – the sibling of noise_parameter_ids_by_observable().

A constant-per-observable observableParameters is a per-observable scale/offset: the n-th token binds observableParameter${n}_${observableId} and is substituted into the observableFormula / noiseFormula (an id stays a free symbol that resolves from the PSet, a number inlines – ADR-0044). Observables with a blank observableParameters are absent from the map.

A row-varying observableParameters (differing across the observable’s rows) is no longer an error: it routes to the per-measurement binding table (row_varying_observable_ids() / measurement_param_bindings(), ADR-0045) and is excluded here – the observable side of the Phase-2 frontier that Phase 1 deferred.

pybnf.petab.measurements.read_measurement_table(path)[source]

Read a PEtab v2 measurements.tsv into PetabMeasurementRow records.

Dependency-free (stdlib csv), mirroring parameters.read_parameter_table. experimentId is optional (blank -> '', the base time course); modelId is the optional model->data link (blank -> '', a single-model job; ADR-0041); time and measurement are required; noiseParameters is the optional per-point _SD value (blank -> None). Unknown extra columns are tolerated and ignored.

pybnf.petab.measurements.reconstruct_dose_responses(measurement_rows, condition_rows, experiment_rows, observable_id_to_column, sd_suffix='_SD')[source]

Detect + reconstruct dose-response (parameter_scan) groups – the inverse of the exporter’s build_dose_response_conditions + dose_response_measurement_rows (ADR-0046).

A dose-response is represented in PEtab v2 as N Conditions (each setting one swept parameter to a distinct dose) + N Experiments measured at a constant time – inf for the steady-state default (the common case), or a finite t_end. This recovers each such group back to a single swept-axis Data (column 0 = the swept parameter, its values the doses; the observable columns the per-dose measurements) plus the scan endpoint, the form a new-era experiment: (its .exp driving the parameter_scan type inference) re-exports.

Detection heuristic. A PEtab experiment is a dose-response point when its condition sets exactly one target to a numeric value (a dose – a surrogate <p>__REF expression is not numeric, so a conditioned time course is excluded) and all its measurements share one time. Points group by their experimentId stem (<stem>_<i> -> stem), the swept parameter, the scan time, and the modelId. A steady-state (time=inf) point is always a dose-response – steady state has no other meaning in PyBNF. A finite-time point is treated as one only when its experimentId follows the exporter’s <stem>_<i> convention, so a single-timepoint time course (one measured time, a one-target condition, but an ordinary name) is not misread. Within a stem group the swept parameter and scan time must agree – a mixed group raises (ambiguous).

Returns (dose_responses, remaining_rows, consumed_condition_ids, consumed_experiment_ids):

  • dose_responses – a list of {name, model_id, swept_param, scan_time, data} (one per reconstructed scan; scan_time is inf for steady state);

  • remaining_rows – the measurement rows NOT in any dose-response (the time-course pivot handles them);

  • consumed_condition_ids / consumed_experiment_ids – the condition/experiment ids absorbed into the scans, so the caller drops them from the time-course reconstruction.

pybnf.petab.measurements.row_varying_noise_ids(rows)[source]

The set of observable_ids whose noiseParameters parameter id differs across the observable’s rows – the row-varying per-measurement noise frontier bound per data point from the binding table (measurement_param_bindings(), ADR-0045).

A constant-per-observable id (noise_parameter_ids_by_observable()) and a numeric / blank cell are absent. The complement of the constant map over the id-valued observables.

pybnf.petab.measurements.row_varying_observable_ids(rows)[source]

The set of observable_ids whose observableParameters tuple differs across the observable’s rows – the row-varying observable scale/offset frontier bound per data point from the binding table (measurement_param_bindings(), ADR-0045).

The observable-side sibling of row_varying_noise_ids(). A constant-per-observable tuple (observable_parameters_by_observable()) and a blank cell are absent.

pybnf.petab.measurements.write_measurement_table(rows, path)[source]

Write measurement rows to path as a PEtab v2 measurements.tsv.

Two columns are emitted only when some row needs them, so a table that needs neither stays byte-identical to the pre-multi-model / pre-row-varying output (the byte-equal round-trip oracle):

  • modelId (the model->data link, ADR-0041) – emitted when some row carries a non-empty model_id (a multi-model job); a single-model job stamps ''.

  • observableParameters (a row-varying observable scale/offset, ADR-0045) – emitted when some row carries an observable_parameters tuple, its tokens semicolon-joined.

The noiseParameters cell is the per-row parameter-id token of a row-varying noise placeholder (the sidecar source, ADR-0045) when set, else the numeric _SD value (chi_sq), else blank.

Conditions and experiments

PEtab v2 conditions/experiments tables, both directions (#422/#423/#407; ADR-0027/0028/the importer read path).

The two tables that make a PyBNF job’s simulation vary per dataset. A new-era condition: (a named MutationSet of var op val perturbations) maps onto a PEtab Condition (targetId/targetValue overrides) referenced by an Experiment (a period sequence). This module is the neutral seam, mirroring parameters.py / observables.py: the asset is the neutral rows + the pure op->``targetValue`` mapping + the builders; the disposable half is the TSV readers/writers.

The importer reverse (conditions_from_rows + read_condition_table / read_experiment_table) inverts build_experiment_conditions(), undoing the surrogate-base machinery: a <p>__REF base pin (a row whose targetValue is the surrogate name) is dropped, a relative op in the surrogate (v1__REF * 2) recovers the fit-parameter perturbation (v1 * 2), a bare-number target recovers an absolute set (a fixed parameter’s relative op was lossily precomputed on export, so it round-trips as var = <num> – the same PEtab value either way), and the synthesized cond_wildtype maps back to a wildtype experiment (no condition:), not a condition: line.

The surrogate-base parameter (the crux, ADR-0027). PEtab forbids one id from appearing in both the parameter table and a condition target. A PyBNF condition routinely perturbs a fit parameter, and a relative op on one (v1*2) can’t be precomputed (the base is the estimated value). So a fit-and-perturbed parameter v1 is split: the estimated quantity is renamed to a surrogate v1__REF (which lives only in the parameter table), while the model name v1 becomes a pure condition target. The __REF marker is a double-underscore suffix, mirroring PyBNF’s own __FREE is-fit marker, so it can never clash with a user-defined model name.

The exporter reads the new-era surface (ADR-0028): build_experiment_conditions() transcribes named condition:/experiment: lines, and build_dose_response_conditions() maps a dose-response (parameter_scan) experiment to one Condition per dose + an Experiment measured at the scan time (inf => steady state, ADR-0046) – both live export paths.

pybnf.petab.conditions.CONDITION_ID_PREFIX = 'cond_'

The conditionId prefix the exporter wraps every condition name in (cond_<name>), and the synthesized base condition for wildtype experiments.

class pybnf.petab.conditions.PetabConditionRow(condition_id: str, target_id: str, target_value: str)[source]

One row of a PEtab v2 conditions table: a single entity override.

target_value is a ready-to-write string – a bare number (an absolute set or a precomputed relative op on a fixed target) or a sympy-parseable expression in a surrogate parameter (a relative op on a fit target, e.g. v1__REF * 2).

class pybnf.petab.conditions.PetabExperimentRow(experiment_id: str, time: float, condition_id: str)[source]

One period of a PEtab v2 experiments table.

In chunk 2 every experiment is a single period applied at time=0 (a Mutant or a dose sets initial conditions; measurements then occur at their own times). The surrogate split makes this the period CheckInitialChangeSymbols inspects.

pybnf.petab.conditions.REF_MARKER = '__REF'

The surrogate-base marker (a double-underscore suffix, like PyBNF’s __FREE).

pybnf.petab.conditions.build_dose_response_conditions(stem, swept_param, dose_values, scan_time)[source]

Build the conditions/experiments for a dose-response Parameter Scan.

Each measured dose (a .exp column-0 cell) becomes its own Condition setting the swept parameter and a single-period Experiment at time=0 (the dose is an initial condition; the measurement occurs later, at scan_time). Returns (condition_rows, experiment_rows, experiment_ids) where experiment_ids[i] is the experimentId for dose row i (for tagging that row’s measurements).

pybnf.petab.conditions.build_experiment_conditions(experiments, conditions, fit_params, nominal_of, extra_surrogate=frozenset({}))[source]

Build conditions/experiments for a new-era job (ADR-0028 Chunk 5b).

Generalizes build_mutant_conditions() from “base + mutants each carrying their own data” to “named conditions + named experiments that reference them” – the new era decouples a Condition from the Experiment that applies it (a condition: is named once; N experiment: records may reference it, so a shared condition emits its rows once).

experiments is a list of (experiment_name, condition_name_or_None) in declaration order. conditions maps a condition name to its perturbations [(var, op, val), ...] (val a float). fit_params is the set of model-parameter names that are fit; nominal_of(var) returns a fixed parameter’s numeric nominal (or None for an expression/unknown).

Returns (condition_rows, experiment_rows, surrogate_params, experiment_to_id):

  • surrogate_params (the set M) – fit parameters perturbed by some referenced condition (an unused condition contributes nothing), unioned with extra_surrogate (fit params perturbed by a pre-equilibration condition in the same job – build_preequilibration_conditions()’s contribution, threaded in by the orchestrator so M stays problem-global across both experiment shapes). They are renamed to <p>__REF in the parameter table and pinned in every experiment’s Condition: M is problem-global, because the model name <p> becomes a pure condition target, so every simulation must re-supply it (the surrogate-base machinery, ADR-0027). A param that only a pre-equilibration condition perturbs is thus still re-pinned (base value) in every time-course/wildtype Condition here.

  • condition_rows – each referenced condition’s targets emitted once (conditionId cond_<name>): a fit target’s relative op is symbolic in its surrogate (v1__REF * 2), a fixed target’s relative op is precomputed; plus a base pin p = p__REF for each p in M the condition does not itself set. Plus a shared synthesized base condition cond_wildtype (pinning all of M) when M is non-empty and some experiment is wildtype.

  • experiment_to_id{experiment_name: experimentId}: the name for a conditioned experiment, or for a wildtype one when M is non-empty; '' (“model as is”) for a wildtype experiment when M is empty (the chunk-1 base behaviour preserved, so a condition-free job needs no experiments table).

pybnf.petab.conditions.build_preequilibration_conditions(experiments, conditions, nominal_of, surrogate=frozenset({}), existing_condition_ids=frozenset({}))[source]

Build the conditions/experiments for new-era pre-equilibration experiments (ADR-0052, #441 Phase 2 + #443 Phase 2.x) – the multi-period structural sibling of build_experiment_conditions().

A pre-equilibration experiment maps to a PEtab v2 two-period Experiment (ADR-0052’s bidirectional rule): a leading time = -inf period under the pre-equilibration condition (equilibrate to steady state, unmeasured) followed by a time = 0 period under the measurement condition (the data grid is measured there). experiments is a list of (name, preequil_cond, measurement_cond_or_None); conditions maps a condition name to its perturbations [(var, op, val), ...]; nominal_of(var) returns a fixed parameter’s numeric nominal (the fit-vs-fixed split a target needs is carried by surrogate, below – a target in M is a surrogate-handled fit param, the rest are fixed).

surrogate is the problem-global M – the full fit-and-perturbed set, already split to <p>__REF, including any param a pre-equilibration condition itself perturbs (the orchestrator threads the pre-equilibration contribution into M via build_experiment_conditions()’s extra_surrogate, so both builders share one M). The shared _condition_rows_for() re-pins all of M on every period’s condition, so a fit-parameter perturbation in a pre-equilibration period composes correctly (#443): the perturbing period emits the surrogate op (k = k__REF * 2 / an absolute k = 0.5) and every other period re-pins the base value (k = k__REF).

existing_condition_ids is the set of conditionId values build_experiment_conditions() already emitted (its time-course conditions plus the synthesized cond_wildtype base when present); a condition shared between a time course and a pre-equilibration experiment is emitted once, and the wash-out base condition is reused rather than re-emitted.

Returns (condition_rows, experiment_rows, experiment_to_id). experiment_to_id[name] = name (a pre-equilibration experiment always has an experiments table – two periods – so it is never the empty-id “model as is” case).

A wash-out (no measurement condition) measures at the model default: an empty conditionId on the time = 0 period when M is empty, else the synthesized base condition WILDTYPE_CONDITION_ID (re-pinning every removed fit param at its base value – the same base build_experiment_conditions() pins for a wildtype time course, emitted once and shared). The importer maps that base back to “no condition:” (a wash-out), so the round trip is preserved.

pybnf.petab.conditions.condition_name_from_id(condition_id)[source]

The new-era condition: name for a PEtab conditionId, or None.

None for the synthesized WILDTYPE_CONDITION_ID (which maps back to a wildtype experiment with no condition:) and for an absent/blank id. Otherwise the cond_ prefix is stripped (an externally-authored id without the prefix passes through unchanged, defensively).

pybnf.petab.conditions.conditions_from_rows(condition_rows, surrogate_params)[source]

Invert build_experiment_conditions()’ condition rows to new-era perturbations {condition_name: [(var, op, val), ...]}.

surrogate_params is the set of model-parameter names that are fit-and-perturbed (the <p>__REF surrogates, recovered from the parameter table by the orchestrator). Rows of the synthesized wildtype base are skipped; base pins (a row whose targetValue is exactly a surrogate name) are dropped as machinery; the rest map to (var, op, val) perturbations (see _perturbation_from_row()). Declaration order within a condition is preserved (the wide<->long byte-equal round trip).

pybnf.petab.conditions.mutation_target_value(op, val, *, nominal=None, surrogate=None)[source]

Map one PyBNF mutation <op> <val> to a PEtab targetValue string.

An absolute set (=) is the bare number, regardless of target kind. A relative op (* / + -) needs the base value:

  • Fit target – pass surrogate (the <p>__REF symbol): the result is a symbolic expression in it (v1__REF * 2), whose free symbol is the parameter-table surrogate (CheckInitialChangeSymbols-clean).

  • Fixed target – pass nominal (the model’s numeric value): the result is the relative op precomputed to a bare number. A relative op with nominal is None (an expression-RHS / unknown nominal) raises NotImplementedError – evaluating a BNGL expression tree is simulation-grade work, out of scope (ADR-0026 precedent).

pybnf.petab.conditions.read_condition_table(path)[source]

Read a PEtab v2 conditions.tsv into PetabConditionRow records (stdlib csv; targetValue kept as the raw string).

pybnf.petab.conditions.read_experiment_table(path)[source]

Read a PEtab v2 experiments.tsv into PetabExperimentRow records (stdlib csv; time coerced to float).

pybnf.petab.conditions.surrogate_name(model_param)[source]

The surrogate-base parameter id for a fit-and-mutated model parameter.

pybnf.petab.conditions.write_condition_table(rows, path)[source]

Write condition rows to path as a PEtab v2 conditions.tsv.

pybnf.petab.conditions.write_experiment_table(rows, path)[source]

Write experiment rows to path as a PEtab v2 experiments.tsv.

Measurement formulas

PEtab-math translation for the observableFormula layer (issue #407, ADR-0035/0036).

Two production directions, both over petab’s sympy-backed math grammar (petab.v2.math) – PEtab math is a specified grammar, so we translate via its parsed tree rather than a hand-rolled string tokenizer (ADR-0033 warned precisely against the string approach – operator precedence, the ^ power operator, and the ln/log10/sqrt spellings are where it would silently go wrong):

  • bngl_body_to_petab_math() – a BNGL function body -> a PEtab math expression (the exporter’s opt-in inlining mode, which generates the round-trip oracle). The hard semantic part (precedence, ^, sqrt) is written once and guarded by a numeric self-check (_assert_round_trips()).

  • compile_petab_formula() – a PEtab math expression -> a vectorized numpy callable (the measurement-model observation layer, ADR-0036): the formula is evaluated post-simulation over the output trajectory + the PSet, never by editing a model file. This is the direction SBML import and the retrofitted BNGL-expression path turn on; it superseded ADR-0035’s synthesis into the model file (the PEtab-math -> BNGL function body printer the importer once used to inject a begin functions entry).

petab/sympy is the optional runtime extra pybnf[petab] – imported lazily, only on these expression paths. The bare-name observableFormula common case never reaches this module and stays dependency-free + simulator-free (ADR-0019); an expression import with petab absent raises a clear “install pybnf[petab]” error, not an ImportError.

MVP scope (ADR-0035/0036). Arithmetic over existing model entities (BNGL parameters / observables / functions; SBML species / parameters) – the surface a measurement model needs (Boehm’s quotient of sums is the worked fixture). A free symbol that is not a model entity is an error; a PEtab observableParameter*/noiseParameter* per-measurement placeholder is the deferred frontier and raises pointing here.

pybnf.petab.formula.bngl_body_to_petab_math(body, entities)[source]

Translate a BNGL function body to a PEtab math expression string.

The exporter’s inlining mode (ADR-0035): a fitted function column emits its body as observableFormula instead of the bare name. Every free symbol is validated against the model namespace (parameters u observables u functions), then the parsed tree is serialized by our own precedence-safe PEtab printer (_petab_printer_cls()) so the emitted formula is math the PEtab oracle accepts and re-parses to itself. A final round-trip self-check (_assert_round_trips()) refuses to emit any string that does not parse back to the same expression – a wrong observableFormula is worse than a refused one (ADR-0035). A BNGL func() reference to another global function is rewritten to a bare symbol first (PEtab math has no user zero-arg functions); the function set is closed and known, so this is a bounded rename, not a tokenizer.

Raises PybnfError on a missing petab extra, an unknown free symbol, an unparseable body, or a body that does not survive the serialize/re-parse round trip; NotImplementedError on a per-measurement placeholder symbol.

pybnf.petab.formula.compile_objective_expression(formula, free_params, *, data_columns=(), backend='numpy')[source]

Compile a bring-your-own objective expression to (callable, ordered_names) (ADR-0050; the JAX backend is ADR-0059 item 2; data columns are the data-binding follow-up).

The fourth direction of the translator: the user writes a closed-form negative log-likelihood (or cost) as PEtab math over the declared free parametersobjective = expression + expression = 0.5*((1 - x1)^2 + 100*(x2 - x1^2)^2) – with no model file (the inline analytical objective the #425 epic asks for). The sympy backend is identical to compile_petab_formula(); the only differences are the validation namespace and the wording: every free symbol must be a declared free parameter (so the expression binds by name to the PSet, ADR-0050 §4 – the bind-by-name footgun fix the named-target slice deferred), not a model entity, and an unknown symbol is reported in those terms (naming it). PEtab math uses ^ for exponentiation (not **); a ** surfaces as a clear parse error here.

free_params is the set/iterable of declared free-parameter names. data_columns is the set/iterable of experimental-data column names also allowed as symbols when the objective binds measurements (data = curve.exp; the data-binding follow-up): a data-bound expression is a per-observation NLL contribution over the parameters and the row’s data columns ((y - vmax*x/(km+x))^2 references the free parameters vmax/km and the data columns x/y), which the model evaluates per row and sums (ExpressionModel.execute). With no bound data data_columns is empty and the contract is unchanged.

Returns the callable and the sorted free-symbol names it expects positionally (callable(*[binding[name] for name in ordered_names]), where a parameter name binds to a scalar and a data-column name to that column’s array); a declared parameter the expression does not reference is simply absent from the list (a likelihood flat in that direction).

backend selects the lambdify target: 'numpy' (the default) feeds the gradient-free score-column path (de / am / dream); 'jax' produces a JAX-traceable callable so job_type = hmc can jax.grad the user’s expression (ADR-0059 item 2). The parse, symbol validation, and ordered_names are backend-independent (one sympy expression, one sorted free-symbol set), so the numpy and JAX callables bind their arguments identically – the bind-by-name contract holds across both, exactly as the prior families’ numpy/JAX logpdfs agree by construction.

Raises PybnfError on a missing petab extra, an unparseable expression, or a free symbol that is neither a declared free parameter nor a bound data column.

pybnf.petab.formula.compile_petab_formula(formula, allowed_symbols, *, detail=None)[source]

Compile a PEtab math observableFormula to (numpy_callable, ordered_names).

The third direction of the translator (ADR-0036, the measurement-model observation layer): instead of serializing to a model-language body, the parsed PEtab expression is turned into a vectorized numpy callable so the layer can evaluate the measurement model post-simulation over the output trajectory’s columns + the PSet – no model-file edit, identical for BNGL and SBML and every backend. allowed_symbols is the model’s expression namespace (BNGL ParamList, or SBML species u parameters; ADR-0026/0036); detail overrides the unknown-symbol error’s namespace listing.

Returns the callable and the sorted free-symbol name list it expects positionally (callable(*[binding[name] for name in ordered_names])); the caller binds each name to a trajectory column or a scalar. Reuses sympify_petab + the shared free-symbol validation, then lambdifys with the numpy backend.

Raises PybnfError on a missing petab extra, an unparseable formula, or an unknown free symbol; NotImplementedError on a per-measurement placeholder symbol.

pybnf.petab.formula.compile_petab_formula_derivatives(formula, allowed_symbols, *, detail=None)[source]

Compile the partial derivatives of a PEtab observableFormula -> ({name: d_callable}, ordered_names) (#453/#385, the gradient sibling of compile_petab_formula()).

A per-measurement measurement model (ADR-0045) is a general PEtab formula over sim-output columns + per-row scale/offset placeholders, applied in _prediction; its contribution to ∂pred/∂θ is the symbolic gradient of that formula chained through each referenced column’s sensitivity and any estimated placeholder/parameter it names. sympy.diff gives the partials exactly (the formula is already a parsed sympy tree), so the chain rule is the exact derivative of the same callable compile_petab_formula() builds – not a finite difference.

Returns derivs – a {free_symbol_name: numpy_callable} map, one entry per free symbol of the formula – and the sorted ordered_names the value callable expects. Every derivative callable takes the same positional arguments in the same ``ordered_names`` order as compile_petab_formula()’s value callable, so the caller binds one argument list once and evaluates f and every ∂f/∂symbol from it (a derivative that does not depend on a given symbol simply ignores that slot). Same parse + symbol validation as the value compiler (so an unknown symbol is the same error and a placeholder is admitted identically).

Raises PybnfError on a missing petab extra, an unparseable formula, or an unknown free symbol; NotImplementedError on a per-measurement placeholder symbol the value compiler would also reject (it does not, for a registered PerMeasurementModel).

pybnf.petab.formula.formula_free_symbols(formula)[source]

The sorted free-symbol names of a PEtab math formula – the parameters a FormulaSigma (or a measurement model) reads from the PSet (ADR-0044).

A parse only (no namespace validation, no lambdify): used to derive the free parameters an expression noiseFormula requires the fit to declare, before the callable is compiled. Raises PybnfError on a missing petab extra or an unparseable formula.

pybnf.petab.formula.inline_assignment_rules(formula, rules, *, observable_id=None)[source]

Inline the SBML assignment-rule variables a measurement observableFormula references down to the species/parameters their rules are defined over (#465, ADR-0036).

An SBML assignmentRule variable="X" makes X an algebraic function of other model entities, recomputed every step – never a simulation-output column and value-less, so the measurement layer cannot resolve X as a symbol (#464). But its RHS is resolvable, and SBML authors define exactly these convenience observables (the D2D Epo_cells := Epo_EpoRi + dEpoi), so observable: Epo_cells, formula: Epo_cells should just work. This substitutes each referenced rule variable with its rule’s RHS – recursively, so a rule defined over another rule resolves through (SBML allows forward references; resolution is by dependency, not document order) – leaving a formula over only species/parameters that the existing compile_petab_formula() + the chain-rule gradient (MeasurementModel.prediction_sensitivity()) handle unchanged: the rule’s species sensitivities flow in automatically.

rules maps every assignment-rule target id to its RHS as a PEtab-math infix string (the stdlib _sbml scanner’s serialization), or to None when the rule’s MathML used a construct the scanner could not translate. A formula that references no rule variable is returned verbatim (the common case never reaches sympy, so it stays byte-stable). The substitution + serialization go through sympy (never a string tokenizer – ADR-0033), guarded by the same numeric round-trip self-check as the exporter.

Raises PybnfError on a missing petab extra, an unparseable formula/RHS, a reference to a rule whose MathML was not translatable (None RHS), a cyclic rule dependency, or a substitution that fails its round-trip self-check.

pybnf.petab.formula.inline_constants(formula, constants)[source]

Substitute fixed-parameter constants into a PEtab math observableFormula.

A PEtab observableFormula may reference a fixed parameter that lives only in the PEtab parameters table, not in the model file (Boehm’s specC17 = 0.107 – a species-activity constant absent from the SBML; ADR-0037). Such a symbol resolves against neither the model namespace nor the simulation trajectory, so the measurement layer cannot evaluate it. Because it is fixed, substituting its numeric value is exact: the measurement model then references only model entities + estimated parameters and the model file stays unedited (ADR-0036).

constants is the {name: value} map of fixed PEtab parameters not present as model entities (a fixed parameter that is a model entity stays a symbol – it resolves as a model constant). Substitution + serialization go through sympy (never a string tokenizer – ADR-0033), guarded by the same numeric round-trip self-check as the exporter. If no constant is a free symbol of formula the original string is returned verbatim (the bare-name / model-only common case never reaches sympy, so the demo round trip is byte-stable).

Raises PybnfError on a missing petab extra, an unparseable formula, or a substitution that fails its round-trip self-check.

pybnf.petab.formula.substitute_placeholders(formula, substitutions)[source]

Substitute per-measurement placeholder symbols into a PEtab math formula (ADR-0044).

The sibling of inline_constants(), for the constant-per-observable placeholder reduction: a PEtab observableParameter${n}_${id} / noiseParameter${n}_${id} placeholder whose measurements-table value is constant across an observable’s rows is not per-measurement at all – it is a single scalar for that observable. Substituting it reduces the placeholder to the existing per-observable machinery (the ADR-0036 measurement layer for an observableFormula; a sigma source for a noiseFormula).

substitutions maps a placeholder name to its token: a number is substituted as a constant (sp.Float); a parameter id is substituted as a free symbol (sp.Symbol), which resolves from the PSet at eval time (a nuisance free parameter, ADR-0034). Substitution + serialization go through sympy (never a string tokenizer – ADR-0033), guarded by the same numeric round-trip self-check as the exporter. A formula with no substituted placeholder (the bare-name / no-placeholder common case) is returned verbatim (it never reaches sympy). A placeholder not in substitutions is left in place (the caller validates it downstream).

Raises PybnfError on a missing petab extra, an unparseable formula, or a substitution that fails its round-trip self-check.

BNGL model loader

A PEtab Model adapter for BNGL, registered into petab at runtime (ADR-0026).

petab 0.8.x ships only sbml/pysb model loaders, so petablint cannot load a language: bngl problem – it dies at model-load before any table check. BnglModel is the missing loader: a petab.v1.models.model.Model backed by PyBNF’s own stdlib BNGL reader (pybnf.petab._bngl), so the PEtab v2 exporter’s emitted problems validate at model level (the ~5 model-cross checks ADR-0025 had to exclude), with no BNG2.pl needed for the enumeration.

This is Step A of issue #420 – a local reference implementation. register_bngl() teaches a running petab about BNGL by rebinding petab.v2.core.model_factory (petab’s factory is a hardcoded if/elif with no plugin hook). Step B upstreams the same class to PEtab-dev/libpetab-python as a peer of PySBModel; once that lands, register_bngl() collapses to a no-op.

Validation needs only parsing, so every ABC method is backed by the parsed entity sets – except BnglModel.is_valid(), which shells out to BNG2.pl --check (the real validator) when a BNG2.pl is locatable, and degrades gracefully to True when it is not. This module imports petab and is therefore not imported by pybnf.petab.__init__; it is reached only from the test tier / an explicit register_bngl() call, keeping core dependency-free (ADR-0025).

class pybnf.petab.bngl_model.BnglModel(entities, model_id, path=None)[source]

PEtab Model wrapper for a BNGL model (validation-grade; no simulation).

static from_file(filepath_or_buffer, model_id=None, base_path=None)[source]

Load the model from the given path/URL

Parameters:
  • filepath_or_buffer – Absolute or relative path/URL to the model file. If relative, it is interpreted relative to base_path, if given.

  • base_path – Base path for relative paths in the model file.

  • model_id – Model ID

Returns:

A Model instance holding the given model

get_free_parameter_ids_with_values()[source]

Get free model parameters along with their values

Returns:

Iterator over tuples of (parameter_id, parameter_value)

get_parameter_ids()[source]

Get all parameter IDs from this model

Returns:

Iterator over model parameter IDs

get_parameter_value(id_)[source]

Get a parameter value

Parameters:

id – ID of the parameter whose value is to be returned

Raises:

ValueError – If no parameter with the given ID exists

Returns:

The value of the given parameter as specified in the model

get_valid_ids_for_condition_table()[source]

Get IDs of all model entities that are allowed to occur as columns in the PEtab conditions table.

Returns:

Iterator over model entity IDs

get_valid_parameters_for_parameter_table()[source]

Get IDs of all parameters that are allowed to occur in the PEtab parameters table

Returns:

Iterator over parameter IDs

has_entity_with_id(entity_id)[source]

Any declared identifier: the full BNGL model-entity namespace.

is_state_variable(id_)[source]

A species. At validation grade, only the concrete seed species are known (the full set is a network-generation product, out of scope; ADR-0026).

is_valid()[source]

BNG2.pl --check (real validation, no network gen) when locatable, else True – never a false failure where no BNG backend is available.

symbol_allowed_in_observable_formula(id_)[source]

The BNG ParamList: parameters, observables, global functions only.

to_file(filename=None)[source]

Save the model to the given file

Parameters:

filename – Destination filename

pybnf.petab.bngl_model.MODEL_TYPE_BNGL = 'bngl'

BNGL model type, as used in a PEtab v2 yaml file as language.

pybnf.petab.bngl_model.register_bngl()[source]

Teach a running petab to load language: bngl models via BnglModel.

Idempotent and additive: rebinds petab.v2.core.model_factory to route bngl to BnglModel and delegate every other language to the captured original (so sbml/pysb are untouched), and registers 'bngl' as a known model type. A guarded permanent rebind (no teardown) – the temporary local stand-in for Step B’s upstream loader (ADR-0026).

Importing a problem

PEtab v2 problem importer: a BNGL-native PEtab v2 problem -> a new-era PyBNF job (issue #407; the importer read path, ADR-0025 reversed / ADR-0032).

The inverse of pybnf.petab.export.export_job(). Given a problem.yaml + its TSV tables + a BNGL model, import_job() writes a runnable new-era (edition 2) .conf plus the .exp data files and a verbatim copy of the model (new-era binds free parameters by id, ADR-0034, so the model needs no re-instrumentation) – the form the exporter reads. It closes the “two-adapter proof” at the read level for BNGL-native problems: the reverse asset mappers (parameters/observables/measurements/conditions) run backwards onto the shared neutral rows, and this module is the disposable orchestrator that ties them together (problem.yaml reader + .conf/.exp writers).

PEtab is a problem spec; PyBNF is a job spec (the run-recipe gap). A PEtab problem fixes the objective landscape (model, data, conditions, parameters + priors, the noise model) but deliberately says nothing about how to search it – no optimizer/sampler, no algorithm settings, no simulation method, no seed – because PEtab is a cross-tool exchange format and the method belongs to the tool. So import = PEtab problem + a supplied run-recipe. The problem half is recovered exactly (and round-trips byte-for-byte through a re-export); the recipe half – job_type + that fit’s algorithm settings (SEARCH), the per-experiment method: (SIMULATION), and output_dir / verbosity / required keys (PLUMBING) – is supplied, not recovered, and is excluded from the round-trip identity. The recipe is not a new language: each group is an existing PyBNF/ADR-0028 surface, and its defaults come from the registry/schema, never a parallel table here.

Concretely, the recipe is supplied through import_job()’s parameters:

  • job_type – the SEARCH method token ('de' by default). 'all' enumerates the fit-type registry (every optimizer + sampler; the check checker excluded) and writes one runnable imported_<jt>.conf per method, the existing benchmark-harness pattern (ADR-0012): the importer covers the whole toolbox and stays correct as it grows. Sampler-vs-optimizer is a genuine scientific choice (a sampler treats the priors as Bayesian priors), so the importer must not pick for the user.

  • method / method_overrides – the SIMULATION method, emitted on every experiment: line ('ode' by default; method_overrides={exp: method} sets per-experiment values). It is per-experiment, never a single global knob: a job can have multiple models/experiments each simulated differently, and the method is not derivable from data (deterministic and stochastic models yield identically-shaped traces). Round-trip is lossy here – export drops method (no PEtab home), import defaults it to ode – so a stochastic model does not survive a PEtab hop.

  • settings – overrides for the required algorithm/run settings (population_size / max_iterations / verbosity); the per-method schema defaults the rest.

Dependency-free + simulator-free on the bare-name path. Like the other read-path chunks, the import path uses only stdlib + pybnf.data.Data + the asset mappers, so the bare-name common case runs in the bngsim-less CI tier. problem.yaml is hand-parsed (the exporter emits a fixed, simple shape). The petab library is the test-only oracle for the bare-name path, and the optional pybnf[petab] extra for an expression observableFormula.

Scope (read path: BNGL and SBML, one or many models). Both model languages import (ADR-0036): the model file is carried verbatim for each, and an expression observableFormula becomes a first-class measurement model – a PEtab math expression evaluated as a post-simulation transform over the output trajectory (the observation layer), emitted as an observable: <id>, formula: <expr> conf line – never by editing the model file (the begin functions synthesis of ADR-0035 is superseded). The bare-name common case still needs no translator and stays dependency-free. SBML observables are 100% expressions, so SBML import pulls in the pybnf[petab] extra. A multi-model problem imports too (ADR-0041): each model_files entry is carried verbatim and declared with its own model: line, an expression observableFormula validates against the union of every model’s namespace, and each experiment’s model is recovered from the modelId on its measurement rows (emitted as a per-experiment model: field; a BNGL + SBML mix is fine). A constant-per-observable observableParameters scale/offset and an expression noiseFormula import too (ADR-0044): the placeholder is substituted into the observable/noise formula (an id resolves from the PSet, a number inlines), an expression noiseFormula becoming a FormulaSigma (noise_model <obs> = <family>, <param> = formula <expr>). A dose-response (parameter_scan) problem imports too (ADR-0046): N Conditions each setting one swept parameter at a constant measurement time (inf => steady state, or a finite t_end) are reconstructed into a single swept-axis .exp (column 0 the swept parameter) + a parameter_scan experiment: (the inverse of the exporter’s dose-response emission – reconstruct_dose_responses). Out of scope, each mirroring an export-side boundary: a condition-table sympy layer; the five PEtab prior families PyBNF lacks; a dose-response that also carries a named condition or row-varying per-measurement placeholders. (One-sided truncation now maps to a half-bounded box – ADR-0047, #432.)

class pybnf.petab.import_.ImportedExperiment(name, condition, preequilibrate, data_files, model_location, measparams_file, t_end)
condition

Alias for field number 1

data_files

Alias for field number 3

measparams_file

Alias for field number 5

model_location

Alias for field number 4

name

Alias for field number 0

preequilibrate

Alias for field number 2

t_end

Alias for field number 6

pybnf.petab.import_.import_job(problem_yaml_path, out_dir, job_type='de', method='ode', method_overrides=None, settings=None)[source]

Import the BNGL-native PEtab v2 problem at problem_yaml_path into out_dir.

Reads the problem’s tables + model, reconstructs the experiments’ data, and writes a new-era PyBNF job: the .exp data files, a verbatim copy of the BNGL model (new-era binds free parameters by id, so the model needs no re-instrumentation – ADR-0034), and one or more .conf files. The problem (parameters/priors, observables/noise, measurements, conditions/experiments) is recovered exactly; the run-recipe (job_type, method, settings) is supplied by the caller (see the module docstring). Returns the out_dir path.

job_type is the SEARCH method token, or 'all' to emit one imported_<jt>.conf per registered optimizer + sampler. method (default 'ode') is the per-experiment SIMULATION method; method_overrides (a {experiment_name: method} map) sets per-experiment values. settings overrides the required algorithm/run settings.

Both BNGL and SBML models import (ADR-0036): the model file is carried verbatim, and an expression observableFormula (e.g. a quotient of sums) becomes a conf measurement model (observable: <id>, formula: <expr>) evaluated post-simulation – the optional pybnf[petab] extra. A constant-per-observable observableParameters scale/offset and an expression noiseFormula are substituted/reduced and import too (ADR-0044). A dose-response (parameter_scan) problem – N conditions each setting one swept parameter at a constant measurement time (inf => steady state, ADR-0046) – is reconstructed into a single swept-axis .exp + a parameter_scan experiment. Raises NotImplementedError at the remaining PEtab/PyBNF boundaries (a model language other than bngl/sbml; the five unsupported prior families; a log-normal/log-laplace noise distribution; a condition expression; a row-varying per-measurement observableParameters/noiseParameters placeholder; replicate rows) and PybnfError for a malformed problem (an observableFormula symbol that is not a model entity, or an ambiguous dose-response group).

pybnf.petab.import_.read_problem_yaml(path)[source]

Hand-parse the minimal problem.yaml shape write_problem_yaml() emits.

Returns a dict with the table-file lists (parameter_files / observable_files / measurement_files / condition_files / experiment_files) and a models list – one {model_id, location, language} entry per model_files entry, in declaration order (one or many, ADR-0041). For single-model convenience the first model is also surfaced as model_file / model_id / model_language. Dependency-free (no YAML library): the writer emits a flat key: + `` - item`` list shape and a two-level model_files block, which a small indentation-aware scan reads exactly. The scan is order-independent, so a real v2 problem.yaml that lists model_files first (our writer emits it last) reads identically.

This is a pure reader: it records each model’s language but does not enforce a policy on it. The supported-language scope (BNGL or SBML, ADR-0036) is enforced by the importer (_require_supported_model()), not here.

Exporting a job

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 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 (_require_modern_edition()) and a legacy data linkage – model = X : Y.exp / mutant / param_scan (_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 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.

pybnf.petab.export.clean_model_for_petab(text)[source]

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.

pybnf.petab.export.export_job(conf_path, out_dir, inline_functions=False)[source]

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 (_require_modern_edition()). Raises NotImplementedError at every documented boundary and PybnfError for a malformed/unsupported job (see the module docstring).

pybnf.petab.export.write_problem_yaml(path, models, has_conditions=False, has_experiments=False)[source]

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).