Source code for pybnf.petab.measurements

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

PyBNF stores a dataset as a **wide** :class:`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 :class:`PetabMeasurementRow`
seam shared with the other PEtab tables:

* **export** -- ``measurement_rows_from_data`` (wide ``Data`` -> neutral rows) +
  ``write_measurement_table`` (rows -> TSV, the disposable half).
* **import** -- ``read_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``.
"""

import csv
import math
import re
from dataclasses import dataclass

import numpy as np

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

# A binding-table placeholder key (``noiseParameter1_obs_y`` / ``observableParameter2_obs_y``):
# its kind + 1-based index are what bind it to a measurements column, NOT the observableId
# suffix -- the importer keyed the sidecar to the source PEtab observableId, which the exporter
# regenerates with its own ``obs_``/``func_`` prefix, so the suffix can drift (ADR-0045).
_PLACEHOLDER_KEY = re.compile(r'(observable|noise)Parameter(\d+)_')


[docs] @dataclass(frozen=True) class PetabMeasurementRow: """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. """ 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 = ()
# --------------------------------------------------------------------------- # Asset: wide Data -> long PetabMeasurementRow records # ---------------------------------------------------------------------------
[docs] def measurement_rows_from_data(data, column_to_observable_id, experiment_id='', sd_suffix='_SD', model_id='', measurement_params=None): """Pivot one experiment's wide :class:`~pybnf.data.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 :class:`~pybnf.noise.PerMeasurementFormulaSigma` noise source); for ``observableParameter{n}_<id>`` the n-th token becomes the row's ``observableParameters`` tuple (a :class:`~pybnf.measurement.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). """ indvar = min(data.cols, key=data.cols.get) # column 0 if indvar.lower() != 'time': raise NotImplementedError( f"The exp file's independent variable is '{indvar}', not 'time': this is a " f"dose-response / parameter_scan dataset whose independent axis is a swept " f"parameter. In PEtab v2 that axis lives in the conditions/experiments tables " f"(each dose a separate experiment at steady state), not the measurement " f"table -- a later export chunk (ADR-0025, #407).") iv = data.cols[indvar] params = measurement_params or {} rows = [] for col, observable_id in column_to_observable_id.items(): ci = data.cols[col] suffix = sd_suffix.get(col) if isinstance(sd_suffix, dict) else sd_suffix sd_ci = None if suffix is None else data.cols.get(col + suffix) # Row-varying per-measurement tokens (ADR-0045), keyed by the data column: the noise # placeholder (one) and the observable placeholders (index order), each a {time: token} # map read at the row's time. Matched by placeholder kind+index, not the observableId # suffix, so a sidecar the importer keyed to the source PEtab observableId still resolves. noise_by_time, obs_by_time = _column_placeholder_series(params.get(col, {})) for i in range(data.data.shape[0]): value = data.data[i, ci] if np.isnan(value): continue t = float(data.data[i, iv]) noise = None if sd_ci is None else float(data.data[i, sd_ci]) noise_id = _token_at_time(noise_by_time, t) if noise_by_time else None obs_params = tuple(_token_at_time(d, t) for d in obs_by_time) rows.append(PetabMeasurementRow( observable_id=observable_id, time=t, measurement=float(value), experiment_id=experiment_id, model_id=model_id, noise_parameters=noise, noise_parameter_id=noise_id, observable_parameters=obs_params)) return rows
def _column_placeholder_series(col_params): """Split one sidecar column's ``{placeholder: {time: token}}`` into ``(noise_series, [obs_series, ...])`` (ADR-0045): the single ``noiseParameter{n}`` ``{time: token}`` map (or ``None``) and the ``observableParameter{n}`` maps in 1-based index order. Matched by the placeholder's **kind + index**, never its observableId suffix, so a sidecar the importer keyed to the source PEtab observableId resolves even after the exporter regenerates the observableId with its own ``obs_``/``func_`` prefix.""" noise, obs = None, {} for placeholder, by_time in col_params.items(): m = _PLACEHOLDER_KEY.match(placeholder) if m is None: continue if m.group(1) == 'noise': noise = by_time else: obs[int(m.group(2))] = by_time return noise, [obs[i] for i in sorted(obs)] def _token_at_time(by_time, t): """The per-measurement binding token for time ``t``: an exact float-key hit, else the closest key within a tight tolerance (the ``.exp`` and sidecar times share a source, so this only absorbs float-repr noise -- the export mirror of ``config._token_at_time``). Returns ``None`` if no key matches (the column simply carries no token at that row).""" if t in by_time: return by_time[t] for st, token in by_time.items(): if np.isclose(st, t, rtol=0, atol=1e-9): return token return None
[docs] def dose_response_measurement_rows(data, column_to_observable_id, experiment_ids, scan_time, sd_suffix='_SD', model_id=''): """Pivot a dose-response (swept-axis) wide :class:`~pybnf.data.Data` to long rows. The dual of :func:`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). """ rows = [] for col, observable_id in column_to_observable_id.items(): ci = data.cols[col] # sd_suffix is either one suffix for every column or a {column: suffix_or_None} map # (the per-observable-noise form -- ADR-0021/0045), mirroring the time-course pivot. suffix = sd_suffix.get(col) if isinstance(sd_suffix, dict) else sd_suffix sd_ci = None if suffix is None else data.cols.get(col + suffix) for i in range(data.data.shape[0]): value = data.data[i, ci] if np.isnan(value): continue noise = None if sd_ci is None else float(data.data[i, sd_ci]) rows.append(PetabMeasurementRow( observable_id=observable_id, time=float(scan_time), measurement=float(value), experiment_id=experiment_ids[i], model_id=model_id, noise_parameters=noise)) return rows
# --------------------------------------------------------------------------- # Import: long PetabMeasurementRow records -> wide Data (the reverse asset) # ---------------------------------------------------------------------------
[docs] def data_from_measurement_rows(rows, observable_id_to_column, sd_suffix='_SD', indvar='time'): """Pivot long measurement ``rows`` back to the wide :class:`~pybnf.data.Data` replicates per ``(experiment, model)`` -- the inverse of :func:`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 :class:`~pybnf.data.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. """ by_group = {} for row in rows: by_group.setdefault((row.experiment_id, row.model_id), []).append(row) return {key: [_wide_data_from_group(key[0], bucket, observable_id_to_column, sd_suffix, indvar) for bucket in _deal_replicates(group)] for key, group in by_group.items()}
def _deal_replicates(group): """Partition one experiment's rows into replicate buckets (ADR-0039). PEtab records replicates as repeated ``(observable, time)`` rows with no replicate index; deal the k-th occurrence of each cell into bucket k, preserving first-seen order. Bucket 0 is the full grid (it sees every cell first); a later bucket holds only the cells that repeat that many times. The inverse of the forward export, which emits one replicate ``Data`` after another, so re-exporting these buckets in order reproduces the source rows for a homogeneous-grid replicate set. """ buckets = [] seen = {} for row in group: k = seen.get((row.observable_id, row.time), 0) while k >= len(buckets): buckets.append([]) buckets[k].append(row) seen[(row.observable_id, row.time)] = k + 1 return buckets or [[]] def _wide_data_from_group(eid, group, observable_id_to_column, sd_suffix, indvar): """Pivot one replicate bucket's measurement rows to a wide :class:`~pybnf.data.Data`. The bucket is collision-free by construction (:func:`_deal_replicates` puts at most one measurement per ``(observable, time)`` cell in each bucket), so a single value lands in each cell. """ present = {row.observable_id for row in group} unknown = present - set(observable_id_to_column) if unknown: raise PybnfError( f"Measurement rows for experiment '{eid}' reference observable id(s) " f"{sorted(unknown)} that are absent from the observables table.") # Wide columns in observables-table order (so a re-export reproduces the order). columns = [observable_id_to_column[oid] for oid in observable_id_to_column if oid in present] column_of_id = {oid: observable_id_to_column[oid] for oid in present} times = sorted({row.time for row in group}) time_index = {t: i for i, t in enumerate(times)} has_noise = any(row.noise_parameters is not None for row in group) values = {col: [np.nan] * len(times) for col in columns} sds = {col: [np.nan] * len(times) for col in columns} if has_noise else None for row in group: col = column_of_id[row.observable_id] i = time_index[row.time] values[col][i] = row.measurement if has_noise and row.noise_parameters is not None: sds[col][i] = row.noise_parameters headers = [indvar] + columns data_columns = [times] + [values[col] for col in columns] if has_noise: headers += [col + sd_suffix for col in columns] data_columns += [sds[col] for col in columns] arr = np.array(data_columns, dtype=float).T return Data.from_columns(arr, headers, indvar=indvar) # --------------------------------------------------------------------------- # Import: dose-response reconstruction (the inverse of dose_response_measurement_rows) # --------------------------------------------------------------------------- # A dose-response experimentId stem: '<stem>_<i>' -> the original experiment name + the dose # index (the exporter's build_dose_response_conditions naming, build_dose_response_conditions). _DOSE_EID = re.compile(r'^(.+)_(\d+)$')
[docs] def reconstruct_dose_responses(measurement_rows, condition_rows, experiment_rows, observable_id_to_column, sd_suffix='_SD'): """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 :class:`~pybnf.data.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. """ by_cond = {} for row in condition_rows: by_cond.setdefault(row.condition_id, []).append((row.target_id, row.target_value)) def numeric_dose(cid): """The single numeric ``(target, value)`` a dose condition sets, or ``None``.""" targets = by_cond.get(cid, []) if len(targets) != 1: return None target_id, target_value = targets[0] try: return target_id, float(target_value) except (TypeError, ValueError): return None # a surrogate __REF expression etc -- not a dose condition_of = {row.experiment_id: row.condition_id for row in experiment_rows} by_group = {} for row in measurement_rows: by_group.setdefault((row.experiment_id, row.model_id), []).append(row) # Bucket dose-response points by (stem, modelId); record the consumed (eid, modelId) groups. buckets = {} consumed = set() for (eid, mid), rows in by_group.items(): times = {r.time for r in rows} if len(times) != 1: continue # a time course (more than one measured time) (scan_time,) = times dose = numeric_dose(condition_of.get(eid)) if dose is None: continue # no single-numeric-target condition -> not a dose swept_param, dose_value = dose m = _DOSE_EID.match(eid) if not math.isinf(scan_time) and m is None: continue # a finite single-time, non-<stem>_<i> group = a time course stem = m.group(1) if m else eid index = int(m.group(2)) if m else 0 buckets.setdefault((stem, mid), []).append( {'index': index, 'eid': eid, 'swept_param': swept_param, 'dose': dose_value, 'scan_time': scan_time, 'rows': rows}) consumed.add((eid, mid)) dose_responses = [] consumed_condition_ids = set() consumed_experiment_ids = set() for (stem, mid), points in buckets.items(): swept = {p['swept_param'] for p in points} scan_times = {p['scan_time'] for p in points} if len(swept) != 1 or len(scan_times) != 1: raise PybnfError( f"Dose-response experiment group '{stem}' is ambiguous: its experiments set " f"swept parameter(s) {sorted(swept)} at scan time(s) {sorted(scan_times)}. A " f"dose-response sweeps ONE parameter at ONE measurement time (ADR-0046).") swept_param = next(iter(swept)) scan_time = next(iter(scan_times)) points.sort(key=lambda p: (p['index'], p['dose'])) data = _dose_response_data(stem, swept_param, points, observable_id_to_column, sd_suffix) dose_responses.append({'name': stem, 'model_id': mid, 'swept_param': swept_param, 'scan_time': scan_time, 'data': data}) for p in points: consumed_experiment_ids.add(p['eid']) cid = condition_of.get(p['eid']) if cid: consumed_condition_ids.add(cid) remaining_rows = [row for row in measurement_rows if (row.experiment_id, row.model_id) not in consumed] return dose_responses, remaining_rows, consumed_condition_ids, consumed_experiment_ids
def _dose_response_data(stem, swept_param, points, observable_id_to_column, sd_suffix): """Pivot a dose-response group's per-dose rows to a wide swept-axis :class:`~pybnf.data.Data`. Column 0 is the swept parameter (its values the doses, in the points' order); then each measured observable's column in observables-table order (so a re-export reproduces it), with its ``<col><sd_suffix>`` companion when the rows carry ``noiseParameters``. Each point (one dose / one PEtab experiment) contributes one row.""" present = set() has_noise = False for p in points: for row in p['rows']: present.add(row.observable_id) if row.noise_parameters is not None: has_noise = True unknown = present - set(observable_id_to_column) if unknown: raise PybnfError( f"Dose-response '{stem}' references observable id(s) {sorted(unknown)} absent " f"from the observables table.") columns = [observable_id_to_column[oid] for oid in observable_id_to_column if oid in present] column_of_id = {oid: observable_id_to_column[oid] for oid in present} doses = [p['dose'] for p in points] values = {col: [np.nan] * len(points) for col in columns} sds = {col: [np.nan] * len(points) for col in columns} if has_noise else None for i, p in enumerate(points): for row in p['rows']: col = column_of_id[row.observable_id] values[col][i] = row.measurement if has_noise and row.noise_parameters is not None: sds[col][i] = row.noise_parameters headers = [swept_param] + columns data_columns = [doses] + [values[col] for col in columns] if has_noise: headers += [col + sd_suffix for col in columns] data_columns += [sds[col] for col in columns] arr = np.array(data_columns, dtype=float).T return Data.from_columns(arr, headers, indvar=swept_param) # --------------------------------------------------------------------------- # TSV reader (the disposable half of the seam) # ---------------------------------------------------------------------------
[docs] def read_measurement_table(path): """Read a PEtab v2 ``measurements.tsv`` into :class:`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. """ with open(path, newline='') as fh: reader = csv.DictReader(fh, delimiter='\t') return [_measurement_row_from_record(rec) for rec in reader]
def _measurement_row_from_record(rec): oid = rec.get('observableId') if oid is None or oid.strip() == '': raise PybnfError("PEtab measurements row is missing an observableId.") oid = oid.strip() numeric, param_id = _noise_parameters(rec.get('noiseParameters')) return PetabMeasurementRow( observable_id=oid, time=_require_float(rec.get('time'), 'time', oid), measurement=_require_float(rec.get('measurement'), 'measurement', oid), experiment_id=(rec.get('experimentId') or '').strip(), model_id=(rec.get('modelId') or '').strip(), noise_parameters=numeric, noise_parameter_id=param_id, observable_parameters=_observable_parameters(rec.get('observableParameters')), ) def _observable_parameters(s): """Split an ``observableParameters`` cell into its semicolon-delimited tokens (ADR-0044). The n-th token binds ``observableParameter${n}_${observableId}``; each is a number or a parameter id (classified at substitution time, not here). A blank/absent cell -> ``()``. """ if s is None or s.strip() == '': return () return tuple(tok.strip() for tok in s.split(';') if tok.strip()) def _require_float(s, column, oid): if s is None or s.strip() == '': raise PybnfError( f"PEtab measurement for observable '{oid}' is missing the '{column}' value.") return float(s) def _noise_parameters(s): """Split a ``noiseParameters`` cell into ``(numeric, parameter_id)``. Two forms occur in real v2 problems, and this read path now records both (ADR-0037): * a **number** -- the per-point standard deviation (the ``_SD`` cell a ``chi_sq`` re-export reads back) -> ``(value, None)``; * a **parameter id** -- a PEtab *placeholder override* substituted into the observable's declared noise placeholder per measurement (Boehm's ``sd_pSTAT5A_rel``) -> ``(None, id)``. When that id is constant across the observable's rows it *is* a per-observable estimated sigma (:func:`noise_parameter_ids_by_observable`, ADR-0021/0037); a genuinely per-measurement-varying id has no PyBNF analogue and is rejected downstream. A blank cell is ``(None, None)``. The reader only classifies the token here; the constant-per-observable check is cross-row and lives in the importer. """ if s is None or s.strip() == '': return None, None s = s.strip() try: return float(s), None except ValueError: return None, s def _classify_noise_ids(rows): """Split observables whose ``noiseParameters`` is a parameter id into ``(constant, row_varying)`` (ADR-0037/0044/0045). ``constant`` is ``{observable_id: parameter_id}`` for an id constant across the observable's rows (a per-observable estimated sigma, Phase 1); ``row_varying`` is the set of observable_ids whose id **differs** across rows (the per-measurement binding-table frontier, Phase 2). Raises ``NotImplementedError`` for an observable that **mixes** a parameter id with numeric per-point values across its rows -- a per-row source-*kind* change with no clean binding-table form yet (still deferred). """ ids, numeric = {}, set() for row in rows: if row.noise_parameter_id is not None: ids.setdefault(row.observable_id, set()).add(row.noise_parameter_id) elif row.noise_parameters is not None: numeric.add(row.observable_id) constant, row_varying = {}, set() for oid, id_set in ids.items(): if oid in numeric: raise NotImplementedError( f"Observable '{oid}' mixes a parameter-id noiseParameters placeholder " f"with numeric per-point values across its measurement rows -- a per-row " f"change of the noise *source kind* (id vs data column), which has no clean " f"per-measurement binding-table form and stays deferred (#428/ADR-0045).") if len(id_set) == 1: constant[oid] = next(iter(id_set)) else: row_varying.add(oid) return constant, row_varying
[docs] def noise_parameter_ids_by_observable(rows): """``{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 (:func:`row_varying_noise_ids` / :func:`measurement_param_bindings`, ADR-0045) and is excluded here. An id/numeric **mix** is still deferred (raises in :func:`_classify_noise_ids`). """ return _classify_noise_ids(rows)[0]
[docs] def row_varying_noise_ids(rows): """The set of ``observable_id``\\ s 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 (:func:`measurement_param_bindings`, ADR-0045). A constant-per-observable id (:func:`noise_parameter_ids_by_observable`) and a numeric / blank cell are absent. The complement of the constant map over the id-valued observables. """ return _classify_noise_ids(rows)[1]
[docs] def measurement_param_bindings(rows, observable_id_to_column, row_varying_noise=(), row_varying_obs=()): """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 :class:`~pybnf.noise.\ PerMeasurementFormulaSigma` source); * **observable** (``row_varying_obs``) -- the n-th ``observableParameters`` token binds ``observableParameter${n}_<observable_id>`` at that row's ``time`` (the :class:`~pybnf.measurement.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 :func:`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. """ table = {} for row in rows: key = (row.experiment_id, row.model_id) if row.observable_id in row_varying_noise and row.noise_parameter_id is not None: column = observable_id_to_column[row.observable_id] _bind(table, key, column, f'noiseParameter1_{row.observable_id}', row.time, row.noise_parameter_id) if row.observable_id in row_varying_obs and row.observable_parameters: column = observable_id_to_column[row.observable_id] for n, token in enumerate(row.observable_parameters, start=1): _bind(table, key, column, f'observableParameter{n}_{row.observable_id}', row.time, token) return table
def _bind(table, key, column, placeholder, time, token): """Record one ``(experiment, column, placeholder, time) -> token`` cell in the nested binding table (a small helper so the noise and observable arms share the insertion).""" (table.setdefault(key, {}).setdefault(column, {}) .setdefault(placeholder, {}))[time] = token def _classify_observable_params(rows): """Split observables that carry an ``observableParameters`` tuple into ``(constant, row_varying)`` (ADR-0044/0045) -- the observable-side sibling of :func:`_classify_noise_ids`. ``constant`` is ``{observable_id: (token, ...)}`` for an observable whose tuple is identical across all of its (non-blank) rows (a per-observable scale/offset substituted into the formula, Phase 1); ``row_varying`` is the set of observable_ids whose tuple **differs** across rows (the per-measurement binding-table frontier bound per data point, Phase 2). A tuple that varies between a value and a blank cell also routes to ``row_varying`` (a partial binding surfaces as a clean missing-token error at config load, not here). """ seen, nonblank = {}, set() for row in rows: seen.setdefault(row.observable_id, set()).add(row.observable_parameters) if row.observable_parameters: nonblank.add(row.observable_id) constant, row_varying = {}, set() for oid in nonblank: variants = seen[oid] if len(variants) == 1: constant[oid] = next(iter(variants)) else: row_varying.add(oid) return constant, row_varying
[docs] def observable_parameters_by_observable(rows): """``{observable_id: (token, ...)}`` for observables whose ``observableParameters`` tuple is constant across all of that observable's rows (ADR-0044) -- the sibling of :func:`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 (:func:`row_varying_observable_ids` / :func:`measurement_param_bindings`, ADR-0045) and is excluded here -- the observable side of the Phase-2 frontier that Phase 1 deferred. """ return _classify_observable_params(rows)[0]
[docs] def row_varying_observable_ids(rows): """The set of ``observable_id``\\ s whose ``observableParameters`` tuple **differs** across the observable's rows -- the row-varying observable scale/offset frontier bound per data point from the binding table (:func:`measurement_param_bindings`, ADR-0045). The observable-side sibling of :func:`row_varying_noise_ids`. A constant-per-observable tuple (:func:`observable_parameters_by_observable`) and a blank cell are absent. """ return _classify_observable_params(rows)[1]
# --------------------------------------------------------------------------- # Writer (the disposable half of the seam) # ---------------------------------------------------------------------------
[docs] def write_measurement_table(rows, path): """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. """ include_model = any(r.model_id for r in rows) include_obs_params = any(r.observable_parameters for r in rows) columns = ['observableId', 'experimentId'] if include_model: columns.append('modelId') columns += ['time', 'measurement'] if include_obs_params: columns.append('observableParameters') columns.append('noiseParameters') records = [] for r in rows: rec = [r.observable_id, r.experiment_id] if include_model: rec.append(r.model_id) rec += [num(r.time), num(r.measurement)] if include_obs_params: rec.append(';'.join(r.observable_parameters)) rec.append(_noise_cell(r)) records.append(rec) write_tsv(path, columns, records)
def _noise_cell(row): """The ``noiseParameters`` cell for one measurement row: a row-varying parameter-id token (the sidecar source, ADR-0045) takes precedence over the numeric ``_SD`` value; blank when neither is set.""" if row.noise_parameter_id is not None: return row.noise_parameter_id return num(row.noise_parameters)