diff --git a/petab/v1/models/sbml_model.py b/petab/v1/models/sbml_model.py index e6fffcca..2d31c0b9 100644 --- a/petab/v1/models/sbml_model.py +++ b/petab/v1/models/sbml_model.py @@ -130,16 +130,18 @@ def from_string(sbml_string, model_id: str = None) -> SbmlModel: ) @staticmethod - def from_antimony(ant_model: str | Path) -> SbmlModel: + def from_antimony(ant_model: str | Path, **kwargs) -> SbmlModel: """Create SBML model from an Antimony model. Requires the `antimony` package (https://github.com/sys-bio/antimony). :param ant_model: Antimony model as string or path to file. Strings are interpreted as Antimony model strings. + :param kwargs: Additional keyword arguments passed to + :meth:`SbmlModel.from_string`. """ sbml_str = antimony2sbml(ant_model) - return SbmlModel.from_string(sbml_str) + return SbmlModel.from_string(sbml_str, **kwargs) def to_antimony(self) -> str: """Convert the SBML model to an Antimony string.""" diff --git a/petab/v2/lint.py b/petab/v2/lint.py index f7a8daec..0780b340 100644 --- a/petab/v2/lint.py +++ b/petab/v2/lint.py @@ -27,6 +27,10 @@ "ValidationTask", "CheckModel", "CheckProblemConfig", + "CheckMeasuredObservablesDefined", + "CheckOverridesMatchPlaceholders", + "CheckMeasuredExperimentsDefined", + "CheckMeasurementModelId", "CheckPosLogMeasurements", "CheckValidConditionTargets", "CheckUniquePrimaryKeys", @@ -769,8 +773,39 @@ def run(self, problem: Problem) -> ValidationIssue | None: return None -# TODO: check that Measurements model IDs match the available ones -# https://github.com/PEtab-dev/libpetab-python/issues/392 +class CheckMeasurementModelId(ValidationTask): + """Validate model IDs of measurements.""" + + def run(self, problem: Problem) -> ValidationIssue | None: + messages = [] + available_models = {m.model_id for m in problem.models} + + for measurement in problem.measurements: + if not measurement.model_id: + if len(available_models) < 2: + # If there is only one model, it is not required to specify + # the model ID in the measurement table. + continue + + messages.append( + f"Measurement `{measurement}' does not have a model ID, " + "but there are multiple models available. " + "Please specify the model ID in the measurement table." + ) + continue + + if measurement.model_id not in available_models: + messages.append( + f"Measurement `{measurement}' has model ID " + f"`{measurement.model_id}' which does not match " + "any of the available models: " + f"{available_models}." + ) + + if messages: + return ValidationError("\n".join(messages)) + + return None def get_valid_parameters_for_parameter_table( @@ -1011,6 +1046,7 @@ def get_placeholders( CheckProblemConfig(), CheckModel(), CheckUniquePrimaryKeys(), + CheckMeasurementModelId(), CheckMeasuredObservablesDefined(), CheckPosLogMeasurements(), CheckOverridesMatchPlaceholders(), diff --git a/tests/v2/test_lint.py b/tests/v2/test_lint.py index 74aaaa29..12973d86 100644 --- a/tests/v2/test_lint.py +++ b/tests/v2/test_lint.py @@ -37,3 +37,30 @@ def test_check_incompatible_targets(): problem["e1"].periods[0].condition_ids.append("c2") assert (error := check.run(problem)) is not None assert "overlapping targets {'p1'}" in error.message + + +def test_invalid_model_id_in_measurements(): + """Test that measurements with an invalid model ID are caught.""" + problem = Problem() + problem.models.append(SbmlModel.from_antimony("p1 = 1", model_id="model1")) + problem.add_observable("obs1", "A") + problem.add_measurement("obs1", experiment_id="e1", time=0, measurement=1) + + check = CheckMeasurementModelId() + + # Single model -> model ID is optional + assert (error := check.run(problem)) is None, error + + # Two models -> model ID must be set + problem.models.append(SbmlModel.from_antimony("p2 = 2", model_id="model2")) + assert (error := check.run(problem)) is not None + assert "multiple models" in error.message + + # Set model ID to a non-existing model ID + problem.measurements[0].model_id = "invalid_model_id" + assert (error := check.run(problem)) is not None + assert "does not match" in error.message + + # Use a valid model ID + problem.measurements[0].model_id = "model1" + assert (error := check.run(problem)) is None, error