Source code for pybnf.petab.conditions

"""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 :func:`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): :func:`build_experiment_conditions`
transcribes named ``condition:``/``experiment:`` lines, and :func:`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.
"""

import csv
import re
from dataclasses import dataclass

from ..printing import PybnfError
from ._tsv import num, write_tsv

_CONDITION_COLUMNS = ['conditionId', 'targetId', 'targetValue']
_EXPERIMENT_COLUMNS = ['experimentId', 'time', 'conditionId']

#: The surrogate-base marker (a double-underscore suffix, like PyBNF's ``__FREE``).
REF_MARKER = '__REF'

#: The ``conditionId`` prefix the exporter wraps every condition name in
#: (``cond_<name>``), and the synthesized base condition for wildtype experiments.
CONDITION_ID_PREFIX = 'cond_'
WILDTYPE_CONDITION_ID = 'cond_wildtype'


[docs] @dataclass(frozen=True) class PetabConditionRow: """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``). """ condition_id: str target_id: str target_value: str
[docs] @dataclass(frozen=True) class PetabExperimentRow: """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. """ experiment_id: str time: float condition_id: str
[docs] def surrogate_name(model_param): """The surrogate-base parameter id for a fit-and-mutated model parameter.""" return f'{model_param}{REF_MARKER}'
# --------------------------------------------------------------------------- # Asset: one mutation's operator -> a PEtab targetValue string # ---------------------------------------------------------------------------
[docs] def mutation_target_value(op, val, *, nominal=None, surrogate=None): """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). """ if op == '=': return num(val) if op not in ('*', '/', '+', '-'): raise ValueError(f"Unknown mutation operator {op!r}") if surrogate is not None: return f'{surrogate} {op} {num(val)}' if nominal is None: raise NotImplementedError( f"A relative mutation ('{op}' {num(val)}) of a fixed parameter needs the " f"parameter's numeric nominal value, but it has a non-numeric (expression) " f"value in the model. Evaluating a BNGL parameter expression is " f"simulation-grade work, out of scope for the exporter (ADR-0027).") if op == '*': return num(nominal * val) if op == '/': return num(nominal / val) if op == '+': return num(nominal + val) return num(nominal - val)
# --------------------------------------------------------------------------- # Asset: named conditions + experiments -> conditions/experiments (surrogate-base) # --------------------------------------------------------------------------- def _condition_rows_for(cid, perturbations, surrogate, nominal_of): """The condition rows for one PEtab Condition ``cid`` from its ``perturbations`` (``[(var, op, val), ...]``), under the problem-global surrogate set ``surrogate``. The shared per-condition emission of the surrogate-base machinery (ADR-0027), extracted so :func:`build_experiment_conditions` (time-course / wildtype) and :func:`build_preequilibration_conditions` (multi-period pre-equilibration, ADR-0052) emit a condition the same way: each surrogate (fit) param is pinned (this condition's expression where it sets it, else the base value ``<p>__REF`` -- every experiment re-supplies every M param, since the model name is now a pure condition target), then the fixed-param perturbations are emitted with precomputed numeric ``targetValue`` entries. """ rows = [] mut_by_var = {var: (op, val) for var, op, val in perturbations} for p in sorted(surrogate): if p in mut_by_var: op, val = mut_by_var[p] rows.append(PetabConditionRow( cid, p, mutation_target_value(op, val, surrogate=surrogate_name(p)))) else: rows.append(PetabConditionRow(cid, p, surrogate_name(p))) for var, op, val in perturbations: if var in surrogate: continue rows.append(PetabConditionRow( cid, var, mutation_target_value(op, val, nominal=nominal_of(var)))) return rows
[docs] def build_experiment_conditions(experiments, conditions, fit_params, nominal_of, extra_surrogate=frozenset()): """Build conditions/experiments for a new-era job (ADR-0028 Chunk 5b). Generalizes :func:`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 -- :func:`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). """ referenced = {c for _name, c in experiments if c is not None} surrogate = {var for c in referenced for var, _op, _val in conditions[c] if var in fit_params} | set(extra_surrogate) condition_rows = [] experiment_rows = [] # Each referenced condition, emitted once (deterministic order). for c in sorted(referenced): condition_rows += _condition_rows_for( f'cond_{c}', conditions[c], surrogate, nominal_of) # A shared synthesized base condition for wildtype experiments when M is non-empty # (they too must re-supply every removed fit param at its base value). has_wildtype = any(c is None for _name, c in experiments) wildtype_cid = None if surrogate and has_wildtype: wildtype_cid = 'cond_wildtype' if wildtype_cid in {f'cond_{c}' for c in referenced}: raise PybnfError( "A condition named 'wildtype' clashes with the synthesized base condition " "the exporter uses to pin fit-and-perturbed parameters for wildtype " "experiments. Rename the 'wildtype' condition.") condition_rows.extend( PetabConditionRow(wildtype_cid, p, surrogate_name(p)) for p in sorted(surrogate)) experiment_to_id = {} for name, c in experiments: if c is not None: experiment_to_id[name] = name experiment_rows.append(PetabExperimentRow(name, 0.0, f'cond_{c}')) elif surrogate: experiment_to_id[name] = name experiment_rows.append(PetabExperimentRow(name, 0.0, wildtype_cid)) else: experiment_to_id[name] = '' # model as is -- no experiment row needed return condition_rows, experiment_rows, surrogate, experiment_to_id
[docs] def build_preequilibration_conditions(experiments, conditions, nominal_of, surrogate=frozenset(), existing_condition_ids=frozenset()): """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 :func:`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 :func:`build_experiment_conditions`'s ``extra_surrogate``, so both builders share one ``M``). The shared :func:`_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 :func:`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 :data:`WILDTYPE_CONDITION_ID` (re-pinning every removed fit param at its base value -- the same base :func:`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. """ referenced = set() for _name, pre, meas in experiments: referenced.add(pre) if meas is not None: referenced.add(meas) emitted = set(existing_condition_ids) condition_rows = [] # Each referenced condition, emitted once across the whole job: a condition shared with a # time-course experiment was already emitted by build_experiment_conditions (skip it). A # fit-parameter perturbation here is handled by _condition_rows_for, since `surrogate` is the # problem-global M (the pre-equilibration contribution was threaded into it) -- #443. for c in sorted(referenced): cid = f'cond_{c}' if cid in emitted: continue condition_rows += _condition_rows_for(cid, conditions[c], surrogate, nominal_of) emitted.add(cid) # A wash-out (no measurement condition) with a non-empty M re-pins M at base on its time=0 # measurement period via the synthesized base condition cond_wildtype (the same base # build_experiment_conditions pins for wildtype time courses) -- emitted once, shared (#443). has_washout = any(meas is None for _name, _pre, meas in experiments) if surrogate and has_washout: if WILDTYPE_CONDITION_ID in {f'cond_{c}' for c in referenced}: raise PybnfError( "A condition named 'wildtype' clashes with the synthesized base condition the " "exporter uses to re-pin fit-and-perturbed parameters on a wash-out measurement " "period. Rename the 'wildtype' condition.") if WILDTYPE_CONDITION_ID not in emitted: condition_rows.extend( PetabConditionRow(WILDTYPE_CONDITION_ID, p, surrogate_name(p)) for p in sorted(surrogate)) emitted.add(WILDTYPE_CONDITION_ID) experiment_rows = [] experiment_to_id = {} for name, pre, meas in experiments: experiment_to_id[name] = name # Period 0: the -inf pre-equilibration period (steady state, unmeasured). experiment_rows.append(PetabExperimentRow(name, float('-inf'), f'cond_{pre}')) # Period 1: the time=0 measurement period. A measurement condition -> its cond id; a # wash-out -> the synthesized base cond_wildtype when M is non-empty (re-pin M at base), # else an empty conditionId (M empty -> the model default). if meas is not None: meas_cid = f'cond_{meas}' elif surrogate: meas_cid = WILDTYPE_CONDITION_ID else: meas_cid = '' experiment_rows.append(PetabExperimentRow(name, 0.0, meas_cid)) return condition_rows, experiment_rows, experiment_to_id
[docs] def build_dose_response_conditions(stem, swept_param, dose_values, scan_time): """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). """ condition_rows = [] experiment_rows = [] experiment_ids = [] for i, dose in enumerate(dose_values): eid = f'{stem}_{i}' cid = f'cond_{eid}' condition_rows.append(PetabConditionRow(cid, swept_param, num(dose))) experiment_rows.append(PetabExperimentRow(eid, 0.0, cid)) experiment_ids.append(eid) return condition_rows, experiment_rows, experiment_ids
# --------------------------------------------------------------------------- # Import: PEtab conditions -> new-era condition: perturbations (the reverse asset) # ---------------------------------------------------------------------------
[docs] def condition_name_from_id(condition_id): """The new-era ``condition:`` name for a PEtab ``conditionId``, or ``None``. ``None`` for the synthesized :data:`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). """ if not condition_id or condition_id == WILDTYPE_CONDITION_ID: return None if condition_id.startswith(CONDITION_ID_PREFIX): return condition_id[len(CONDITION_ID_PREFIX):] return condition_id
[docs] def conditions_from_rows(condition_rows, surrogate_params): """Invert :func:`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 :func:`_perturbation_from_row`). Declaration order within a condition is preserved (the wide<->long byte-equal round trip). """ conditions = {} for row in condition_rows: name = condition_name_from_id(row.condition_id) if name is None: continue pert = _perturbation_from_row(row, surrogate_params) if pert is not None: conditions.setdefault(name, []).append(pert) return conditions
def _perturbation_from_row(row, surrogate_params): """One condition row -> a ``(var, op, val)`` perturbation, or ``None`` for a base pin. * ``targetValue == '<var>__REF'`` exactly -> a base pin (machinery re-supplying a removed fit parameter at its estimated value); dropped (``None``). * ``targetValue == '<var>__REF <op> <num>'`` -> a relative op on a *fit* parameter (recover ``op`` + value; the surrogate is the parameter table's stand-in for it). * a bare number -> an absolute set ``var = <num>`` (a relative op on a *fixed* target is lossily precomputed to a number on export, with no PEtab home for the original op, so it round-trips as an absolute set -- the same PEtab value either way). * anything else -> a ``targetValue`` expression for the deferred sympy layer. """ var = row.target_id value = row.target_value.strip() if var in surrogate_params: ref = surrogate_name(var) if value == ref: return None # base pin -- machinery, not a user perturbation match = re.match(rf'^{re.escape(ref)}\s*([*/+-])\s*(.+)$', value) if match: return (var, match.group(1), float(match.group(2))) try: return (var, '=', float(value)) # absolute set (fit or fixed target) except ValueError: raise NotImplementedError( f"Condition targetValue {row.target_value!r} for '{var}' is an expression, " f"not a base pin, a surrogate relative op, or a number. Evaluating PEtab " f"condition formulae needs the sympy layer (the deferred observableFormula " f"chunk, #407), which adopts the petab library.") # --------------------------------------------------------------------------- # TSV readers (the disposable half of the seam) # ---------------------------------------------------------------------------
[docs] def read_condition_table(path): """Read a PEtab v2 ``conditions.tsv`` into :class:`PetabConditionRow` records (stdlib ``csv``; ``targetValue`` kept as the raw string).""" with open(path, newline='') as fh: reader = csv.DictReader(fh, delimiter='\t') rows = [] for rec in reader: cid = (rec.get('conditionId') or '').strip() tid = (rec.get('targetId') or '').strip() if not cid or not tid: raise PybnfError( "PEtab conditions row is missing a conditionId or targetId.") rows.append(PetabConditionRow(cid, tid, (rec.get('targetValue') or '').strip())) return rows
[docs] def read_experiment_table(path): """Read a PEtab v2 ``experiments.tsv`` into :class:`PetabExperimentRow` records (stdlib ``csv``; ``time`` coerced to float).""" with open(path, newline='') as fh: reader = csv.DictReader(fh, delimiter='\t') rows = [] for rec in reader: eid = (rec.get('experimentId') or '').strip() cid = (rec.get('conditionId') or '').strip() if not eid: raise PybnfError("PEtab experiments row is missing an experimentId.") time = rec.get('time') rows.append(PetabExperimentRow( eid, float(time) if time and time.strip() else 0.0, cid)) return rows
# --------------------------------------------------------------------------- # Writers (the disposable half of the seam) # ---------------------------------------------------------------------------
[docs] def write_condition_table(rows, path): """Write condition ``rows`` to ``path`` as a PEtab v2 ``conditions.tsv``.""" records = [[r.condition_id, r.target_id, r.target_value] for r in rows] write_tsv(path, _CONDITION_COLUMNS, records)
[docs] def write_experiment_table(rows, path): """Write experiment ``rows`` to ``path`` as a PEtab v2 ``experiments.tsv``.""" records = [[r.experiment_id, num(r.time), r.condition_id] for r in rows] write_tsv(path, _EXPERIMENT_COLUMNS, records)