Skip to content

Commit 1a83733

Browse files
FFroehlichclaude
andauthored
petab1to2: order placeholders by index, not as text (#532)
`petab1to2` sorted an observable's placeholders as text when building the v2 `observablePlaceholders` / `noisePlaceholders` columns. The v1 overrides in the measurement table are positional, so from 10 placeholders on (`observableParameter10_x` sorts before `observableParameter2_x`), the override values were silently assigned to the wrong placeholders. Changes: * Take the observable and noise placeholders from `v1.get_formula_placeholders`, the helper the v1 linter uses. It orders them by the index in their ID. * If an observable's placeholders are not numbered consecutively from 1 (e.g. only `observableParameter1_x` and `observableParameter3_x`), raise a `ValueError`. The positional overrides can't be mapped in that case. In `petab1to2`, v1 linting already rejects such problems ("Non-consecutive numbering of placeholder parameter"). The new check covers direct calls to `v1v2_observable_df`. Tests: * `test_petab1to2_placeholder_order`: converts a problem with 12 observable placeholders and 11 noise placeholders, and checks that each placeholder gets its own override value. * `test_v1v2_observable_df_placeholder_gap`: checks that a skipped index raises the error, for observable and noise placeholders. The repro from the issue now prints `observableParameter<i>_obs_B = i` for all 11 placeholders. Closes #531 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 921ce8e commit 1a83733

2 files changed

Lines changed: 119 additions & 18 deletions

File tree

‎petab/v2/petab1to2.py‎

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

33
from __future__ import annotations
44

5-
import re
65
import shutil
76
import warnings
87
from contextlib import suppress
@@ -15,7 +14,6 @@
1514
from pandas.io.common import get_handle, is_url
1615

1716
from .. import v1, v2
18-
from ..v1.math import sympify_petab
1917
from ..v1.yaml import get_path_prefix, load_yaml
2018
from ..v1.yaml import validate as validate_yaml
2119
from ..versions import get_major_version
@@ -445,23 +443,19 @@ def extract_placeholders(row: pd.Series, type_: str) -> str:
445443
if pd.isna(formula):
446444
return ""
447445

448-
t = f"{re.escape(type_)}Parameter"
449-
o = re.escape(row[v1.C.OBSERVABLE_ID])
450-
451-
pattern = re.compile(rf"(?:^|\W)({t}\d+_{o})(?=\W|$)")
452-
453-
expr = sympify_petab(formula)
454-
# for 10+ placeholders, the current lexicographical sorting will result
455-
# in incorrect ordering of the placeholder IDs, so that they don't
456-
# align with the overrides in the measurement table, but who does
457-
# that anyway?
458-
return v2.C.PARAMETER_SEPARATOR.join(
459-
sorted(
460-
str(sym)
461-
for sym in expr.free_symbols
462-
if sym.is_Symbol and pattern.match(str(sym))
446+
# v1 overrides are positional: the i-th override replaces
447+
# `{type_}Parameter{i}_{observableId}`. This returns the placeholders
448+
# ordered by index and fails if they are not numbered 1..n.
449+
try:
450+
placeholders = v1.get_formula_placeholders(
451+
formula, row[v1.C.OBSERVABLE_ID], type_
463452
)
464-
)
453+
except AssertionError as e:
454+
raise ValueError(
455+
f"Cannot convert the {type_} placeholders of observable "
456+
f"`{row[v1.C.OBSERVABLE_ID]}': {e}"
457+
) from e
458+
return v2.C.PARAMETER_SEPARATOR.join(placeholders)
465459

466460
df[v2.C.OBSERVABLE_PLACEHOLDERS] = df.apply(
467461
extract_placeholders, args=("observable",), axis=1

‎tests/v2/test_conversion.py‎

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,113 @@ def test_v1v2_observable_df_noise_distribution():
3131
]
3232

3333

34+
def test_petab1to2_placeholder_order(tmp_path):
35+
"""Test that placeholders are ordered by index, not as text, so that the
36+
positional overrides still match for 10+ placeholders."""
37+
obs_id = "obs_B"
38+
n_obs, n_noise = 12, 11
39+
observable_df = pd.DataFrame(
40+
data={
41+
v1.C.OBSERVABLE_ID: [obs_id],
42+
v1.C.OBSERVABLE_FORMULA: [
43+
" + ".join(
44+
f"observableParameter{i}_{obs_id} * B^{i}"
45+
for i in range(1, n_obs + 1)
46+
)
47+
],
48+
v1.C.NOISE_FORMULA: [
49+
" + ".join(
50+
f"noiseParameter{i}_{obs_id}"
51+
for i in range(1, n_noise + 1)
52+
)
53+
],
54+
}
55+
).set_index(v1.C.OBSERVABLE_ID)
56+
# the i-th observable override is i, the i-th noise override is 100 + i
57+
measurement_df = pd.DataFrame(
58+
data={
59+
v1.C.OBSERVABLE_ID: [obs_id, obs_id],
60+
v1.C.SIMULATION_CONDITION_ID: ["c0", "c0"],
61+
v1.C.TIME: [1.0, 2.0],
62+
v1.C.MEASUREMENT: [0.3, 0.5],
63+
v1.C.OBSERVABLE_PARAMETERS: ";".join(
64+
str(i) for i in range(1, n_obs + 1)
65+
),
66+
v1.C.NOISE_PARAMETERS: ";".join(
67+
str(100 + i) for i in range(1, n_noise + 1)
68+
),
69+
}
70+
)
71+
condition_df = pd.DataFrame(
72+
data={v1.C.CONDITION_ID: ["c0"], "A": [1.0]}
73+
).set_index(v1.C.CONDITION_ID)
74+
parameter_df = pd.DataFrame(
75+
data={
76+
v1.C.PARAMETER_ID: ["k1"],
77+
v1.C.PARAMETER_SCALE: [v1.C.LIN],
78+
v1.C.LOWER_BOUND: [1e-3],
79+
v1.C.UPPER_BOUND: [1e3],
80+
v1.C.NOMINAL_VALUE: [0.5],
81+
v1.C.ESTIMATE: [1],
82+
}
83+
).set_index(v1.C.PARAMETER_ID)
84+
v1_problem = v1.Problem(
85+
model=v1.models.sbml_model.SbmlModel.from_antimony(
86+
"A -> B; k1 * A; A = 1; B = 0; k1 = 0.5"
87+
),
88+
condition_df=condition_df,
89+
measurement_df=measurement_df,
90+
parameter_df=parameter_df,
91+
observable_df=observable_df,
92+
)
93+
94+
problem = petab1to2(v1_problem.to_files_generic(tmp_path))
95+
96+
observable = problem[obs_id]
97+
assert len(problem.measurements) == 2
98+
for measurement in problem.measurements:
99+
assert {
100+
str(placeholder): float(override)
101+
for placeholder, override in zip(
102+
observable.observable_placeholders,
103+
measurement.observable_parameters,
104+
strict=True,
105+
)
106+
} == {
107+
f"observableParameter{i}_{obs_id}": i for i in range(1, n_obs + 1)
108+
}
109+
assert {
110+
str(placeholder): float(override)
111+
for placeholder, override in zip(
112+
observable.noise_placeholders,
113+
measurement.noise_parameters,
114+
strict=True,
115+
)
116+
} == {
117+
f"noiseParameter{i}_{obs_id}": 100 + i
118+
for i in range(1, n_noise + 1)
119+
}
120+
121+
122+
@pytest.mark.parametrize("type_", ["observable", "noise"])
123+
def test_v1v2_observable_df_placeholder_gap(type_):
124+
"""Test that non-consecutively numbered placeholders are rejected,
125+
because v1 overrides are positional."""
126+
formula = f"{type_}Parameter1_obs1 + {type_}Parameter3_obs1"
127+
observable_df = pd.DataFrame(
128+
data={
129+
v1.C.OBSERVABLE_ID: ["obs1"],
130+
v1.C.OBSERVABLE_FORMULA: [
131+
formula if type_ == "observable" else "a"
132+
],
133+
v1.C.NOISE_FORMULA: [formula if type_ == "noise" else "1"],
134+
}
135+
).set_index(v1.C.OBSERVABLE_ID)
136+
137+
with pytest.raises(ValueError, match="Non-consecutive numbering"):
138+
v1v2_observable_df(observable_df)
139+
140+
34141
def test_petab1to2_remote():
35142
"""Test that we can upgrade a remote PEtab 1.0.0 problem."""
36143
yaml_url = (

0 commit comments

Comments
 (0)