From b5c8dd0ca9344cd1cbeef94716d8fff860702e37 Mon Sep 17 00:00:00 2001 From: Daniel Weindl Date: Wed, 23 Jul 2025 11:32:41 +0200 Subject: [PATCH 1/3] v2: Store path info in *Table objects Store path info in *Table and Model objects to make it easier to read, modify, write complete PEtab problem. Add `Problem.to_files()`. Closes #412. --- petab/_utils.py | 35 +++++++ petab/v1/models/model.py | 28 ++++-- petab/v1/models/pysb_model.py | 31 ++++++- petab/v1/models/sbml_model.py | 19 +++- petab/v2/core.py | 169 +++++++++++++++++++++++++--------- tests/v2/test_core.py | 72 +++++++++++++-- 6 files changed, 283 insertions(+), 71 deletions(-) create mode 100644 petab/_utils.py diff --git a/petab/_utils.py b/petab/_utils.py new file mode 100644 index 00000000..808cebe7 --- /dev/null +++ b/petab/_utils.py @@ -0,0 +1,35 @@ +"""Private, version-independent utility functions for PEtab.""" + +from pathlib import Path + +from pydantic import AnyUrl, TypeAdapter + +PathOrUrlAdapter = TypeAdapter(AnyUrl | Path) + + +def _generate_path( + file_path: str | Path | AnyUrl, + base_path: Path | str | AnyUrl | None = None, +) -> str: + """ + Generate a local path or URL from a file path and an optional base path. + + :return: A string representing the relative or absolute path or URL. + Absolute if `file_path` or `base_path` is an absolute path or URL, + relative otherwise. + """ + if base_path is None: + return str(file_path) + + file_path = PathOrUrlAdapter.validate_python(file_path) + if isinstance(file_path, AnyUrl): + # if URL, this is absolute + return str(file_path) + + base_path = PathOrUrlAdapter.validate_python(base_path) + if isinstance(base_path, Path): + # if file_path is absolute, base_path will be ignored + return str(base_path / file_path) + + # combine URL parts + return f"{base_path}/{file_path}" diff --git a/petab/v1/models/model.py b/petab/v1/models/model.py index e25ca0b2..c11002d7 100644 --- a/petab/v1/models/model.py +++ b/petab/v1/models/model.py @@ -21,17 +21,22 @@ def __repr__(self): @staticmethod @abc.abstractmethod - def from_file(filepath_or_buffer: Any, model_id: str) -> Model: + def from_file( + filepath_or_buffer: Any, model_id: str, base_path: str | Path = None + ) -> Model: """Load the model from the given path/URL - :param filepath_or_buffer: URL or path of the model + :param filepath_or_buffer: + Absolute or relative path/URL to the model file. + If relative, it is interpreted relative to `base_path` if given. + :param base_path: Base path for relative paths in the model file. :param model_id: Model ID :returns: A ``Model`` instance holding the given model """ ... @abc.abstractmethod - def to_file(self, filename: [str, Path]): + def to_file(self, filename: str | Path | None = None): """Save the model to the given file :param filename: Destination filename @@ -131,11 +136,16 @@ def is_state_variable(self, id_: str) -> bool: def model_factory( - filepath_or_buffer: Any, model_language: str, model_id: str = None + filepath_or_buffer: Any, + model_language: str, + model_id: str = None, + base_path: str | Path = None, ) -> Model: """Create a PEtab model instance from the given model - :param filepath_or_buffer: Path/URL of the model + :param filepath_or_buffer: Path/URL of the model. + Absolute or relative to `base_path` if given. + :param base_path: Base path for relative paths in the model file. :param model_language: PEtab model language ID for the given model :param model_id: PEtab model ID for the given model :returns: A :py:class:`Model` instance representing the given model @@ -145,12 +155,16 @@ def model_factory( if model_language == MODEL_TYPE_SBML: from .sbml_model import SbmlModel - return SbmlModel.from_file(filepath_or_buffer, model_id=model_id) + return SbmlModel.from_file( + filepath_or_buffer, model_id=model_id, base_path=base_path + ) if model_language == MODEL_TYPE_PYSB: from .pysb_model import PySBModel - return PySBModel.from_file(filepath_or_buffer, model_id=model_id) + return PySBModel.from_file( + filepath_or_buffer, model_id=model_id, base_path=base_path + ) if model_language in known_model_types: raise NotImplementedError( diff --git a/petab/v1/models/pysb_model.py b/petab/v1/models/pysb_model.py index 0b69d797..1a615e0f 100644 --- a/petab/v1/models/pysb_model.py +++ b/petab/v1/models/pysb_model.py @@ -1,5 +1,7 @@ """Functions for handling PySB models""" +from __future__ import annotations + import itertools import re import sys @@ -9,6 +11,7 @@ import pysb +from ..._utils import _generate_path from .. import is_valid_identifier from . import MODEL_TYPE_PYSB from .model import Model @@ -54,9 +57,18 @@ class PySBModel(Model): type_id = MODEL_TYPE_PYSB - def __init__(self, model: pysb.Model, model_id: str = None): + def __init__( + self, + model: pysb.Model, + model_id: str = None, + rel_path: Path | str | None = None, + base_path: str | Path | None = None, + ): super().__init__() + self.rel_path = rel_path + self.base_path = base_path + self.model = model self._model_id = model_id or self.model.name @@ -68,16 +80,25 @@ def __init__(self, model: pysb.Model, model_id: str = None): ) @staticmethod - def from_file(filepath_or_buffer, model_id: str = None): + def from_file( + filepath_or_buffer, model_id: str = None, base_path: str | Path = None + ) -> PySBModel: return PySBModel( - model=_pysb_model_from_path(filepath_or_buffer), model_id=model_id + model=_pysb_model_from_path( + _generate_path(filepath_or_buffer, base_path) + ), + model_id=model_id, + rel_path=filepath_or_buffer, + base_path=base_path, ) - def to_file(self, filename: [str, Path]): + def to_file(self, filename: str | Path | None = None) -> None: from pysb.export import export model_source = export(self.model, "pysb_flat") - with open(filename, "w") as f: + with open( + filename or _generate_path(self.rel_path, self.base_path), "w" + ) as f: f.write(model_source) @property diff --git a/petab/v1/models/sbml_model.py b/petab/v1/models/sbml_model.py index c6957ca6..e6fffcca 100644 --- a/petab/v1/models/sbml_model.py +++ b/petab/v1/models/sbml_model.py @@ -10,6 +10,7 @@ import sympy as sp from sympy.abc import _clash +from ..._utils import _generate_path from ..sbml import ( get_sbml_model, is_sbml_consistent, @@ -33,6 +34,8 @@ def __init__( sbml_reader: libsbml.SBMLReader = None, sbml_document: libsbml.SBMLDocument = None, model_id: str = None, + rel_path: Path | str | None = None, + base_path: str | Path | None = None, ): """Constructor. @@ -42,6 +45,9 @@ def __init__( :param model_id: Model ID. Defaults to the SBML model ID.""" super().__init__() + self.rel_path = rel_path + self.base_path = base_path + if sbml_model is None and sbml_document is None: raise ValueError( "Either sbml_model or sbml_document must be given." @@ -87,15 +93,19 @@ def __setstate__(self, state): self.__dict__.update(state) @staticmethod - def from_file(filepath_or_buffer, model_id: str = None) -> SbmlModel: + def from_file( + filepath_or_buffer, model_id: str = None, base_path: str | Path = None + ) -> SbmlModel: sbml_reader, sbml_document, sbml_model = get_sbml_model( - filepath_or_buffer + _generate_path(filepath_or_buffer, base_path=base_path) ) return SbmlModel( sbml_model=sbml_model, sbml_reader=sbml_reader, sbml_document=sbml_document, model_id=model_id, + rel_path=filepath_or_buffer, + base_path=base_path, ) @staticmethod @@ -159,9 +169,10 @@ def model_id(self): def model_id(self, model_id): self._model_id = model_id - def to_file(self, filename: [str, Path]): + def to_file(self, filename: str | Path | None = None) -> None: write_sbml( - self.sbml_document or self.sbml_model.getSBMLDocument(), filename + self.sbml_document or self.sbml_model.getSBMLDocument(), + filename or _generate_path(self.rel_path, self.base_path), ) def get_parameter_value(self, id_: str) -> float: diff --git a/petab/v2/core.py b/petab/v2/core.py index 6b09c679..93d1c901 100644 --- a/petab/v2/core.py +++ b/petab/v2/core.py @@ -2,6 +2,7 @@ from __future__ import annotations +import copy import logging import os import tempfile @@ -32,6 +33,7 @@ ) from typing_extensions import Self +from .._utils import _generate_path from ..v1 import ( validate_yaml_syntax, yaml, @@ -212,13 +214,21 @@ class PriorDistribution(str, Enum): class BaseTable(BaseModel, Generic[T]): """Base class for PEtab tables.""" + #: The table elements elements: list[T] - - def __init__(self, elements: list[T] = None) -> None: + #: The path to the table file, if applicable. + #: Relative to the base path, if the base path is set and rel_path is not + #: an absolute path. + rel_path: AnyUrl | Path | None = Field(exclude=True, default=None) + #: The base path for the table file, if applicable. + #: This is usually the directory of the PEtab YAML file. + base_path: AnyUrl | Path | None = Field(exclude=True, default=None) + + def __init__(self, elements: list[T] = None, **kwargs) -> None: """Initialize the BaseTable with a list of elements.""" if elements is None: elements = [] - super().__init__(elements=elements) + super().__init__(elements=elements, **kwargs) def __getitem__(self, id_: str) -> T: """Get an element by ID. @@ -252,16 +262,20 @@ def to_df(self) -> pd.DataFrame: pass @classmethod - def from_tsv(cls, file_path: str | Path) -> BaseTable[T]: + def from_tsv( + cls, file_path: str | Path, base_path: str | Path | None = None + ) -> BaseTable[T]: """Create table from a TSV file.""" - df = pd.read_csv(file_path, sep="\t") - return cls.from_df(df) + df = pd.read_csv(_generate_path(file_path, base_path), sep="\t") + return cls.from_df(df, rel_path=file_path, base_path=base_path) - def to_tsv(self, file_path: str | Path) -> None: + def to_tsv(self, file_path: str | Path = None) -> None: """Write the table to a TSV file.""" df = self.to_df() df.to_csv( - file_path, sep="\t", index=not isinstance(df.index, pd.RangeIndex) + file_path or _generate_path(self.rel_path, self.base_path), + sep="\t", + index=not isinstance(df.index, pd.RangeIndex), ) @classmethod @@ -375,18 +389,17 @@ def observables(self) -> list[Observable]: return self.elements @classmethod - def from_df(cls, df: pd.DataFrame) -> ObservableTable: + def from_df(cls, df: pd.DataFrame, **kwargs) -> ObservableTable: """Create an ObservableTable from a DataFrame.""" if df is None: - return cls() + return cls(**kwargs) df = get_observable_df(df) observables = [ Observable(**row.to_dict()) for _, row in df.reset_index().iterrows() ] - - return cls(observables) + return cls(observables, **kwargs) def to_df(self) -> pd.DataFrame: """Convert the ObservableTable to a DataFrame.""" @@ -500,17 +513,17 @@ def conditions(self) -> list[Condition]: return self.elements @classmethod - def from_df(cls, df: pd.DataFrame) -> ConditionTable: + def from_df(cls, df: pd.DataFrame, **kwargs) -> ConditionTable: """Create a ConditionTable from a DataFrame.""" if df is None or df.empty: - return cls() + return cls(**kwargs) conditions = [] for condition_id, sub_df in df.groupby(C.CONDITION_ID): changes = [Change(**row) for row in sub_df.to_dict("records")] conditions.append(Condition(id=condition_id, changes=changes)) - return cls(conditions) + return cls(conditions, **kwargs) def to_df(self) -> pd.DataFrame: """Convert the ConditionTable to a DataFrame.""" @@ -650,10 +663,10 @@ def experiments(self) -> list[Experiment]: return self.elements @classmethod - def from_df(cls, df: pd.DataFrame) -> ExperimentTable: + def from_df(cls, df: pd.DataFrame, **kwargs) -> ExperimentTable: """Create an ExperimentTable from a DataFrame.""" if df is None: - return cls() + return cls(**kwargs) experiments = [] for experiment_id, cur_exp_df in df.groupby(C.EXPERIMENT_ID): @@ -668,12 +681,13 @@ def from_df(cls, df: pd.DataFrame) -> ExperimentTable: ] periods.append( ExperimentPeriod( - time=timepoint, condition_ids=condition_ids + time=timepoint, + condition_ids=condition_ids, ) ) experiments.append(Experiment(id=experiment_id, periods=periods)) - return cls(experiments) + return cls(experiments, **kwargs) def to_df(self) -> pd.DataFrame: """Convert the ExperimentTable to a DataFrame.""" @@ -778,13 +792,10 @@ def measurements(self) -> list[Measurement]: return self.elements @classmethod - def from_df( - cls, - df: pd.DataFrame, - ) -> MeasurementTable: + def from_df(cls, df: pd.DataFrame, **kwargs) -> MeasurementTable: """Create a MeasurementTable from a DataFrame.""" if df is None: - return cls() + return cls(**kwargs) if C.MODEL_ID in df.columns: df[C.MODEL_ID] = df[C.MODEL_ID].apply(_convert_nan_to_none) @@ -796,7 +807,7 @@ def from_df( for _, row in df.reset_index().iterrows() ] - return cls(measurements) + return cls(measurements, **kwargs) def to_df(self) -> pd.DataFrame: """Convert the MeasurementTable to a DataFrame.""" @@ -843,16 +854,15 @@ def mappings(self) -> list[Mapping]: return self.elements @classmethod - def from_df(cls, df: pd.DataFrame) -> MappingTable: + def from_df(cls, df: pd.DataFrame, **kwargs) -> MappingTable: """Create a MappingTable from a DataFrame.""" if df is None: - return cls() + return cls(**kwargs) mappings = [ Mapping(**row.to_dict()) for _, row in df.reset_index().iterrows() ] - - return cls(mappings) + return cls(mappings, **kwargs) def to_df(self) -> pd.DataFrame: """Convert the MappingTable to a DataFrame.""" @@ -1044,17 +1054,17 @@ def parameters(self) -> list[Parameter]: return self.elements @classmethod - def from_df(cls, df: pd.DataFrame) -> ParameterTable: + def from_df(cls, df: pd.DataFrame, **kwargs) -> ParameterTable: """Create a ParameterTable from a DataFrame.""" if df is None: - return cls() + return cls(**kwargs) parameters = [ Parameter(**row.to_dict()) for _, row in df.reset_index().iterrows() ] - return cls(parameters) + return cls(parameters, **kwargs) def to_df(self) -> pd.DataFrame: """Convert the ParameterTable to a DataFrame.""" @@ -1184,11 +1194,6 @@ def from_yaml( validate_yaml_syntax(yaml_config) - def get_path(filename): - if base_path is None: - return filename - return f"{base_path}/{filename}" - if (format_version := parse_version(yaml_config[C.FORMAT_VERSION]))[ 0 ] != 2: @@ -1220,15 +1225,17 @@ def get_path(filename): config = ProblemConfig( **yaml_config, base_path=base_path, filepath=yaml_file ) + parameter_tables = [ - ParameterTable.from_tsv(get_path(f)) + ParameterTable.from_tsv(f, base_path=base_path) for f in config.parameter_files ] models = [ model_factory( - get_path(model_info.location), - model_info.language, + model_info.location, + base_path=base_path, + model_language=model_info.language, model_id=model_id, ) for model_id, model_info in (config.model_files or {}).items() @@ -1236,7 +1243,7 @@ def get_path(filename): measurement_tables = ( [ - MeasurementTable.from_tsv(get_path(f)) + MeasurementTable.from_tsv(f, base_path) for f in config.measurement_files ] if config.measurement_files @@ -1245,7 +1252,7 @@ def get_path(filename): condition_tables = ( [ - ConditionTable.from_tsv(get_path(f)) + ConditionTable.from_tsv(f, base_path) for f in config.condition_files ] if config.condition_files @@ -1254,7 +1261,7 @@ def get_path(filename): experiment_tables = ( [ - ExperimentTable.from_tsv(get_path(f)) + ExperimentTable.from_tsv(f, base_path) for f in config.experiment_files ] if config.experiment_files @@ -1263,7 +1270,7 @@ def get_path(filename): observable_tables = ( [ - ObservableTable.from_tsv(get_path(f)) + ObservableTable.from_tsv(f, base_path) for f in config.observable_files ] if config.observable_files @@ -1271,7 +1278,7 @@ def get_path(filename): ) mapping_tables = ( - [MappingTable.from_tsv(get_path(f)) for f in config.mapping_files] + [MappingTable.from_tsv(f, base_path) for f in config.mapping_files] if config.mapping_files else None ) @@ -1387,6 +1394,78 @@ def get_problem(problem: str | Path | Problem) -> Problem: "or a PEtab problem object." ) + def to_files(self, base_path: str | Path | None) -> None: + """Write the PEtab problem to files. + + Writes the model, condition, experiment, measurement, parameter, + observable, and mapping tables to their respective files as specified + in the respective objects. + + This expects that all objects have their `rel_path` and `base_path` + set correctly, which is usually done by Problem.from_yaml(). + """ + config = copy.deepcopy(self.config) or ProblemConfig( + format_version="2.0.0" + ) + + for model in self.models: + model.to_file( + _generate_path(model.rel_path, base_path or model.base_path) + ) + + config.model_files = { + model.model_id: ModelFile( + location=model.rel_path, language=model.type_id + ) + for model in self.models + } + + config.condition_files = [ + table.rel_path for table in self.condition_tables if table.rel_path + ] + config.experiment_files = [ + table.rel_path + for table in self.experiment_tables + if table.rel_path + ] + config.observable_files = [ + table.rel_path + for table in self.observable_tables + if table.rel_path + ] + config.measurement_files = [ + table.rel_path + for table in self.measurement_tables + if table.rel_path + ] + config.parameter_files = [ + table.rel_path for table in self.parameter_tables if table.rel_path + ] + config.mapping_files = [ + table.rel_path for table in self.mapping_tables if table.rel_path + ] + + for table in chain( + self.condition_tables, + self.experiment_tables, + self.observable_tables, + self.measurement_tables, + self.parameter_tables, + self.mapping_tables, + ): + if table.rel_path: + table.to_tsv( + _generate_path( + table.rel_path, base_path or table.base_path + ) + ) + + config.to_yaml( + _generate_path( + Path(str(config.filepath)).name, base_path or config.base_path + ) + ) + @property def model(self) -> Model | None: """The model of the problem. diff --git a/tests/v2/test_core.py b/tests/v2/test_core.py index c646d52a..7a93ecf1 100644 --- a/tests/v2/test_core.py +++ b/tests/v2/test_core.py @@ -10,7 +10,7 @@ from sympy.abc import x, y import petab.v2 as petab -from petab.v2 import C, Problem +from petab.v2 import C from petab.v2.C import ( CONDITION_ID, ESTIMATE, @@ -40,7 +40,8 @@ def test_observable_table_round_trip(): with tempfile.TemporaryDirectory() as tmp_dir: tmp_file = Path(tmp_dir) / "observables.tsv" - observables.to_tsv(tmp_file) + observables.rel_path = tmp_file + observables.to_tsv() observables2 = ObservableTable.from_tsv(tmp_file) assert observables == observables2 @@ -51,7 +52,8 @@ def test_condition_table_round_trip(): file = Path(tmp_dir, "Fujita_experimentalCondition.tsv") conditions = ConditionTable.from_tsv(file) tmp_file = Path(tmp_dir) / "conditions.tsv" - conditions.to_tsv(tmp_file) + conditions.rel_path = tmp_file + conditions.to_tsv() conditions2 = ConditionTable.from_tsv(tmp_file) assert conditions == conditions2 @@ -353,7 +355,6 @@ def test_problem_from_yaml_multiple_files(): """ yaml_config = """ format_version: 2.0.0 - parameter_files: [] model_files: model1: location: model1.xml @@ -361,6 +362,7 @@ def test_problem_from_yaml_multiple_files(): model2: location: model2.xml language: sbml + parameter_files: [parameters1.tsv, parameters2.tsv] condition_files: [conditions1.tsv, conditions2.tsv] measurement_files: [measurements1.tsv, measurements2.tsv] observable_files: [observables1.tsv, observables2.tsv] @@ -396,6 +398,10 @@ def test_problem_from_yaml_multiple_files(): petab.write_observable_df( problem.observable_df, Path(tmpdir, f"observables{i}.tsv") ) + problem.add_parameter(f"parameter{i}", False, nominal_value=i) + petab.write_parameter_df( + problem.parameter_df, Path(tmpdir, f"parameters{i}.tsv") + ) petab_problem1 = petab.Problem.from_yaml(yaml_path) @@ -403,12 +409,25 @@ def test_problem_from_yaml_multiple_files(): yaml_config = petab.load_yaml(yaml_path) petab_problem2 = petab.Problem.from_yaml(yaml_config, base_path=tmpdir) - for petab_problem in (petab_problem1, petab_problem2): - assert len(petab_problem.models) == 2 - assert petab_problem.measurement_df.shape[0] == 2 - assert petab_problem.observable_df.shape[0] == 2 - assert petab_problem.condition_df.shape[0] == 2 - assert petab_problem.experiment_df.shape[0] == 2 + # test that we can save the problem to a new directory + with tempfile.TemporaryDirectory() as tmpdir2: + petab_problem1.to_files(tmpdir2) + # check the same files are created + assert { + file.relative_to(tmpdir) for file in Path(tmpdir).iterdir() + } == { + file.relative_to(tmpdir2) for file in Path(tmpdir2).iterdir() + } + petab_problem3 = petab.Problem.from_yaml( + Path(tmpdir2, "problem.yaml") + ) + + for petab_problem in (petab_problem1, petab_problem2, petab_problem3): + assert len(petab_problem.models) == 2 + assert petab_problem.measurement_df.shape[0] == 2 + assert petab_problem.observable_df.shape[0] == 2 + assert petab_problem.condition_df.shape[0] == 2 + assert petab_problem.experiment_df.shape[0] == 2 def test_modify_problem(): @@ -599,3 +618,36 @@ def test_get_measurements_for_experiment(): assert problem.get_measurements_for_experiment(e1) == [m1, m2] assert problem.get_measurements_for_experiment(e2) == [m3] + + +def test_generate_path(): + import platform + + from petab._utils import _generate_path as gp + + assert gp("foo") == "foo" + assert gp(Path("foo")) == "foo" + assert gp("https://example.com/foo") == "https://example.com/foo" + assert gp(AnyUrl("https://example.com/foo")) == "https://example.com/foo" + + assert gp("foo", "bar") == str(Path("bar", "foo")) + assert gp(Path("foo"), "bar") == str(Path("bar", "foo")) + assert gp(Path("foo"), Path("bar")) == str(Path("bar", "foo")) + assert ( + gp("bar", AnyUrl("https://example.com/foo")) + == "https://example.com/foo/bar" + ) + assert ( + gp("bar", "https://example.com/foo") == "https://example.com/foo/bar" + ) + assert ( + gp("https://example.com/foo", "https://example.com/bar") + == "https://example.com/foo" + ) + + if platform.system() == "Windows": + assert gp(Path("foo"), "c:/bar") == "c:/bar/foo" + assert gp("c:/foo", "c:/bar") == "c:/foo" + else: + assert gp(Path("foo"), "/bar") == "/bar/foo" + assert gp("/foo", "bar") == "/foo" From 8c5354a3f856104ac05bcaf207fe55806ab725a6 Mon Sep 17 00:00:00 2001 From: Daniel Weindl Date: Tue, 19 Aug 2025 09:25:30 +0200 Subject: [PATCH 2/3] Apply suggestions from code review Co-authored-by: Maren Philipps <55318391+m-philipps@users.noreply.github.com> --- petab/v1/models/model.py | 2 +- petab/v2/core.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/petab/v1/models/model.py b/petab/v1/models/model.py index c11002d7..96613757 100644 --- a/petab/v1/models/model.py +++ b/petab/v1/models/model.py @@ -28,7 +28,7 @@ def from_file( :param filepath_or_buffer: Absolute or relative path/URL to the model file. - If relative, it is interpreted relative to `base_path` if given. + If relative, it is interpreted relative to `base_path`, if given. :param base_path: Base path for relative paths in the model file. :param model_id: Model ID :returns: A ``Model`` instance holding the given model diff --git a/petab/v2/core.py b/petab/v2/core.py index 93d1c901..7cb263da 100644 --- a/petab/v2/core.py +++ b/petab/v2/core.py @@ -1399,7 +1399,7 @@ def to_files(self, base_path: str | Path | None) -> None: Writes the model, condition, experiment, measurement, parameter, observable, and mapping tables to their respective files as specified - in the respective objects. + by the `rel_path` and `base_path` of their respective objects. This expects that all objects have their `rel_path` and `base_path` set correctly, which is usually done by Problem.from_yaml(). From b82ca886ae4bff31df78c1437e47e0d0ee6eea07 Mon Sep 17 00:00:00 2001 From: Daniel Weindl Date: Tue, 19 Aug 2025 09:38:27 +0200 Subject: [PATCH 3/3] doc --- petab/v2/core.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/petab/v2/core.py b/petab/v2/core.py index 7cb263da..8207c573 100644 --- a/petab/v2/core.py +++ b/petab/v2/core.py @@ -1402,7 +1402,12 @@ def to_files(self, base_path: str | Path | None) -> None: by the `rel_path` and `base_path` of their respective objects. This expects that all objects have their `rel_path` and `base_path` - set correctly, which is usually done by Problem.from_yaml(). + set correctly, which is usually done by :meth:`Problem.from_yaml`. + + :param base_path: + The base path the yaml file and tables will be written to. + If ``None``, the `base_path` of the individual tables and + :obj:`Problem.config.base_path` will be used. """ config = copy.deepcopy(self.config) or ProblemConfig( format_version="2.0.0"