55import re
66import shutil
77import warnings
8+ from collections import defaultdict
89from contextlib import suppress
910from pathlib import Path
1011from tempfile import TemporaryDirectory
1112from urllib .parse import urlparse
1213from uuid import uuid4
1314
1415import pandas as pd
16+ import sympy as sp
1517from pandas .io .common import get_handle , is_url
18+ from sbmlmath import TimeSymbol , sbml_math_to_sympy
1619
1720from .. import v1 , v2
18- from ..v1 .math import sympify_petab
21+ from ..v1 .math import petab_math_str , sympify_petab
1922from ..v1 .yaml import get_path_prefix , load_yaml , validate
2023from ..versions import get_major_version
2124from .models import MODEL_TYPE_SBML
2427
2528
2629def 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+
342478def v1v2_condition_df (
343479 condition_df : pd .DataFrame , model : v1 .Model
344480) -> pd .DataFrame :
0 commit comments