Skip to content

Commit b4d0f35

Browse files
committed
add checks for mapping table
1 parent ed11378 commit b4d0f35

2 files changed

Lines changed: 173 additions & 25 deletions

File tree

petab/v2/lint.py

Lines changed: 123 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"CheckPriorDistribution",
4545
"CheckUndefinedExperiments",
4646
"CheckInitialChangeSymbols",
47+
"CheckMappingTable",
4748
"lint_problem",
4849
"default_validation_tasks",
4950
]
@@ -551,7 +552,8 @@ def run(self, problem: Problem) -> ValidationIssue | None:
551552

552553
class CheckAllParametersPresentInParameterTable(ValidationTask):
553554
"""Ensure all required parameters are contained in the parameter table
554-
with no additional ones."""
555+
with no additional ones. This also ensures that the mapping table petab ids
556+
are used in the PEtab problem."""
555557

556558
def run(self, problem: Problem) -> ValidationIssue | None:
557559
if problem.model is None:
@@ -825,17 +827,17 @@ def run(self, problem: Problem) -> ValidationIssue | None:
825827

826828
if parameter.prior_distribution not in PRIOR_DISTRIBUTIONS:
827829
messages.append(
828-
f"Prior distribution `{parameter.prior_distribution}' "
829-
f"for parameter `{parameter.id}' is not valid."
830+
f"Prior distribution `{parameter.prior_distribution}` "
831+
f"for parameter `{parameter.id}` is not valid."
830832
)
831833
continue
832834

833835
if (
834836
exp_num_par := self._num_pars[parameter.prior_distribution]
835837
) != len(parameter.prior_parameters):
836838
messages.append(
837-
f"Prior distribution `{parameter.prior_distribution}' "
838-
f"for parameter `{parameter.id}' requires "
839+
f"Prior distribution `{parameter.prior_distribution}` "
840+
f"for parameter `{parameter.id}` requires "
839841
f"{exp_num_par} parameters, but got "
840842
f"{len(parameter.prior_parameters)} "
841843
f"({parameter.prior_parameters})."
@@ -848,8 +850,8 @@ def run(self, problem: Problem) -> ValidationIssue | None:
848850
_ = parameter.prior_dist.sample(1)
849851
except Exception as e:
850852
messages.append(
851-
f"Prior parameters `{parameter.prior_parameters}' "
852-
f"for parameter `{parameter.id}' are invalid "
853+
f"Prior parameters `{parameter.prior_parameters}` "
854+
f"for parameter `{parameter.id}` are invalid "
853855
f"(hint: {e})."
854856
)
855857

@@ -874,16 +876,16 @@ def run(self, problem: Problem) -> ValidationIssue | None:
874876
continue
875877

876878
messages.append(
877-
f"Measurement `{measurement}' does not have a model ID, "
879+
f"Measurement `{measurement}` does not have a model ID, "
878880
"but there are multiple models available. "
879881
"Please specify the model ID in the measurement table."
880882
)
881883
continue
882884

883885
if measurement.model_id not in available_models:
884886
messages.append(
885-
f"Measurement `{measurement}' has model ID "
886-
f"`{measurement.model_id}' which does not match "
887+
f"Measurement `{measurement}` has model ID "
888+
f"`{measurement.model_id}` which does not match "
887889
"any of the available models: "
888890
f"{available_models}."
889891
)
@@ -894,6 +896,102 @@ def run(self, problem: Problem) -> ValidationIssue | None:
894896
return None
895897

896898

899+
class CheckMappingTable(ValidationTask):
900+
"""Validate the mapping table."""
901+
902+
def run(self, problem: Problem) -> ValidationIssue | None:
903+
messages = []
904+
905+
# Mapping table is optional
906+
if problem.mappings:
907+
# Check that each id only occurs once
908+
counter = Counter(
909+
[
910+
getattr(mapping, attr)
911+
for mapping in problem.mappings
912+
for attr in ["petab_id", "model_id"]
913+
if getattr(mapping, attr)
914+
]
915+
)
916+
non_unique = {id_ for id_, count in counter.items() if count > 1}
917+
if non_unique:
918+
return ValidationError(
919+
f"Mapping table contains non-unique IDs: {non_unique}."
920+
)
921+
922+
# petabEntityId is not defined elsewhere in the PEtab problem
923+
petab_ids_mapping = {m.petab_id for m in problem.mappings}
924+
defined_petab_ids = (
925+
{c.id for c in problem.conditions}
926+
| {e.id for e in problem.experiments}
927+
| {o.id for o in problem.observables}
928+
)
929+
if petab_ids_mapping & defined_petab_ids:
930+
messages.append(
931+
f"PEtab IDs `{petab_ids_mapping & defined_petab_ids}` are "
932+
"defined in the mapping table but also defined through "
933+
"other PEtab tables."
934+
)
935+
# Grab all symbols from condition table for later
936+
condition_target_ids_values = {
937+
sym
938+
for cond in problem.conditions
939+
for change in cond.changes
940+
for sym in (
941+
change.target_value.free_symbols
942+
| change.target_id.free_symbols
943+
)
944+
}
945+
946+
for mapping in problem.mappings:
947+
# petabEntityId is not referenced in any model
948+
for model in problem.models:
949+
if model.has_entity_with_id(mapping.petab_id):
950+
messages.append(
951+
f"`{mapping.petab_id}` is used in the mapping "
952+
"table and referenced directly in the model "
953+
f"`{model.model_id}`."
954+
)
955+
956+
# modelEntityId can be empty
957+
if mapping.model_id:
958+
# model_id is not in petab problem
959+
if mapping.model_id in defined_petab_ids:
960+
messages.append(
961+
f"PEtab ID `{mapping.model_id}` mirrors the "
962+
"model entity ID referenced in the mapping table."
963+
)
964+
if mapping.model_id in condition_target_ids_values:
965+
messages.append(
966+
f"Identifier `{mapping.model_id}` is mapped to a "
967+
"PEtab ID in the mapping table, but also directly "
968+
"used in the conditions table."
969+
)
970+
for observable_formula in [
971+
o.formula for o in problem.observables
972+
]:
973+
if mapping.model_id in observable_formula.free_symbols:
974+
messages.append(
975+
f"Identifier `{mapping.model_id}` is mapped "
976+
"to a PEtab ID in the mapping table, but also "
977+
"directly used in an observable formula."
978+
)
979+
for obs_noise_formula in [
980+
o.noise_formula for o in problem.observables
981+
]:
982+
if mapping.model_id in obs_noise_formula.free_symbols:
983+
messages.append(
984+
f"Identifier `{mapping.model_id}` is mapped "
985+
"to a PEtab ID in the mapping table, but also "
986+
"directly used in a noise formula."
987+
)
988+
989+
if messages:
990+
return ValidationError("\n".join(messages))
991+
992+
return None
993+
994+
897995
def get_valid_parameters_for_parameter_table(
898996
problem: Problem,
899997
) -> set[str]:
@@ -933,9 +1031,13 @@ def get_valid_parameters_for_parameter_table(
9331031
if p not in invalid
9341032
)
9351033

1034+
# Add petab ids from mapping table if they are used for aliasing
9361035
for mapping in problem.mappings:
937-
if mapping.model_id and mapping.model_id in parameter_ids.keys():
1036+
if mapping.model_id:
9381037
parameter_ids[mapping.petab_id] = None
1038+
# An aliased model id is not a valid parameter id
1039+
if mapping.model_id in parameter_ids:
1040+
del parameter_ids[mapping.model_id]
9391041

9401042
# add output parameters from observable table
9411043
output_parameters = problem.get_output_parameters()
@@ -977,20 +1079,13 @@ def get_required_parameters_for_parameter_table(
9771079
measurement table as well as all parametric condition table overrides
9781080
that are not defined in the model.
9791081
"""
980-
parameter_ids = set()
981-
condition_targets = {
982-
change.target_id
983-
for cond in problem.conditions
984-
for change in cond.changes
985-
}
1082+
# Start with mapping table petab ids
1083+
parameter_ids = {m.petab_id for m in problem.mappings}
9861084

