Skip to content

Commit 32037ac

Browse files
committed
add option to convert model assignments to experiments in v1 to v2 converter
1 parent 2610499 commit 32037ac

2 files changed

Lines changed: 363 additions & 7 deletions

File tree

‎petab/v2/petab1to2.py‎

Lines changed: 143 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,20 @@
55
import re
66
import shutil
77
import warnings
8+
from collections import defaultdict
89
from contextlib import suppress
910
from pathlib import Path
1011
from tempfile import TemporaryDirectory
1112
from urllib.parse import urlparse
1213
from uuid import uuid4
1314

1415
import pandas as pd
16+
import sympy as sp
1517
from pandas.io.common import get_handle, is_url
18+
from sbmlmath import TimeSymbol, sbml_math_to_sympy
1619

1720
from .. import v1, v2
18-
from ..v1.math import sympify_petab
21+
from ..v1.math import petab_math_str, sympify_petab
1922
from ..v1.yaml import get_path_prefix, load_yaml, validate
2023
from ..versions import get_major_version
2124
from .models import MODEL_TYPE_SBML
@@ -24,7 +27,9 @@
2427

2528

2629
def petab1to2(
27-
yaml_config: Path | str, output_dir: Path | str | None = None
30+
yaml_config: Path | str,
31+
output_dir: Path | str | None = None,
32+
assignments_to_experiments: bool = False,
2833
) -> v2.Problem | None:
2934
"""Convert from PEtab 1.0 to PEtab 2.0 format.
3035
@@ -46,27 +51,39 @@ def petab1to2(
4651
:param output_dir:
4752
The output directory to save the converted PEtab problem, or ``None``,
4853
to return a :class:`petab.v2.Problem` instance.
54+
:param assignments_to_experiments:
55+
Whether to replace time-dependent piecewise assignments in the model,
56+
such as ``k := dose * piecewise(0, time < t_dose, 1)``, by
57+
experiment periods and conditions.
4958
5059
:raises ValueError:
5160
If the input is invalid or does not pass linting or if the generated
5261
files do not pass linting.
5362
"""
5463
if output_dir is not None:
55-
return petab_files_1to2(yaml_config, output_dir)
64+
return petab_files_1to2(
65+
yaml_config, output_dir, assignments_to_experiments
66+
)
5667

5768
with TemporaryDirectory() as tmp_dir:
58-
petab_files_1to2(yaml_config, tmp_dir)
69+
petab_files_1to2(yaml_config, tmp_dir, assignments_to_experiments)
5970
return v2.Problem.from_yaml(Path(tmp_dir, Path(yaml_config).name))
6071

6172

