Skip to content

Commit e660eb1

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-531-placeholder-sort-5c5d23
# Conflicts: # petab/v2/petab1to2.py
2 parents bfaf9e4 + 921ce8e commit e660eb1

2 files changed

Lines changed: 245 additions & 38 deletions

File tree

‎petab/v2/petab1to2.py‎

Lines changed: 48 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,19 @@
1414
from pandas.io.common import get_handle, is_url
1515

1616
from .. import v1, v2
17-
from ..v1.yaml import get_path_prefix, load_yaml, validate
17+
from ..v1.yaml import get_path_prefix, load_yaml
18+
from ..v1.yaml import validate as validate_yaml
1819
from ..versions import get_major_version
1920
from .models import MODEL_TYPE_SBML
2021

2122
__all__ = ["petab1to2"]
2223

2324

2425
def petab1to2(
25-
yaml_config: Path | str, output_dir: Path | str | None = None
26+
yaml_config: Path | str,
27+
output_dir: Path | str | None = None,
28+
*,
29+
validate: bool = True,
2630
) -> v2.Problem | None:
2731
"""Convert from PEtab 1.0 to PEtab 2.0 format.
2832
@@ -44,31 +48,45 @@ def petab1to2(
4448
:param output_dir:
4549
The output directory to save the converted PEtab problem, or ``None``,
4650
to return a :class:`petab.v2.Problem` instance.
51+
:param validate:
52+
Whether to lint the input PEtab v1 problem, and the
53+
output PEtab v2 problem.
54+
If ``True`` (default), both problems are validated.
55+
If ``False``, neither problem is validated.
56+
The ``yaml_config`` content is validated regardless.
4757
4858
:raises ValueError:
49-
If the input is invalid or does not pass linting or if the generated
50-
files do not pass linting.
59+
If the input is invalid or, if ``validate`` is ``True``, if the input
60+
does not pass linting or if the generated files do not pass linting.
5161
"""
5262
if output_dir is not None:
53-
return petab_files_1to2(yaml_config, output_dir)
63+
return petab_files_1to2(yaml_config, output_dir, validate=validate)
5464

5565
with TemporaryDirectory() as tmp_dir:
56-
petab_files_1to2(yaml_config, tmp_dir)
66+
petab_files_1to2(yaml_config, tmp_dir, validate=validate)
5767
return v2.Problem.from_yaml(Path(tmp_dir, Path(yaml_config).name))
5868

5969

60-
def petab_files_1to2(yaml_config: Path | str | dict, output_dir: Path | str):
70+
def petab_files_1to2(
71+
yaml_config: Path | str | dict,
72+
output_dir: Path | str,
73+
*,
74+
validate: bool = True,
75+
):
6176
"""Convert PEtab files from PEtab 1.0 to PEtab 2.0.
6277
6378
6479
:param yaml_config:
6580
The PEtab problem as dictionary or YAML file name.
6681
:param output_dir:
6782
The output directory to save the converted PEtab problem.
83+
:param validate:
84+
Whether to lint the PEtab v1 problem before and the generated
85+
PEtab v2 problem after the conversion. See :func:`petab1to2`.
6886
6987
:raises ValueError:
70-
If the input is invalid or does not pass linting or if the generated
71-
files do not pass linting.
88+
If the input is invalid or, if ``validate`` is ``True``, if the input
89+
does not pass linting or if the generated files do not pass linting.
7290
"""
7391
if isinstance(yaml_config, Path | str):
7492
yaml_file = str(yaml_config)
@@ -83,7 +101,7 @@ def petab_files_1to2(yaml_config: Path | str | dict, output_dir: Path | str):
83101
get_dest_path = lambda filename: f"{output_dir}/{filename}" # noqa: E731
84102

85103
# Validate the original PEtab problem
86-
validate(yaml_config, path_prefix=path_prefix)
104+
validate_yaml(yaml_config, path_prefix=path_prefix)
87105
if get_major_version(yaml_config) != 1:
88106
raise ValueError("PEtab problem is not version 1.")
89107
petab_problem = v1.Problem.from_yaml(yaml_file or yaml_config)
@@ -92,7 +110,7 @@ def petab_files_1to2(yaml_config: Path | str | dict, output_dir: Path | str):
92110
petab_problem.condition_df = petab_problem.condition_df.drop(
93111
columns=[v1.C.CONDITION_NAME], errors="ignore"
94112
)
95-
if v1.lint_problem(petab_problem):
113+
if validate and v1.lint_problem(petab_problem):
96114
raise ValueError("Provided PEtab problem does not pass linting.")
97115