9871085
# Add parameters from measurement table, unless they are fixed parameters
9881086
def append_overrides(overrides):
9891087
parameter_ids.update(
990-
str_p
991-
for p in overrides
992-
if isinstance(p, sp.Symbol)
993-
and (str_p := str(p)) not in condition_targets
1088+
str(p) for p in overrides if isinstance(p, sp.Symbol)
9941089
)
9951090

9961091
for m in problem.measurements:
@@ -1033,7 +1128,12 @@ def append_overrides(overrides):
10331128
if not problem.model.has_entity_with_id(str(p))
10341129
)
10351130

1036-
# parameters that are overridden via the condition table are not allowed
1131+
# Parameters that are overridden via the condition table are not allowed
1132+
condition_targets = {
1133+
change.target_id
1134+
for cond in problem.conditions
1135+
for change in cond.changes
1136+
}
10371137
parameter_ids -= condition_targets
10381138

10391139
return parameter_ids
@@ -1090,5 +1190,5 @@ def get_placeholders(
10901190
CheckUnusedConditions(),
10911191
CheckPriorDistribution(),
10921192
CheckInitialChangeSymbols(),
1093-
# TODO validate mapping table
1193+
CheckMappingTable(),
10941194
]

tests/v2/test_lint.py

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22

33
from copy import deepcopy
44

