Skip to content

Commit ef79523

Browse files
authored
v2: Update to new observable placeholder specification (#393)
Adapt to the changes in PEtab-dev/PEtab#625. Placeholders are now listed explicitly. Closes #390.
1 parent 3bc6777 commit ef79523

File tree

4 files changed

+82
-44
lines changed

4 files changed

+82
-44
lines changed

petab/v2/C.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,10 +145,14 @@
145145
OBSERVABLE_NAME = "observableName"
146146
#: Observable formula column in the observable table
147147
OBSERVABLE_FORMULA = "observableFormula"
148+
#: Observable placeholders column in the observable table
149+
OBSERVABLE_PLACEHOLDERS = "observablePlaceholders"
148150
#: Noise formula column in the observable table
149151
NOISE_FORMULA = "noiseFormula"
150152
#: Noise distribution column in the observable table
151153
NOISE_DISTRIBUTION = "noiseDistribution"
154+
#: Noise placeholders column in the observable table
155+
NOISE_PLACEHOLDERS = "noisePlaceholders"
152156

153157
#: Mandatory columns of observable table
154158
OBSERVABLE_DF_REQUIRED_COLS = [

petab/v2/core.py

Lines changed: 30 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,11 @@
22

33
from __future__ import annotations
44

5-
import re
65
from collections.abc import Sequence
76
from enum import Enum
87
from itertools import chain
98
from pathlib import Path
10-
from typing import Annotated, Literal
9+
from typing import Annotated
1110

1211
import numpy as np
1312
import pandas as pd
@@ -192,6 +191,14 @@ class Observable(BaseModel):
192191
noise_distribution: NoiseDistribution = Field(
193192
alias=C.NOISE_DISTRIBUTION, default=NoiseDistribution.NORMAL
194193
)
194+
#: Placeholder symbols for the observable formula.
195+
observable_placeholders: list[sp.Symbol] = Field(
196+
alias=C.OBSERVABLE_PLACEHOLDERS, default=[]
197+
)
198+
#: Placeholder symbols for the noise formula.
199+
noise_placeholders: list[sp.Symbol] = Field(
200+
alias=C.NOISE_PLACEHOLDERS, default=[]
201+
)
195202

196203
#: :meta private:
197204
model_config = ConfigDict(
@@ -221,37 +228,24 @@ def _sympify(cls, v):
221228

222229
return sympify_petab(v)
223230

224-
def _placeholders(
225-
self, type_: Literal["observable", "noise"]
226-
) -> set[sp.Symbol]:
227-
formula = (
228-
self.formula
229-
if type_ == "observable"
230-
else self.noise_formula
231-
if type_ == "noise"
232-
else None
233-
)
234-
if formula is None or formula.is_number:
235-
return set()
236-
237-
if not (free_syms := formula.free_symbols):
238-
return set()
231+
@field_validator(
232+
"observable_placeholders", "noise_placeholders", mode="before"
233+
)
234+
@classmethod
235+
def _sympify_id_list(cls, v):
236+
if v is None:
237+
return []
239238

240-
# TODO: add field validator to check for 1-based consecutive numbering
241-
t = f"{re.escape(type_)}Parameter"
242-
o = re.escape(self.id)
243-
pattern = re.compile(rf"(?:^|\W)({t}\d+_{o})(?=\W|$)")
244-
return {s for s in free_syms if pattern.match(str(s))}
239+
if isinstance(v, float) and np.isnan(v):
240+
return []
245241

246-
@property
247-
def observable_placeholders(self) -> set[sp.Symbol]:
248-
"""Placeholder symbols for the observable formula."""
249-
return self._placeholders("observable")
242+
if isinstance(v, str):
243+
v = v.split(C.PARAMETER_SEPARATOR)
244+
elif not isinstance(v, Sequence):
245+
v = [v]
250246

251-
@property
252-
def noise_placeholders(self) -> set[sp.Symbol]:
253-
"""Placeholder symbols for the noise formula."""
254-
return self._placeholders("noise")
247+
v = [pid.strip() for pid in v]
248+
return [sympify_petab(_valid_petab_id(pid)) for pid in v if pid]
255249

256250

257251
class ObservableTable(BaseModel):
@@ -289,6 +283,12 @@ def to_df(self) -> pd.DataFrame:
289283
noise = record[C.NOISE_FORMULA]
290284
record[C.OBSERVABLE_FORMULA] = petab_math_str(obs)
291285
record[C.NOISE_FORMULA] = petab_math_str(noise)
286+
record[C.OBSERVABLE_PLACEHOLDERS] = C.PARAMETER_SEPARATOR.join(
287+
map(str, record[C.OBSERVABLE_PLACEHOLDERS])
288+
)
289+
record[C.NOISE_PLACEHOLDERS] = C.PARAMETER_SEPARATOR.join(
290+
map(str, record[C.NOISE_PLACEHOLDERS])
291+
)
292292
return pd.DataFrame(records).set_index([C.OBSERVABLE_ID])
293293

294294
@classmethod

petab/v2/petab1to2.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import re
56
import shutil
67
from contextlib import suppress
78
from itertools import chain
@@ -14,6 +15,7 @@
1415
from pandas.io.common import get_handle, is_url
1516

1617
from .. import v1, v2
18+
from ..v1.math import sympify_petab
1719
from ..v1.yaml import get_path_prefix, load_yaml, validate
1820
from ..versions import get_major_version
1921
from .models import MODEL_TYPE_SBML
@@ -351,6 +353,7 @@ def v1v2_observable_df(observable_df: pd.DataFrame) -> pd.DataFrame:
351353
352354
Perform all updates that can be done solely on the observable table:
353355
* drop observableTransformation, update noiseDistribution
356+
* update placeholder parameters
354357
"""
355358
df = observable_df.copy().reset_index()
356359

@@ -388,6 +391,43 @@ def update_noise_dist(row):
388391
df[v2.C.NOISE_DISTRIBUTION] = df.apply(update_noise_dist, axis=1)
389392
df.drop(columns=[v1.C.OBSERVABLE_TRANSFORMATION], inplace=True)
390393

394+
def extract_placeholders(row: pd.Series, type_: str) -> str:
395+
"""Extract placeholders from observable formula."""
396+
if type_ == "observable":
397+
formula = row[v1.C.OBSERVABLE_FORMULA]
398+
elif type_ == "noise":
399+
formula = row[v1.C.NOISE_FORMULA]
400+
else:
401+
raise ValueError(f"Unknown placeholder type: {type_}")
402+
403+
if pd.isna(formula):
404+
return ""
405+
406+
t = f"{re.escape(type_)}Parameter"
407+
o = re.escape(row[v1.C.OBSERVABLE_ID])
408+
409+
pattern = re.compile(rf"(?:^|\W)({t}\d+_{o})(?=\W|$)")
410+
411+
expr = sympify_petab(formula)
412+
# for 10+ placeholders, the current lexicographical sorting will result
413+
# in incorrect ordering of the placeholder IDs, so that they don't
414+
# align with the overrides in the measurement table, but who does
415+
# that anyway?
416+
return v2.C.PARAMETER_SEPARATOR.join(
417+
sorted(
418+
str(sym)
419+
for sym in expr.free_symbols
420+
if sym.is_Symbol and pattern.match(str(sym))
421+
)
422+
)
423+
424+
df[v2.C.OBSERVABLE_PLACEHOLDERS] = df.apply(
425+
extract_placeholders, args=("observable",), axis=1
426+
)
427+
df[v2.C.NOISE_PLACEHOLDERS] = df.apply(
428+
extract_placeholders, args=("noise",), axis=1
429+
)
430+
391431
return df
392432

393433

tests/v2/test_core.py

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -160,28 +160,22 @@ def test_observable():
160160
assert Observable(id="obs1", formula="x + y", non_petab=1).non_petab == 1
161161

162162
o = Observable(id="obs1", formula=x + y)
163-
assert o.observable_placeholders == set()
164-
assert o.noise_placeholders == set()
163+
assert o.observable_placeholders == []
164+
assert o.noise_placeholders == []
165165

166166
o = Observable(
167167
id="obs1",
168168
formula="observableParameter1_obs1",
169169
noise_formula="noiseParameter1_obs1",
170+
observable_placeholders="observableParameter1_obs1",
171+
noise_placeholders="noiseParameter1_obs1",
170172
)
171-
assert o.observable_placeholders == {
173+
assert o.observable_placeholders == [
172174
sp.Symbol("observableParameter1_obs1", real=True),
173-
}
174-
assert o.noise_placeholders == {
175+
]
176+
assert o.noise_placeholders == [
175177
sp.Symbol("noiseParameter1_obs1", real=True)
176-
}
177-
178-
# TODO: this should raise an error
179-
# (numbering is not consecutive / not starting from 1)
180-
# TODO: clarify if observableParameter0_obs1 would be allowed
181-
# as regular parameter
182-
#
183-
# with pytest.raises(ValidationError):
184-
# Observable(id="obs1", formula="observableParameter2_obs1")
178+
]
185179

186180

187181
def test_change():

0 commit comments

Comments
 (0)