98116
output_dir = Path(output_dir)
@@ -132,20 +150,23 @@ def petab_files_1to2(yaml_config: Path | str | dict, output_dir: Path | str):
132150
# records for the experiment table to be created
133151
experiments = []
134152

153+
def v2_condition_id(cond_id: str) -> str:
154+
"""Get the condition ID to use in the v2 experiment table.
155+
156+
Conditions without any changes (all-NaN rows) are not included in the
157+
v2 condition table. Periods applying such a condition get an empty
158+
condition ID ("use the model as is").
159+
"""
160+
if petab_problem.condition_df.loc[cond_id].notna().any():
161+
return cond_id
162+
return ""
163+
135164
def create_experiment_id(sim_cond_id: str, preeq_cond_id: str) -> str:
136165
if not sim_cond_id and not preeq_cond_id:
137166
return ""
138-
# check whether the conditions will exist in the v2 condition table
139-
sim_cond_exists = (
140-
petab_problem.condition_df.loc[sim_cond_id].notna().any()
141-
)
142-
preeq_cond_exists = (
143-
preeq_cond_id
144-
and petab_problem.condition_df.loc[preeq_cond_id].notna().any()
145-
)
146-
if not sim_cond_exists and not preeq_cond_exists:
147-
# if we have only all-NaN conditions, we don't create a new
148-
# experiment
167+
if not preeq_cond_id and not v2_condition_id(sim_cond_id):
168+
# without pre-equilibration and without any changes, there is
169+
# nothing to be described by an experiment
149170
return ""
150171

151172
if preeq_cond_id:
@@ -177,14 +198,14 @@ def create_experiment_id(sim_cond_id: str, preeq_cond_id: str) -> str:
177198
{
178199
v2.C.EXPERIMENT_ID: exp_id,
179200
v2.C.TIME: v2.C.TIME_PREEQUILIBRATION,
180-
v2.C.CONDITION_ID: preeq_cond_id,
201+
v2.C.CONDITION_ID: v2_condition_id(preeq_cond_id),
181202
}
182203
)
183204
experiments.append(
184205
{
185206
v2.C.EXPERIMENT_ID: exp_id,
186207
v2.C.TIME: 0,
187-
v2.C.CONDITION_ID: sim_cond_id,
208+
v2.C.CONDITION_ID: v2_condition_id(sim_cond_id),
188209
}
189210
)
190211
if experiments:
@@ -215,20 +236,6 @@ def create_experiment_id(sim_cond_id: str, preeq_cond_id: str) -> str:
215236
else:
216237
measurement_df[v1.C.PREEQUILIBRATION_CONDITION_ID] = ""
217238

