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 -- when it
holds only base pins; one with real targets imports under its literal id (#905).

**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 math
import re
from dataclasses import dataclass

from ..printing import PybnfError
from ._bngl import _block_lines as bngl_block_lines
from ._tsv import num, write_tsv

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

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

#: The prefix a synthesized species-amount target id carries (``species_<sanitized-pattern>``).
SPECIES_ID_PREFIX = 'species_'

#: 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 PetabMappingRow: """One row of a PEtab v2 mapping table: a ``petabEntityId`` -> ``modelEntityId`` alias. The exporter's use (#477) is the **species-amount condition target**: a BNGL species pattern (``A()``, ``IGF1(ds,hs,label~hot)``) is not a valid PEtab identifier (it carries parens/commas/tildes), so it cannot be a condition ``targetId`` directly. The mapping row aliases a synthesized SId ``petab_id`` (:func:`species_target_id`, used as the target) to the verbatim BNGL ``model_id`` pattern; petab's ``CheckValidConditionTargets`` admits a mapping petab_id whose model_id is a state variable (a species) as a condition target. """ petab_id: str model_id: 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}'
[docs] def is_species_target(target): """True iff a condition target is a BNGL species *pattern* (a ``setConcentration`` wash / bolus, ADR-0062), not a bare parameter/compartment id -- detected by the ``(`` a pattern always carries and a PEtab/BNGL identifier never does.""" return '(' in target
[docs] def species_target_id(pattern): """A PEtab v2 SId aliasing a BNGL species *pattern* for the mapping table's ``petabEntityId``. A BNGL pattern (``A()``, ``IGF1(ds,hs,label~hot)``) is not a valid PEtab identifier, so it is sanitized to ``species_<A-Za-z0-9_>``: every run of non-identifier characters collapses to a single ``_`` and leading/trailing ``_`` are trimmed. Content-derived and deterministic -- the same pattern always maps to the same id, so an import -> re-export is byte-stable regardless of condition order. Two distinct patterns that sanitize alike would collide; the exporter detects that and raises (:func:`~pybnf.petab.export._species_id_map`).""" return SPECIES_ID_PREFIX + re.sub(r'\W+', '_', pattern).strip('_')
def _species_target_value(pattern, op, val): """One species ``setConcentration`` perturbation's PEtab ``targetValue`` string (ADR-0062). A species amount is an *absolute* quantity, so only ``=`` is meaningful (a relative op has no bolus meaning) -- a relative op raises. The value is a number (emitted via :func:`num`) or a parameter-expression (the dose-tracking competitor ``IGF1_cold_conc*(NA*Vecf)``), emitted verbatim; the measurement period is not subject to ``CheckInitialChangeSymbols``, so an expression there is unconstrained.""" if op != '=': raise NotImplementedError( f"Condition sets the species amount '{pattern}' with a relative op ('{op}'); only an " f"absolute set ('=') of a species amount has a PEtab v2 targetValue -- a " f"setConcentration is a bolus / wash, not a scaling (ADR-0062).") try: return num(float(val)) except (TypeError, ValueError): return str(val).strip() # a parameter-expression value, emitted verbatim # --------------------------------------------------------------------------- # Asset: one mutation's operator -> a PEtab targetValue string # ---------------------------------------------------------------------------
[docs] def mutation_target_value(op, val, *, nominal=None, surrogate=None, target=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). A **parameter-reference value** (``val`` a *string* free-parameter id, not a number -- a per-condition estimated initial condition, ADR-0076) emits that id verbatim as the ``targetValue`` for an absolute set: it is PEtab-legal (the referenced id is a parameter-table entry, and the fixed target is not, so no id is in both tables -- the surrogate split is not needed). A relative op on a parameter reference is a multi-symbol expression, deferred (``NotImplementedError``). """ if isinstance(val, str): if op == '=': return val raise NotImplementedError( f"A relative mutation ('{op}' {val}) whose value is a parameter reference is a " f"multi-symbol condition expression, out of scope for the exporter (ADR-0076); a " f"parameter-valued condition targetValue is supported only as an absolute set.") 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: named = f"the fixed parameter '{target}'" if target else 'a fixed parameter' fix = (f"Write this condition as an absolute set ('= <number>'), or declare '{target}' as " f"a fit parameter so the condition can be expressed relative to its surrogate" if target else "Write this condition as an absolute set ('= <number>')") raise NotImplementedError( f"A relative mutation ('{op}' {num(val)}) of {named} needs that parameter's numeric " f"nominal value, but the model file does not settle one. In a BNGL model its value is " f"an expression over other parameters. In an SBML model an assignment rule computes it " f"every step, or an initial assignment derives it at the start of the simulation. " f"PyBNF does not report a value the model file alone cannot settle, because a PEtab " f"parameter table may override or estimate the entities it is computed from, and the " f"reported number would then be stale. {fix} (ADR-0027, #465, #795).") 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, species_id_of=None): """The condition rows for one PEtab Condition ``cid`` from its ``perturbations`` (``[(var, op, val), ...]``), under the problem-global surrogate set ``surrogate``. ``nominal_of(var)`` is already bound to this condition (the builders bind the ``nominal_of(condition, var)`` they take to the condition being emitted -- #897). The shared per-condition emission of the surrogate-base machinery (ADR-0027), extracted so :func:`build_experiment_conditions` (time-course / wildtype), :func:`build_preequilibration_conditions` (multi-period pre-equilibration, ADR-0052), and :func:`build_preequilibrated_dose_response_conditions` (pre-equilibrated scan, ADR-0062) 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. ``species_id_of`` (``{pattern: petab_id}``, ADR-0062) maps a BNGL species ``setConcentration`` target to its mapping-table SId (:func:`species_target_id`); such a target is emitted with that id and a species ``targetValue`` (number or parameter-expression) and is never a surrogate/fit param. When ``None`` the condition has no species targets (every prior shape).""" species_id_of = species_id_of or {} 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 if is_species_target(var): rows.append(PetabConditionRow( cid, species_id_of[var], _species_target_value(var, op, val))) continue rows.append(PetabConditionRow( cid, var, mutation_target_value(op, val, nominal=nominal_of(var), target=var))) if not rows: # PEtab v2 has no zero-row condition: a conditionId an experiment names must have at # least one row. Only a `perturbations: none` condition (#906, ADR-0150) has nothing to # write, and the builders export it as the model as is (a blank conditionId) instead. raise PybnfError( f"Condition '{cid}' would be exported with no rows, which PEtab cannot express. A " f"'perturbations: none' condition is exported as the model as is, never as a " f"conditionId; this is a PyBNF exporter defect, please report it.") 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(condition, var)`` returns a fixed parameter's numeric nominal in the model ``condition`` belongs to (or ``None`` for an expression/unknown) -- per condition, because two models of a multi-model job may give a same-named fixed parameter different values (#897). 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, lambda v, c=c: nominal_of(c, v)) # 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
# --------------------------------------------------------------------------- # Fixed-duration equilibration (#896): the equilibration period's start time # ---------------------------------------------------------------------------
[docs] def equilibration_period_time(equil_t_end): """The PEtab v2 start time of a pre-equilibration experiment's leading (unmeasured) period. ``None`` (no ``equil_t_end:``, the ADR-0052 default) is ``-inf``: equilibrate to steady state. A fixed duration ``T`` is the finite time ``-T``: PEtab v2 runs a period from its start time until the next period starts, and the measured period starts at 0, so the equilibration runs for exactly ``T`` (#896). The exporter has already checked ``T`` is finite and positive (``export._read_experiments``).""" return float('-inf') if equil_t_end is None else -float(equil_t_end)
[docs] def equil_t_end_from_period_time(time): """The inverse of :func:`equilibration_period_time`: ``-inf`` -> ``None`` (steady state), a finite ``-T < 0`` -> ``T``. The caller checks that the period is followed by one at exactly ``0`` (the measured period); anything else raises ``ValueError``.""" if math.isinf(time) and time < 0: return None if math.isfinite(time) and time < 0: return -time raise ValueError(f'not a pre-equilibration period start time: {time!r}')
[docs] def refuse_measurements_inside_fixed_equilibration(experiment, equil_t_end, times): """Refuse an imported measurement taken during a fixed-duration equilibration period. A PEtab v2 experiment can be measured at any time from its first period's start, so a problem whose leading period starts at ``-T`` may carry measurements at times in ``[-T, 0)``, taken while the system equilibrates. PyBNF's ``preequilibrate:`` + ``equil_t_end: T`` equilibration is unmeasured, and its measured phase starts at the intervention (``t = 0``), so such a measurement has no PyBNF representation. Imported as it is, it would land on the measured phase's time grid, where bngsim starts integrating at the earliest sample time and so scores every measurement of the experiment late (#896). ``times`` are the experiment's measurement times.""" early = sorted({float(t) for t in times if t < 0}) if early: raise NotImplementedError( f"Experiment '{experiment}' is measured at time(s) " f"{', '.join(num(t) for t in early)}, inside its fixed-duration equilibration period " f"(time -{num(equil_t_end)} to 0). PyBNF runs that equilibration (preequilibrate: " f"with equil_t_end: {num(equil_t_end)}) unmeasured and measures only from the " f"intervention at time 0, so a measurement taken during the equilibration has no " f"PyBNF representation (#896). Remove those measurement rows to import the rest.")
# The BNGL blocks whose expressions can read the simulation time: a function body, an inline # rate-law expression, a parameter or compartment-volume expression. Actions (inside or outside a # ``begin actions`` block) are simulation directives, not model structure, so they are not read. _BNGL_EXPRESSION_BLOCKS = ('parameters', 'functions', 'reaction rules', 'compartments') _BNGL_TIME = re.compile(r'\btime\b') _BNGL_TFUN_CALL = re.compile(r'\btfun\s*\(') # An SBML ``<csymbol>`` for the simulation time (Level 2 and Level 3 share the URL). _SBML_TIME_CSYMBOL = re.compile( r'definitionURL\s*=\s*["\']http://www\.sbml\.org/sbml/symbols/time["\']') def _tfun_arguments(text, start): """The top-level comma-separated arguments of the ``tfun(`` call whose ``(`` is at ``start - 1``, or ``None`` when the call is unterminated.""" depth, args, current = 0, [], [] for ch in text[start:]: if ch in '([': depth += 1 elif ch in ')]': if depth == 0: args.append(''.join(current).strip()) return args depth -= 1 elif ch == ',' and depth == 0: args.append(''.join(current).strip()) current = [] continue current.append(ch) return None
[docs] def model_time_reads(text, language): """How a model reads the simulation time, as a list of short descriptions (empty when it does not): what makes it **non-autonomous**, so that shifting its clock changes its result. PyBNF runs a fixed-duration equilibration (``equil_t_end: T``) on ``t`` in ``[0, T]`` and restarts the clock at 0 for the measured phase (``pset.py::_append_preequilibration_actions`` and the bngsim SBML backend's ``_begin_preequilibration``); a PEtab v2 leading period at ``-T`` runs on ``[-T, 0]``. The two are the same simulation exactly when nothing in the model reads the time (#896), which is what this checks: * **BNGL** -- the ``time`` symbol in a parameters, functions, reaction rules, or compartments block (``time()`` in a function or rate law, or the index of a ``tfun(..., time)`` table function), and a lowercase ``tfun`` call with no index at all (bngsim indexes it by time by default). The legacy uppercase ``TFUN(counter, 'file')`` is indexed by an observable, so it is a state read, not a time read. * **SBML** -- a ``<csymbol>`` for time anywhere in the document (a kinetic law, rule, initial assignment, or event trigger). """ if language == 'sbml': return ['a <csymbol> for time'] if _SBML_TIME_CSYMBOL.search(text) else [] body = '\n'.join(line for block in _BNGL_EXPRESSION_BLOCKS for line in bngl_block_lines(text, block)) reads = [] if _BNGL_TIME.search(body): reads.append("the 'time' symbol (time() or a time-indexed tfun)") for m in _BNGL_TFUN_CALL.finditer(body): args = _tfun_arguments(body, m.end()) if args is None: continue positional = [a for a in args if '=>' not in a] index_at = 2 if positional and positional[0].startswith('[') else 1 if len(positional) <= index_at: reads.append('a tfun table function with no index (indexed by time by default)') break return reads
[docs] def build_preequilibration_conditions(experiments, conditions, nominal_of, surrogate=frozenset(), existing_condition_ids=frozenset(), species_id_of=None): """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). A **fixed-duration** equilibration (``equil_t_end: T``) leads with a finite ``time = -T`` period instead, so the equilibration runs for exactly ``T`` before the measured period starts at 0 (#896, :func:`equilibration_period_time`). ``experiments`` is a list of ``(name, preequil_cond, measurement_cond_or_None, equil_t_end_or_None)``; ``conditions`` maps a condition name to its perturbations ``[(var, op, val), ...]``; ``nominal_of(condition, var)`` returns a fixed parameter's numeric nominal in the model the condition belongs to, as for :func:`build_experiment_conditions` (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. ``species_id_of`` (``{pattern: petab_id}``, ADR-0062) maps a species ``setConcentration`` wash target to its mapping-table id, threaded through :func:`_condition_rows_for` so a pre-equilibration or measurement (wash) condition can perturb a species amount. A ``perturbations: none`` pre-equilibration condition (an empty perturbation list, #906, ADR-0150) equilibrates the model as it stands, so it has no rows of its own: its ``-inf`` period takes the wash-out's treatment -- a blank ``conditionId`` when ``M`` is empty (PEtab v2's "the model as is"), else :data:`WILDTYPE_CONDITION_ID`, which re-pins ``M`` at base. """ referenced = set() for _name, pre, meas, _equil in experiments: if conditions[pre]: 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, lambda v, c=c: nominal_of(c, v), species_id_of=species_id_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). # A `none` pre-equilibration re-pins M on its equilibration period the same way (#906). has_washout = any(meas is None or not conditions[pre] for _name, pre, meas, _equil 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, equil_t_end in experiments: experiment_to_id[name] = name # Period 0: the unmeasured pre-equilibration period -- -inf (steady state), or -T for a # fixed equil_t_end: T (#896). A `none` condition is the model as is: blank, or the M # re-pin cond_wildtype (#906). if conditions[pre]: pre_cid = f'cond_{pre}' elif surrogate: pre_cid = WILDTYPE_CONDITION_ID else: pre_cid = '' experiment_rows.append(PetabExperimentRow( name, equilibration_period_time(equil_t_end), pre_cid)) # 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, surrogate=frozenset()): """Build the conditions/experiments for a dose-response Parameter Scan. Each dose of the exported dose axis ``dose_values`` 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``). ``dose_values`` is the axis the fitter scans -- the sorted union of every replicate's doses (#895), built by the exporter -- not one data file's rows. Returns ``(condition_rows, experiment_rows, experiment_ids)`` where ``experiment_ids[i]`` is the experimentId for ``dose_values[i]``; the exporter keys each data row to the id of that row's own dose. ``surrogate`` is the problem-global surrogate set M (ADR-0027). Its parameters are out of the parameter table, so every simulation must re-supply them: each per-dose Condition therefore also pins ``p = p__REF`` for every ``p`` in M, exactly as the time-course, wildtype and pre-equilibration builders do (#892). Without the pin a PEtab tool simulates every dose at ``p``'s model-file value instead of its estimate. The swept parameter is never pinned: the dose sets it, as the fitter's scan does. """ 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))) condition_rows.extend(PetabConditionRow(cid, p, surrogate_name(p)) for p in sorted(surrogate) if p != swept_param) experiment_rows.append(PetabExperimentRow(eid, 0.0, cid)) experiment_ids.append(eid) return condition_rows, experiment_rows, experiment_ids
[docs] def build_preequilibrated_dose_response_conditions(experiments, conditions, nominal_of, species_id_of=None, existing_condition_ids=frozenset(), surrogate=frozenset()): """Build the conditions/experiments for **pre-equilibrated dose-response** experiments -- the preincubate -> wash -> dose-scan protocol (#477; ADR-0062), the combination of ADR-0052's two-period pre-equilibration and ADR-0046's dose-response scan. Each scanned experiment maps to N (one per dose) **two-period** PEtab Experiments ``<stem>_<i>``: a leading ``time = -inf`` steady-state period under the shared pre-equilibration condition, then a ``time = 0`` **measurement period** that applies BOTH the shared measurement (wash) condition AND a per-dose condition ``cond_<stem>_<i>`` setting the swept parameter to dose ``i``. A period carrying two condition ids is a native PEtab v2 shape (repeated experiment-table rows at the same ``(experimentId, time)``); the two conditions' targets are disjoint -- the swept parameter is never a wash target. The per-dose conditions are exactly :func:`build_dose_response_conditions`'; the pre-equilibration and wash conditions are the job's named ``condition:`` sets, whose species ``setConcentration`` targets are emitted through ``species_id_of`` (ADR-0062). Measurements are tagged ``<stem>_<i>`` at the scan time -- identical to a plain dose-response, so :func:`~pybnf.petab.measurements.dose_response_measurement_rows` pivots them unchanged. ``experiments`` is a list of ``(name, preequilibrate_cond, wash_cond_or_None, swept_param, dose_values, scan_time, equil_t_end_or_None)`` in declaration order; ``dose_values`` is the exported dose axis (the sorted union of the replicates' doses, #895), and a fixed-duration equilibration (``equil_t_end: T``) leads each dose's experiment with a ``time = -T`` period in place of ``-inf`` (#896, :func:`equilibration_period_time`). ``species_id_of`` (``{pattern: petab_id}``) maps a species pattern to its mapping-table id; ``existing_condition_ids`` dedups a pre-equilibration or wash condition already emitted by another experiment shape. ``surrogate`` is the problem-global surrogate set M (ADR-0027, #892). Its parameters are out of the parameter table, so the simulation must be given them before it starts. The pre-equilibration condition is emitted through :func:`_condition_rows_for`, so the ``-inf`` period sets every ``p`` in M: its own value where the condition perturbs ``p``, else the base pin ``p = p__REF``. A later period keeps a value it does not change. That is PEtab v2's rule (a period's changes persist; libpetab turns them into SBML events), and it is PyBNF's too: the fitter applies the pre-equilibration condition as an inline ``setParameter`` and never undoes it, so a fit parameter the pre-equilibration condition sets keeps that value through the scan. So a wash-free measurement period carries only the per-dose condition. A synthesized base there would re-pin such a parameter to its estimate, which the fit does not do. A wash condition is emitted through :func:`_condition_rows_for` as well, so it also re-pins M. That is harmless for a parameter the pre-equilibration left at its base, and wrong for one it set: the orchestrator refuses the latter, and a swept parameter in M, whose pin would collide with the dose. The per-dose condition sets only the swept parameter. PEtab v2 forbids two conditions of one period from sharing a target (``CheckValidConditionTargets``), so it cannot carry the wash's pins as well. Returns ``(condition_rows, experiment_rows, experiment_ids_by_name)`` where ``experiment_ids_by_name[name]`` is the ordered ``[<stem>_0, <stem>_1, ...]`` list (aligned with that experiment's ``dose_values``) for tagging its measurements. """ emitted = set(existing_condition_ids) condition_rows = [] experiment_rows = [] experiment_ids_by_name = {} # The shared pre-equilibration + wash conditions, each emitted once across the whole job. A # condition shared with another shape was already emitted under the same problem-global M. # A `perturbations: none` pre-equilibration (#906, ADR-0150) has no rows of its own: its # equilibration period is the model as is -- blank, or, when M is non-empty, the base # condition cond_wildtype, which pins all of M at base and so sets M before the simulation # starts, as any other pre-equilibration condition here does. for _name, pre, wash, _sp, _dv, _st, _equil in experiments: for c in (pre, wash): if c is None or not conditions[c]: continue cid = f'cond_{c}' if cid in emitted: continue condition_rows += _condition_rows_for(cid, conditions[c], surrogate, lambda v, c=c: nominal_of(c, v), species_id_of=species_id_of) emitted.add(cid) base_cid = '' if surrogate and any(not conditions[pre] for _n, pre, *_rest in experiments): # (A job condition named 'wildtype' is refused before any builder runs, #905.) base_cid = WILDTYPE_CONDITION_ID 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) for name, pre, wash, swept_param, dose_values, _scan_time, equil_t_end in experiments: eids = [] for i, dose in enumerate(dose_values): eid = f'{name}_{i}' dose_cid = f'cond_{eid}' condition_rows.append(PetabConditionRow(dose_cid, swept_param, num(dose))) # Period 0: the unmeasured pre-equilibration period -- -inf (steady state), or -T for # a fixed equil_t_end: T (#896). Its condition sets all of M, and those values persist # into the measurement period. experiment_rows.append(PetabExperimentRow( eid, equilibration_period_time(equil_t_end), f'cond_{pre}' if conditions[pre] else base_cid)) # Period 1: the measurement period -- the shared wash condition (if any) plus the # per-dose swept-parameter condition, applied simultaneously (disjoint targets). if wash is not None and conditions[wash]: experiment_rows.append(PetabExperimentRow(eid, 0.0, f'cond_{wash}')) experiment_rows.append(PetabExperimentRow(eid, 0.0, dose_cid)) eids.append(eid) experiment_ids_by_name[name] = eids return condition_rows, experiment_rows, experiment_ids_by_name
# --------------------------------------------------------------------------- # Import: PEtab conditions -> new-era condition: perturbations (the reverse asset) # --------------------------------------------------------------------------- def _is_base_pin(row, surrogate_params): """True iff a condition row is a surrogate base pin ``p = p__REF`` -- machinery re-supplying a removed fit parameter at its estimated value (the row :func:`_perturbation_from_row` drops). The importer reads a pin as the identity once ``p__REF`` is renamed back to ``p``; that is exact only when no earlier period of the experiment changed ``p``, which is left to the issue that handles pins exactly.""" return (row.target_id in surrogate_params and row.target_value.strip() == surrogate_name(row.target_id))
[docs] def drop_synthesized_wildtype(condition_rows, experiment_rows, surrogate_params): """Remove the exporter's synthesized base condition from an imported problem (#905). The exporter writes :data:`WILDTYPE_CONDITION_ID` to re-pin every fit-and-perturbed parameter at its base value (``p = p__REF``) on a wildtype time course or a wash-out measurement period (ADR-0027/0052). The importer has always read those rows as "no condition" (see :func:`_is_base_pin` for when that is exact), so a ``cond_wildtype`` made **only** of base pins (or with no rows at all) means "no condition": its rows are dropped and every experiments-table period that applies it gets a blank ``conditionId``, the PEtab spelling of "the model as is" that the reconstruction below already reads. A ``cond_wildtype`` with any other row carries real targets -- a condition some other tool wrote, or a PyBNF condition named ``wildtype`` exported before the exporter reserved that name -- and is left in place, to import under its literal id (:func:`condition_name_from_id`). Before #905 every ``cond_wildtype`` was treated as the base, so a real one was dropped whole and its experiments were fitted against the unperturbed model. Returns ``(condition_rows, experiment_rows)``, the inputs unchanged unless the base was dropped. ``surrogate_params`` is the set of model parameters with a ``<p>__REF`` surrogate. """ wildtype_rows = [r for r in condition_rows if r.condition_id == WILDTYPE_CONDITION_ID] if not all(_is_base_pin(r, surrogate_params) for r in wildtype_rows): return condition_rows, experiment_rows return ([r for r in condition_rows if r.condition_id != WILDTYPE_CONDITION_ID], [PetabExperimentRow(r.experiment_id, r.time, '') if r.condition_id == WILDTYPE_CONDITION_ID else r for r in experiment_rows])
[docs] def condition_name_from_id(condition_id): """The new-era ``condition:`` name for a PEtab ``conditionId``, or ``None``. ``None`` for an absent/blank id (the model as is). Otherwise the ``cond_`` prefix is stripped (an externally-authored id without the prefix passes through unchanged, defensively) -- except for :data:`WILDTYPE_CONDITION_ID`, which keeps its literal id. By the time an id reaches here the importer has dropped a ``cond_wildtype`` that is only the exporter's synthesized base (:func:`drop_synthesized_wildtype`), so one that is still present carries real targets. It is not named ``wildtype``: the exporter reserves that name and would refuse to export the imported job, whereas a condition named ``cond_wildtype`` re-exports as ``cond_cond_wildtype`` and imports back under the same name (#905). """ if not condition_id: return None if condition_id == WILDTYPE_CONDITION_ID: return condition_id if condition_id.startswith(CONDITION_ID_PREFIX): return condition_id[len(CONDITION_ID_PREFIX):] return condition_id
#: The name the importer gives the ``perturbations: none`` condition it synthesizes for a #: ``time = -inf`` period that applies no condition (#906, ADR-0150); ``unperturbed_2``, #: ``unperturbed_3``, ... when the problem already uses it. UNPERTURBED_CONDITION_NAME = 'unperturbed'
[docs] def free_condition_name(taken, base=UNPERTURBED_CONDITION_NAME): """The first of ``base``, ``base_2``, ``base_3``, ... that is neither in ``taken`` (a set of condition names and ids) nor, prefixed ``cond_``, an id in it.""" name, n = base, 1 while name in taken or f'{CONDITION_ID_PREFIX}{name}' in taken: n += 1 name = f'{base}_{n}' return name
[docs] def condition_names_and_ids(condition_rows, experiment_rows): """Every condition id either table uses, and the conf name each one imports as.""" ids = {r.condition_id for r in condition_rows} | {r.condition_id for r in experiment_rows} return ids | ({condition_name_from_id(cid) for cid in ids} - {None})
[docs] def name_unperturbed_equilibrations(condition_rows, experiment_rows): """Point each ``time = -inf`` period that applies no condition at a synthesized ``none`` condition (#906, ADR-0150). In PEtab v2 a blank ``conditionId`` means "the model as is". On the ``-inf`` period of a pre-equilibration that is an equilibration with nothing changed -- a PyBNF ``perturbations: none`` condition applied as ``preequilibrate:`` -- which is not the same thing as no pre-equilibration at all, the meaning ``None`` has in the period readers. Before #906 the two collapsed, and the experiment started from the seed species instead of the steady state. So each such period is given the id ``cond_<name>`` for one ``name`` (:func:`free_condition_name`) that collides with no condition name or id in the problem; every reader downstream then sees an ordinary named pre-equilibration, and the importer declares ``name`` as a ``none`` condition. A period whose id :func:`condition_name_from_id` maps to ``None`` counts as applying no condition: a blank id, which includes the exporter's base condition once :func:`drop_synthesized_wildtype` has blanked it (only when every row is a base pin). A ``cond_wildtype`` with a real target keeps its id and stays a named pre-equilibration (#905). A blank id on a finite period is left alone: there it is simply the absence of a ``condition:`` (a wash-out). Returns ``(experiment_rows, name)``; ``name`` is ``None``, and the rows are unchanged, when no period applies no condition. """ def unconditioned_equilibration(row): return math.isinf(row.time) and row.time < 0 and condition_name_from_id( row.condition_id) is None if not any(unconditioned_equilibration(r) for r in experiment_rows): return experiment_rows, None name = free_condition_name(condition_names_and_ids(condition_rows, experiment_rows)) cid = f'{CONDITION_ID_PREFIX}{name}' return ([PetabExperimentRow(r.experiment_id, r.time, cid) if unconditioned_equilibration(r) else r for r in experiment_rows], name)
[docs] def refuse_inexact_unperturbed_periods(condition_rows, experiment_rows, surrogate_params, measured_ids): """Refuse the periods the importer would read as the model as is when PEtab does not (#906, ADR-0150). The importer turns a blank ``-inf`` period into the synthesized ``perturbations: none`` condition :func:`name_unperturbed_equilibrations` names, and a named condition whose rows are all base pins ``p = p__REF`` into a ``none`` condition of its own. In PyBNF a ``none`` condition leaves every parameter as it stands: a fit parameter at its trial value, and whatever an earlier period of the experiment set. Two PEtab readings differ from that, and are refused here, before anything is dropped or renamed. The rows are the tables as read; ``surrogate_params`` is the set M of parameters estimated through a ``<p>__REF`` surrogate; only the experiments in ``measured_ids`` are checked, since only they reach the conf. * **A first period that leaves a parameter of M unset.** Each ``p`` in M is a condition target, not a parameter-table entry, so until a period sets it PEtab runs it at the model file's value, where PyBNF would run the trial value. So a ``-inf`` period that is blank or applies a pins-only condition, and a first measured period that applies a pins-only named condition, must pin all of M. The exporter's own periods always do. The exact import, which would set such a ``p`` to its model-file value, is left to #948. * **A pins-only named condition after an earlier period changed what it pins.** ``p = p__REF`` is the identity only while nothing earlier in the experiment set ``p``. After a pre-equilibration that set ``k = 2``, PEtab's pin restores ``k`` to its estimate, which a ``none`` condition would not do. Importing such a re-pin exactly is #948. (The exporter's pins-only ``cond_wildtype`` in that position is the older #948 case and is left as it is.) Raises ``NotImplementedError`` naming the experiment and the parameters. """ rows_of = {} for row in condition_rows: rows_of.setdefault(row.condition_id, []).append(row) def pins_only(cid): rows = rows_of.get(cid) return bool(rows) and all(_is_base_pin(r, surrogate_params) for r in rows) periods_of = {} for row in experiment_rows: if row.experiment_id in measured_ids: periods_of.setdefault(row.experiment_id, []).append(row) for eid in sorted(periods_of): periods = sorted(periods_of[eid], key=lambda r: r.time) first, cid = periods[0], periods[0].condition_id equilibration = math.isinf(first.time) and first.time < 0 if surrogate_params and ((equilibration and (not cid or pins_only(cid))) or (cid and cid != WILDTYPE_CONDITION_ID and pins_only(cid))): unset = sorted(set(surrogate_params) - {r.target_id for r in rows_of.get(cid, [])}) if unset: applied = (f"condition '{cid}', whose rows only re-pin fit parameters" if cid else 'no condition (a blank conditionId)') where = "to the period's condition" if cid else 'as a condition of that period' names = ', '.join(unset) raise NotImplementedError( f"Experiment '{eid}' starts with a period that applies {applied}, which " f"PyBNF imports as the model as is (a `perturbations: none` condition), with " f"{names} at the fitted value. But {names} " f"{'is' if len(unset) == 1 else 'are'} estimated through " f"{', '.join(surrogate_name(p) for p in unset)} and set by conditions, so " f"PEtab runs that period at the model file's value instead. Add " f"{', '.join(f'{p} = {surrogate_name(p)}' for p in unset)} {where} if the " f"fitted value is meant (as PyBNF's exporter writes it); importing the model " f"file's value is not supported yet (#948).") for later in periods[1:]: cid = later.condition_id if not cid or cid == WILDTYPE_CONDITION_ID or not pins_only(cid): continue for p in sorted({r.target_id for r in rows_of[cid]}): setters = [e.condition_id for e in periods if e.time < later.time and any(r.target_id == p and not _is_base_pin(r, surrogate_params) for r in rows_of.get(e.condition_id, []))] if setters: raise NotImplementedError( f"Experiment '{eid}' applies condition '{cid}' at time " f"{num(later.time)}, whose only rows re-pin fit parameters, after an " f"earlier period set {p} (condition '{setters[-1]}'). PEtab's " f"{p} = {surrogate_name(p)} restores {p} to its estimate there, but PyBNF " f"reads a condition of only such pins as the model as is (a " f"`perturbations: none` condition) and would keep the earlier value of " f"{p}. Importing that re-pin exactly is not supported yet (#948).")
[docs] def conditions_from_rows(condition_rows, surrogate_params, species_by_id=None, free_names=frozenset(), fixed_params=None, applied=None): """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). Base pins (a row whose ``targetValue`` is exactly a surrogate name) are dropped as machinery, so the synthesized wildtype base -- only pins -- yields no condition (the importer has already dropped it, :func:`drop_synthesized_wildtype`); 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). Two conditionIds can map to one name (``cond_a`` and ``a``, or ``cond_cond_wildtype`` and ``cond_wildtype``). ``applied`` is the set of conditionIds some measured experiment applies (``None``: treat every id as applied). If two or more of the colliding ids are applied, ``PybnfError`` is raised rather than merging their targets into one condition. Otherwise one id is kept under the shared name -- the applied one, or with none applied the first in table order -- and the others are left out: a condition no experiment applies cannot change the fit (libpetab only warns about one). ``species_by_id`` (``{petab_id: pattern}``, ADR-0062) inverts the mapping table: a target that is a mapping species id recovers its BNGL pattern and a verbatim ``=`` value (a species ``setConcentration`` wash/bolus). ``free_names`` (the estimated parameter-table ids) and ``fixed_params`` (``{id: value}`` for the fixed ones) resolve a **parameter-valued** ``targetValue`` -- a per-condition estimated initial condition (ADR-0076): a target set to a free-parameter id becomes a parameter reference (``val`` a *string* naming that free parameter), a target set to a fixed one inlines its numeric value.""" species_by_id = species_by_id or {} kept = _kept_condition_ids(condition_rows, applied) conditions = {} for row in condition_rows: name = condition_name_from_id(row.condition_id) if name is None or row.condition_id not in kept: continue pert = _perturbation_from_row(row, surrogate_params, species_by_id, free_names, fixed_params or {}) if pert is not None: conditions.setdefault(name, []).append(pert) return conditions
def _kept_condition_ids(condition_rows, applied): """The conditionIds :func:`conditions_from_rows` imports: one per PyBNF condition name. Where several ids map to one name, the applied one is kept (``applied=None`` counts every id as applied); with none applied, the first in table order. Two or more applied ids raise ``PybnfError`` naming them, since importing them would merge their targets (#905).""" ids_of_name = {} for row in condition_rows: name = condition_name_from_id(row.condition_id) if name is not None and row.condition_id not in ids_of_name.setdefault(name, []): ids_of_name[name].append(row.condition_id) kept = set() for name, ids in ids_of_name.items(): used = [cid for cid in ids if applied is None or cid in applied] if len(used) > 1: listed = ', '.join(repr(cid) for cid in used[:-1]) + f' and {used[-1]!r}' raise PybnfError( f"PEtab conditions {listed} {'both' if len(used) == 2 else 'all'} import as the " f"PyBNF condition {name!r}, and experiments apply each of them, so their targets " f"would be merged into one condition. Rename one of them in the conditions and " f"experiments tables.") kept.add(used[0] if used else ids[0]) return kept #: A bare PEtab identifier (a parameter-valued ``targetValue`` names exactly one parameter; #: anything with operators/whitespace is a multi-symbol expression for the deferred sympy layer). _BARE_IDENTIFIER = re.compile(r'[A-Za-z_]\w*\Z') def _perturbation_from_row(row, surrogate_params, species_by_id=None, free_names=frozenset(), fixed_params=None): """One condition row -> a ``(var, op, val)`` perturbation, or ``None`` for a base pin. * ``target_id`` a mapping species id -> a species ``setConcentration`` (recover the BNGL ``pattern`` from ``species_by_id`` and the verbatim ``=`` value -- a number or a parameter-expression, ADR-0062); ``val`` stays a *string*, not a float. * ``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). * a bare parameter id -> a **parameter reference** ``var = <free param>`` (a per-condition estimated initial condition, ADR-0076): when the id is an estimated parameter (``free_names``) the ``val`` stays a *string* naming it (resolved from the PSet at apply time); when it is a fixed parameter (``fixed_params``) its numeric value is inlined (an absolute set). * anything else -> a multi-symbol ``targetValue`` expression for the deferred sympy layer. """ species_by_id = species_by_id or {} fixed_params = fixed_params or {} var = row.target_id value = row.target_value.strip() if var in species_by_id: return (species_by_id[var], '=', value) 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: pass # A non-numeric targetValue that names exactly one parameter is a per-condition estimated # initial condition (ADR-0076): bind the target to that parameter. A free parameter stays a # symbolic reference (val a string); a fixed one inlines its nominal value. if _BARE_IDENTIFIER.match(value): if value in free_names: return (var, '=', value) # a free-parameter reference (val stays a STRING) if value in fixed_params: return (var, '=', fixed_params[value]) # a fixed parameter -> its numeric value inlined raise NotImplementedError( f"Condition targetValue {row.target_value!r} for '{var}' is an expression, not a base " f"pin, a surrogate relative op, a number, or a single parameter reference (ADR-0076). " f"A multi-symbol condition formula 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_mapping_table(path): """Read a PEtab v2 mapping table into :class:`PetabMappingRow` records (stdlib ``csv``). Columns ``petabEntityId`` / ``modelEntityId``; the ``modelEntityId`` is kept as its raw string (a BNGL species pattern, not an identifier). A row missing a ``petabEntityId`` raises.""" with open(path, newline='') as fh: reader = csv.DictReader(fh, delimiter='\t') rows = [] for rec in reader: pid = (rec.get('petabEntityId') or '').strip() mid = (rec.get('modelEntityId') or '').strip() if not pid: raise PybnfError("PEtab mapping row is missing a petabEntityId.") rows.append(PetabMappingRow(pid, mid)) 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)
[docs] def write_mapping_table(rows, path): """Write mapping ``rows`` to ``path`` as a PEtab v2 mapping table (``petabEntityId`` -> ``modelEntityId``), the species-pattern alias of the species-amount condition targets (#477).""" records = [[r.petab_id, r.model_id] for r in rows] write_tsv(path, _MAPPING_COLUMNS, records)