62-
def petab_files_1to2(yaml_config: Path | str | dict, output_dir: Path | str):
73+
def petab_files_1to2(
74+
yaml_config: Path | str | dict,
75+
output_dir: Path | str,
76+
assignments_to_experiments: bool = False,
77+
):
6378
"""Convert PEtab files from PEtab 1.0 to PEtab 2.0.
6479
6580
6681
:param yaml_config:
6782
The PEtab problem as dictionary or YAML file name.
6883
:param output_dir:
6984
The output directory to save the converted PEtab problem.
85+
:param assignments_to_experiments:
86+
See :func:`petab1to2`.
7087
7188
:raises ValueError:
7289
If the input is invalid or does not pass linting or if the generated
@@ -96,6 +113,11 @@ def petab_files_1to2(yaml_config: Path | str | dict, output_dir: Path | str):
96113
)
97114
if v1.lint_problem(petab_problem):
98115
raise ValueError("Provided PEtab problem does not pass linting.")
116+
new_periods = (
117+
_assignments_to_periods(petab_problem)
118+
if assignments_to_experiments
119+
else {}
120+
)
99121

100122
output_dir = Path(output_dir)
101123

@@ -115,7 +137,10 @@ def petab_files_1to2(yaml_config: Path | str | dict, output_dir: Path | str):
115137
for file in (
116138
model.location for model in new_yaml_config.model_files.values()
117139
):
118-
_copy_file(get_src_path(file), Path(get_dest_path(file)))
140+
if new_periods:
141+
petab_problem.model.to_file(get_dest_path(file))
142+
else:
143+
_copy_file(get_src_path(file), Path(get_dest_path(file)))
119144

120145
# Update observable table
121146
for observable_file in new_yaml_config.observable_files:
@@ -128,6 +153,8 @@ def petab_files_1to2(yaml_config: Path | str | dict, output_dir: Path | str):
128153
# Update condition table
129154
for condition_file in new_yaml_config.condition_files:
130155
condition_df = v1.get_condition_df(get_src_path(condition_file))
156+
if new_periods:
157+
condition_df = petab_problem.condition_df
131158
condition_df = v1v2_condition_df(condition_df, petab_problem.model)
132159
v2.write_condition_df(condition_df, get_dest_path(condition_file))
133160

@@ -145,7 +172,11 @@ def create_experiment_id(sim_cond_id: str, preeq_cond_id: str) -> str:
145172
preeq_cond_id
146173
and petab_problem.condition_df.loc[preeq_cond_id].notna().any()
147174
)
148-
if not sim_cond_exists and not preeq_cond_exists:
175+
if (
176+
not sim_cond_exists
177+
and not preeq_cond_exists
178+
and (sim_cond_id, preeq_cond_id) not in new_periods
179+
):
149180
# if we have only all-NaN conditions, we don't create a new
150181
# experiment
151182
return ""
@@ -189,6 +220,16 @@ def create_experiment_id(sim_cond_id: str, preeq_cond_id: str) -> str:
189220
v2.C.CONDITION_ID: sim_cond_id,
190221
}
191222
)
223+
experiments.extend(
224+
{v2.C.EXPERIMENT_ID: exp_id, v2.C.TIME: t, v2.C.CONDITION_ID: c}
225+
for t, c in new_periods.get((sim_cond_id, preeq_cond_id), [])
226+
)
227+
if new_periods:
228+
# drop periods of conditions whose changes were all replaced
229+
has_changes = petab_problem.condition_df.notna().any(axis=1)
230+
experiments = [
231+
e for e in experiments if has_changes[e[v2.C.CONDITION_ID]]
232+
]
192233
if experiments:
193234
exp_table_path = output_dir / "experiments.tsv"
194235
if exp_table_path.exists():
@@ -339,6 +380,101 @@ def _copy_file(src: Path | str, dest: Path):
339380
shutil.copy(str(src), str(dest))
340381

341382

383+
def _assignments_to_periods(
384+
problem: v1.Problem,
385+
) -> dict[tuple[str, str], list[tuple[float, str]]]:
386+
"""Replace time-dependent piecewise assignments by experiment periods.
387+
388+
Assignment rules and initial assignments containing
389+
``piecewise(before, time < t_switch, after)`` are removed from the model,
390+
which keeps the value before the switch. The condition table columns they
391+
depend on are replaced by new conditions that set the assignment target
392+
at the start of the respective periods.
393+
394+
:returns: The new ``(time, condition ID)`` periods for each measured
395+
``(simulation condition ID, preequilibration condition ID)`` pair.
396+
"""
397+
sbml_model = problem.model.sbml_model
398+
condition_df = problem.condition_df
399+
overrides = {
400+
cid: row.dropna().to_dict() for cid, row in condition_df.iterrows()
401+
}
402+
pairs = list(
403+
problem.get_simulation_conditions_from_measurement_df()
404+
.reindex(
405+
columns=[
406+
v1.C.SIMULATION_CONDITION_ID,
407+
v1.C.PREEQUILIBRATION_CONDITION_ID,
408+
],
409+
fill_value="",
410+
)
411+
.itertuples(index=False)
412+
)
413+
parameter_ids = set(problem.parameter_df.index)
414+
periods = defaultdict(list)
415+
# (target ID, target value) -> new condition ID
416+
new_conditions = {}
417+
replaced_columns = set()
418+
419+
for assignment in [
420+
*(r for r in sbml_model.getListOfRules() if r.isAssignment()),
421+
*sbml_model.getListOfInitialAssignments(),
422+
]:
423+
expr = sbml_math_to_sympy(assignment)
424+
pws = [pw for pw in expr.atoms(sp.Piecewise) if pw.has(TimeSymbol)]
425+
if not pws:
426+
continue
427+
(pw,) = pws
428+
(time,) = pw.atoms(TimeSymbol)
429+
(before, switch), _ = pw.args
430+
t_switch = sp.solve(switch.lhs - switch.rhs, time)[0]
431+
432+
target_id = assignment.getId()
433+
target = sbml_model.getParameter(target_id)
434+
target.setValue(float(expr.subs(pw, before)))
435+
target.setConstant(False)
436+
assignment.removeFromParentAndDelete()
437+
replaced_columns |= {str(s) for s in expr.free_symbols}
438+
439+
for sim_id, preeq_id in pairs:
440+
t_sim = float(t_switch.subs(overrides[sim_id]))
441+
values = {
442+
t: expr.subs(overrides[sim_id]).subs(time, t)
443+
for t in (0.0, t_sim)
444+
}
445+
if preeq_id:
446+
values[v2.C.TIME_PREEQUILIBRATION] = expr.subs(
447+
overrides[preeq_id]
448+
).subs(time, 0)
449+
elif {str(s) for s in values[t_sim].free_symbols} <= parameter_ids:
450+
# the model already has the pre-switch value, and the switch
451+
# is valid as first period (only parameter table symbols)
452+
del values[0.0]
453+
for t, value in values.items():
454+
cond_id = new_conditions.setdefault(
455+
(target_id, value), f"cond_{len(new_conditions)}"
456+
)
457+
periods[sim_id, preeq_id].append((t, cond_id))
458+
459+
problem.condition_df = pd.concat(
460+
[
461+
condition_df.drop(columns=replaced_columns, errors="ignore"),
462+
pd.DataFrame.from_dict(
463+
{
464+
cid: {
465+
target_id: float(value)
466+
if value.is_number
467+
else petab_math_str(value)
468+
}
469+
for (target_id, value), cid in new_conditions.items()
470+
},
471+
orient="index",
472+
),
473+
]
474+
).rename_axis(v1.C.CONDITION_ID)
475+
return {pair: sorted(p) for pair, p in periods.items()}
476+
477+
342478
def v1v2_condition_df(
343479
condition_df: pd.DataFrame, model: v1.Model
344480
) -> pd.DataFrame:

0 commit comments

Comments
 (0)