218-
if (
219-
petab_problem.condition_df is not None
220-
and len(
221-
set(petab_problem.condition_df.columns) - {v1.C.CONDITION_NAME}
222-
)
223-
== 0
224-
):
225-
# we can't have "empty" conditions with no overrides in v2,
226-
# therefore, we drop the respective condition ID completely
227-
# TODO: or can we?
228-
# TODO: this needs to be checked condition-wise, not globally
229-
measurement_df[v1.C.SIMULATION_CONDITION_ID] = ""
230-
if v1.C.PREEQUILIBRATION_CONDITION_ID in measurement_df.columns:
231-
measurement_df[v1.C.PREEQUILIBRATION_CONDITION_ID] = ""
232239
# condition IDs to experiment IDs
233240
measurement_df.insert(
234241
0,
@@ -251,6 +258,9 @@ def create_experiment_id(sim_cond_id: str, preeq_cond_id: str) -> str:
251258
new_yaml_file = output_dir / Path(yaml_file).name
252259
new_yaml_config.to_yaml(new_yaml_file)
253260

261+
if not validate:
262+
return
263+
254264
# validate updated Problem
255265
validation_issues = v2.lint_problem(new_yaml_file)
256266

‎tests/v2/test_conversion.py‎

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import logging
2+
from pathlib import Path
23

34
import pandas as pd
45
import pytest
56

67
from petab import v1, v2
8+
from petab.v1.models.sbml_model import SbmlModel
79
from petab.v2 import Problem
810
from petab.v2.petab1to2 import petab1to2, v1v2_observable_df
911

@@ -148,6 +150,201 @@ def test_petab1to2_remote():
148150
assert len(problem.measurements)
149151

150152

153+
def _petab1to2_with_conditions(
154+
tmp_path, condition_df: pd.DataFrame, conditions: list[tuple[str, str]]
155+
) -> Problem:
156+
"""Convert a minimal PEtab v1 problem with the given condition table.
157+
158+
``conditions`` are (preequilibrationConditionId, simulationConditionId)
159+
pairs, one measurement per pair.
160+
"""
161+
problem = v1.Problem(
162+
model=SbmlModel.from_antimony(
163+
"A = 1; B = 0; k1 = 0.5; A -> B; k1 * A"
164+
),
165+
condition_df=v1.get_condition_df(condition_df),
166+
observable_df=v1.get_observable_df(
167+
pd.DataFrame(
168+
{
169+
v1.C.OBSERVABLE_ID: ["obs_B"],
170+
v1.C.OBSERVABLE_FORMULA: ["B"],
171+
v1.C.NOISE_FORMULA: [0.1],
172+
}
173+
)
174+
),
175+
measurement_df=v1.get_measurement_df(
176+
pd.DataFrame(
177+
{
178+
v1.C.OBSERVABLE_ID: "obs_B",
179+
v1.C.PREEQUILIBRATION_CONDITION_ID: [
180+
preeq for preeq, _ in conditions
181+
],
182+
v1.C.SIMULATION_CONDITION_ID: [
183+
sim for _, sim in conditions
184+
],
185+
v1.C.TIME: 1.0,
186+
v1.C.MEASUREMENT: 0.5,
187+
}
188+
)
189+
),
190+
parameter_df=v1.get_parameter_df(
191+
pd.DataFrame(
192+
{
193+
v1.C.PARAMETER_ID: ["k1"],
194+
v1.C.PARAMETER_SCALE: [v1.C.LIN],
195+
v1.C.LOWER_BOUND: [1e-3],
196+
v1.C.UPPER_BOUND: [1e3],
197+
v1.C.NOMINAL_VALUE: [0.5],
198+
v1.C.ESTIMATE: [1],
199+
}
200+
)
201+
),
202+
)
203+
return petab1to2(problem.to_files_generic(tmp_path))
204+
205+
206+
def _measured_periods(
207+
problem: Problem,
208+
) -> list[list[tuple[float, list[str]]] | None]:
209+
"""The (time, conditionIds) of the experiment periods per measurement."""
210+
return [
211+
None
212+
if m.experiment_id is None
213+
else [
214+
(p.time, p.condition_ids) for p in problem[m.experiment_id].periods
215+
]
216+
for m in problem.measurements
217+
]
218+
219+
220+
def test_petab1to2_empty_conditions_preequilibration(tmp_path):
221+
"""Pre-equilibration is kept if the condition table sets nothing."""
222+
problem = _petab1to2_with_conditions(
223+
tmp_path,
224+
pd.DataFrame({v1.C.CONDITION_ID: ["c0", "steady"]}),
225+
[("steady", "c0")],
226+
)
227+
228+
assert problem.conditions == []
229+
assert _measured_periods(problem) == [
230+
[(v2.C.TIME_PREEQUILIBRATION, []), (0, [])]
231+
]
232+
assert problem.experiments[0].has_preequilibration
233+
234+
235+
def test_petab1to2_mixed_empty_conditions(tmp_path):
236+
"""Conditions that set nothing are handled condition-wise."""
237+
problem = _petab1to2_with_conditions(
238+
tmp_path,
239+
pd.DataFrame(
240+
{
241+
v1.C.CONDITION_ID: ["c0", "c1", "preeq_set", "preeq_empty"],
242+
"A": [1.0, None, 2.0, None],
243+
}
244+
),
245+
[
246+
("preeq_set", "c0"),
247+
("preeq_set", "c1"),
248+
("preeq_empty", "c0"),
249+
("preeq_empty", "c1"),
250+
("", "c0"),
251+
("", "c1"),
252+
],
253+
)
254+
255+
assert {c.id for c in problem.conditions} == {"c0", "preeq_set"}
256+
preeq = v2.C.TIME_PREEQUILIBRATION
257+
assert _measured_periods(problem) == [
258+
[(preeq, ["preeq_set"]), (0, ["c0"])],
259+
[(preeq, ["preeq_set"]), (0, [])],
260+
[(preeq, []), (0, ["c0"])],
261+
[(preeq, []), (0, [])],
262+
[(0, ["c0"])],
263+
# no pre-equilibration, no changes -> no experiment
264+
None,
265+
]
266+
267+
268+
def test_petab1to2_empty_conditions_no_preequilibration(tmp_path):
269+
"""Without pre-equilibration, conditions that set nothing don't need an
270+
experiment."""
271+
problem = _petab1to2_with_conditions(
272+
tmp_path,
273+
pd.DataFrame({v1.C.CONDITION_ID: ["c0"]}),
274+
[("", "c0")],
275+
)
276+
277+
assert problem.experiments == []
278+
assert _measured_periods(problem) == [None]
279+
280+
281+
def test_petab1to2_validate(tmp_path):
282+
"""Test that `validate=False` allows converting problems that fail
283+
linting for reasons unrelated to the conversion."""
284+
v1_problem = v1.Problem(
285+
model=SbmlModel.from_antimony(
286+
"A -> B; k1 * A; A = 1; B = 0; k1 = 0.5"
287+
),
288+
condition_df=v1.get_condition_df(
289+
pd.DataFrame({v1.C.CONDITION_ID: ["c0"], "A": [1.0]})
290+
),
291+
observable_df=v1.get_observable_df(
292+
pd.DataFrame(
293+
{
294+
v1.C.OBSERVABLE_ID: ["obs_B"],
295+
v1.C.OBSERVABLE_FORMULA: ["B"],
296+
v1.C.NOISE_FORMULA: [0.1],
297+
}
298+
)
299+
),
300+
measurement_df=v1.get_measurement_df(
301+
pd.DataFrame(
302+
{
303+
v1.C.OBSERVABLE_ID: ["obs_B", "obs_B"],
304+
v1.C.SIMULATION_CONDITION_ID: ["c0", "c0"],
305+
v1.C.TIME: [1.0, 2.0],
306+
v1.C.MEASUREMENT: [0.3, 0.5],
307+
v1.C.DATASET_ID: ["d1", "d1"],
308+
}
309+
)
310+
),
311+
parameter_df=v1.get_parameter_df(
312+
pd.DataFrame(
313+
{
314+
v1.C.PARAMETER_ID: ["k1"],
315+
v1.C.PARAMETER_SCALE: [v1.C.LIN],
316+
v1.C.LOWER_BOUND: [1e-3],
317+
v1.C.UPPER_BOUND: [1e3],
318+
v1.C.NOMINAL_VALUE: [0.5],
319+
v1.C.ESTIMATE: [1],
320+
}
321+
)
322+
),
323+
# refers to a dataset that is not in the measurement table
324+
visualization_df=pd.DataFrame(
325+
{
326+
v1.C.PLOT_ID: ["p1"],
327+
v1.C.DATASET_ID: ["no_such_dataset"],
328+
v1.C.X_VALUES: [v1.C.TIME],
329+
}
330+
),
331+
)
332+
yaml_file = v1_problem.to_files_generic(tmp_path / "v1")
333+
334+
with pytest.raises(ValueError, match="does not pass linting"):
335+
petab1to2(yaml_file)
336+
337+
problem = petab1to2(yaml_file, validate=False)
338+
assert isinstance(problem, Problem)
339+
assert len(problem.measurements) == 2
340+
341+
output_dir = tmp_path / "v2"
342+
output_dir.mkdir()
343+
petab1to2(yaml_file, output_dir, validate=False)
344+
problem = Problem.from_yaml(output_dir / Path(yaml_file).name)
345+
assert len(problem.measurements) == 2
346+
347+
151348
try:
152349
import benchmark_models_petab
153350

0 commit comments

Comments
 (0)