5+
from petabtests import DEFAULT_PYSB_FILE
6+
57
from petab.v2 import Problem
68
from petab.v2.lint import *
9+
from petab.v2.models.pysb_model import PySBModel
710
from petab.v2.models.sbml_model import SbmlModel
811

912

@@ -43,7 +46,7 @@ def test_invalid_model_id_in_measurements():
4346
"""Test that measurements with an invalid model ID are caught."""
4447
problem = Problem()
4548
problem.models.append(SbmlModel.from_antimony("p1 = 1", model_id="model1"))
46-
problem.add_observable("obs1", "A")
49+
problem.add_observable("obs1", "A", 1)
4750
problem.add_measurement("obs1", experiment_id="e1", time=0, measurement=1)
4851

4952
check = CheckMeasurementModelId()
@@ -70,7 +73,7 @@ def test_undefined_experiment_id_in_measurements():
7073
"""Test that measurements with an undefined experiment ID are caught."""
7174
problem = Problem()
7275
problem.add_experiment("e1", 0, "c1")
73-
problem.add_observable("obs1", "A")
76+
problem.add_observable("obs1", "A", 1)
7477
problem.add_measurement("obs1", experiment_id="e1", time=0, measurement=1)
7578

7679
check = CheckUndefinedExperiments()
@@ -107,3 +110,48 @@ def test_validate_initial_change_symbols():
107110
problem.parameter_tables[0].parameters.remove(problem["p2"])
108111
assert (error := check.run(problem)) is not None
109112
assert "contains additional symbols: {'p2'}" in error.message
113+
114+
115+
def test_check_mapping_table():
116+
"""Test checks related to the mapping table."""
117+
problem = Problem()
118+
# PySB model from PEtab test suite
119+
problem.model = PySBModel.from_file(DEFAULT_PYSB_FILE)
120+
problem.add_mapping(
121+
petab_id="A_comp",
122+
model_id="A_() ** compartment",
123+
name=None,
124+
)
125+
problem.add_parameter(
126+
"A_comp",
127+
estimate=True,
128+
nominal_value=2,
129+
lb=0,
130+
ub=10,
131+
)
132+
133+
check = CheckMappingTable()
134+
assert check.run(problem) is None
135+
136+
check = CheckAllParametersPresentInParameterTable()
137+
assert check.run(problem) is None
138+
139+
# add a petab id without model id but with name for annotation
140+
problem.add_mapping(petab_id="p2", model_id=None, name="Parameter 2")
141+
problem.add_parameter("p2", estimate=True, nominal_value=1, lb=0, ub=10)
142+
143+
check = CheckMappingTable()
144+
assert check.run(problem) is None
145+
146+
# Invalid: petabEntityId is referenced in the model
147+
problem.mapping_tables = []
148+
problem.add_mapping(
149+
petab_id="a0",
150+
model_id="A_() ** compartment",
151+
name=None,
152+
)
153+
assert (error := check.run(problem)) is not None
154+
assert (
155+
"`a0` is used in the mapping table and referenced directly"
156+
in error.message
157+
)

0 commit comments

Comments
 (0)