Skip to content

Commit 8a1909f

Browse files
m-philippsdilpathBSnellingdweindl
authored
Lint mapping table (#480)
* update data types * add checks for mapping table * Use SBML in test * ensure that all observables have a noise_formula * rollback * remove lint checks that are covered by pydantic * move from sciml pr * move more from sciml pr * fix ruff * aliased model logic * keep FIXME comment * Apply suggestions from code review Co-authored-by: Daniel Weindl <dweindl@users.noreply.github.com> * update test to pysb and feedback from code review * ignore mapping table and update test * Update petab/v2/lint.py Co-authored-by: Daniel Weindl <dweindl@users.noreply.github.com> * update parameter check docstring --------- Co-authored-by: user <59329744+dilpath@users.noreply.github.com> Co-authored-by: Branwen Snelling <branwen.snelling@crick.ac.uk> Co-authored-by: Daniel Weindl <dweindl@users.noreply.github.com>
1 parent 3363e85 commit 8a1909f

4 files changed

Lines changed: 164 additions & 31 deletions

File tree

petab/v2/core.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -318,9 +318,9 @@ class Observable(BaseModel):
318318
#: Observable name.
319319
name: str | None = Field(alias=C.OBSERVABLE_NAME, default=None)
320320
#: Observable formula.
321-
formula: sp.Basic | None = Field(alias=C.OBSERVABLE_FORMULA, default=None)
321+
formula: sp.Basic = Field(alias=C.OBSERVABLE_FORMULA)
322322
#: Noise formula.
323-
noise_formula: sp.Basic | None = Field(alias=C.NOISE_FORMULA, default=None)
323+
noise_formula: sp.Basic = Field(alias=C.NOISE_FORMULA)
324324
#: Noise distribution.
325325
noise_distribution: NoiseDistribution = Field(
326326
alias=C.NOISE_DISTRIBUTION, default=NoiseDistribution.NORMAL

petab/v2/lint.py

Lines changed: 95 additions & 22 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
]
@@ -445,31 +446,31 @@ def run(self, problem: Problem) -> ValidationIssue | None:
445446

446447
# check for uniqueness of all primary keys
447448
counter = Counter(c.id for c in problem.conditions)
448-
duplicates = {id_ for id_, count in counter.items() if count > 1}
449+
duplicates = sorted(id_ for id_, count in counter.items() if count > 1)
449450

450451
if duplicates:
451452
return ValidationError(
452453
f"Condition table contains duplicate IDs: {duplicates}"
453454
)
454455

455456
counter = Counter(o.id for o in problem.observables)
456-
duplicates = {id_ for id_, count in counter.items() if count > 1}
457+
duplicates = sorted(id_ for id_, count in counter.items() if count > 1)
457458

458459
if duplicates:
459460
return ValidationError(
460461
f"Observable table contains duplicate IDs: {duplicates}"
461462
)
462463

463464
counter = Counter(e.id for e in problem.experiments)
464-
duplicates = {id_ for id_, count in counter.items() if count > 1}
465+
duplicates = sorted(id_ for id_, count in counter.items() if count > 1)
465466

466467
if duplicates:
467468
return ValidationError(
468469
f"Experiment table contains duplicate IDs: {duplicates}"
469470
)
470471

471472
counter = Counter(p.id for p in problem.parameters)
472-
duplicates = {id_ for id_, count in counter.items() if count > 1}
473+
duplicates = sorted(id_ for id_, count in counter.items() if count > 1)
473474

474475
if duplicates:
475476
return ValidationError(
@@ -508,7 +509,9 @@ def run(self, problem: Problem) -> ValidationIssue | None:
508509
for experiment in problem.experiments:
509510
# Check that there are no duplicate timepoints
510511
counter = Counter(period.time for period in experiment.periods)
511-
duplicates = {time for time, count in counter.items() if count > 1}
512+
duplicates = sorted(
513+
time for time, count in counter.items() if count > 1
514+
)
512515
if duplicates:
513516
messages.append(
514517
f"Experiment {experiment.id} contains duplicate "
@@ -551,7 +554,8 @@ def run(self, problem: Problem) -> ValidationIssue | None:
551554

552555
class CheckAllParametersPresentInParameterTable(ValidationTask):
553556
"""Ensure all required parameters are contained in the parameter table
554-
with no additional ones."""
557+
with no additional ones.
558+
"""
555559

556560
def run(self, problem: Problem) -> ValidationIssue | None:
557561
if problem.model is None:
@@ -825,17 +829,17 @@ def run(self, problem: Problem) -> ValidationIssue | None:
825829

826830
if parameter.prior_distribution not in PRIOR_DISTRIBUTIONS:
827831
messages.append(
828-
f"Prior distribution `{parameter.prior_distribution}' "
829-
f"for parameter `{parameter.id}' is not valid."
832+
f"Prior distribution `{parameter.prior_distribution}` "
833+
f"for parameter `{parameter.id}` is not valid."
830834
)
831835
continue
832836

833837
if (
834838
exp_num_par := self._num_pars[parameter.prior_distribution]
835839
) != len(parameter.prior_parameters):
836840
messages.append(
837-
f"Prior distribution `{parameter.prior_distribution}' "
838-
f"for parameter `{parameter.id}' requires "
841+
f"Prior distribution `{parameter.prior_distribution}` "
842+
f"for parameter `{parameter.id}` requires "
839843
f"{exp_num_par} parameters, but got "
840844
f"{len(parameter.prior_parameters)} "
841845
f"({parameter.prior_parameters})."
@@ -848,8 +852,8 @@ def run(self, problem: Problem) -> ValidationIssue | None:
848852
_ = parameter.prior_dist.sample(1)
849853
except Exception as e:
850854
messages.append(
851-
f"Prior parameters `{parameter.prior_parameters}' "
852-
f"for parameter `{parameter.id}' are invalid "
855+
f"Prior parameters `{parameter.prior_parameters}` "
856+
f"for parameter `{parameter.id}` are invalid "
853857
f"(hint: {e})."
854858
)
855859

@@ -874,16 +878,16 @@ def run(self, problem: Problem) -> ValidationIssue | None:
874878
continue
875879

876880
messages.append(
877-
f"Measurement `{measurement}' does not have a model ID, "
881+
f"Measurement `{measurement}` does not have a model ID, "
878882
"but there are multiple models available. "
879883
"Please specify the model ID in the measurement table."
880884
)
881885
continue
882886

883887
if measurement.model_id not in available_models:
884888
messages.append(
885-
f"Measurement `{measurement}' has model ID "
886-
f"`{measurement.model_id}' which does not match "
889+
f"Measurement `{measurement}` has model ID "
890+
f"`{measurement.model_id}` which does not match "
887891
"any of the available models: "
888892
f"{available_models}."
889893
)
@@ -894,6 +898,79 @@ def run(self, problem: Problem) -> ValidationIssue | None:
894898
return None
895899

896900

901+
class CheckMappingTable(ValidationTask):
902+
"""Validate the mapping table."""
903+
904+
def run(self, problem: Problem) -> ValidationIssue | None:
905+
# Mapping table is optional
906+
if not problem.mappings:
907+
return None
908+
909+
messages = []
910+
911+
# Check that each id, across both the petabEntityId and
912+
# modelEntityId columns, occurs only once
913+
must_be_unique_ids = []
914+
for mapping in problem.mappings:
915+
petab_id = mapping.petab_id
916+
model_id = mapping.model_id
917+
918+
if petab_id:
919+
must_be_unique_ids.append(petab_id)
920+
# Identity mappings are permitted for annotation
921+
if petab_id == model_id:
922+
continue
923+
if model_id:
924+
must_be_unique_ids.append(model_id)
925+
926+
non_unique_ids = sorted(
927+
id_
928+
for id_, count in Counter(must_be_unique_ids).items()
929+
if count > 1
930+
)
931+
if non_unique_ids:
932+
return ValidationError(
933+
f"Mapping table contains non-unique IDs: {non_unique_ids}."
934+
)
935+
936+
# petabEntityId is not defined elsewhere in the PEtab problem
937+
new_petab_ids = {
938+
m.petab_id
939+
for m in problem.mappings
940+
# Ignore identity mappings used for annotation
941+
if m.petab_id != m.model_id
942+
}
943+
old_petab_ids = (
944+
{c.id for c in problem.conditions}
945+
| {e.id for e in problem.experiments}
946+
| {o.id for o in problem.observables}
947+
)
948+
if overdefined_ids := sorted(new_petab_ids & old_petab_ids):
949+
messages.append(
950+
f"PEtab IDs `{overdefined_ids}` are "
951+
"defined in the mapping table but also defined through "
952+
"other PEtab tables."
953+
)
954+
955+
for mapping in problem.mappings:
956+
# petabEntityId not referenced in any model, if alias
957+
for model in problem.models:
958+
if (
959+
mapping.petab_id != mapping.model_id
960+
and model.has_entity_with_id(mapping.petab_id)
961+
):
962+
messages.append(
963+
f"`{mapping.petab_id}` is used in the mapping "
964+
"table and referenced directly in the model "
965+
f"`{model.model_id}`."
966+
)
967+
968+
if messages:
969+
return ValidationError("\n".join(messages))
970+
971+
return None
972+
973+
897974
def get_valid_parameters_for_parameter_table(
898975
problem: Problem,
899976
) -> set[str]:
@@ -984,13 +1061,9 @@ def get_required_parameters_for_parameter_table(
9841061
for change in cond.changes
9851062
}
9861063

987-
# Add parameters from measurement table, unless they are fixed parameters
9881064
def append_overrides(overrides):
9891065
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
1066+
str(p) for p in overrides if isinstance(p, sp.Symbol)
9941067
)
9951068

9961069
for m in problem.measurements:
@@ -1033,7 +1106,7 @@ def append_overrides(overrides):
10331106
if not problem.model.has_entity_with_id(str(p))
10341107
)
10351108

1036-
# parameters that are overridden via the condition table are not allowed
1109+
# Parameters that are overridden via the condition table are not allowed
10371110
parameter_ids -= condition_targets
10381111

10391112
return parameter_ids
@@ -1090,5 +1163,5 @@ def get_placeholders(
10901163
CheckUnusedConditions(),
10911164
CheckPriorDistribution(),
10921165
CheckInitialChangeSymbols(),
1093-
# TODO validate mapping table
1166+
CheckMappingTable(),
10941167
]

tests/v2/test_core.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ def test_measurments():
181181

182182

183183
def test_observable():
184-
Observable(id="obs1", formula=x + y)
184+
Observable(id="obs1", formula=x + y, noiseFormula=1)
185185
Observable(id="obs1", formula="x + y", noise_formula="x + y")
186186
Observable(id="obs1", formula=1, noise_formula=2)
187187
Observable(
@@ -198,9 +198,17 @@ def test_observable():
198198
observable_parameters=[sp.Symbol("p1")],
199199
noise_parameters=[sp.Symbol("n1")],
200200
)
201-
assert Observable(id="obs1", formula="x + y", non_petab=1).non_petab == 1
201+
assert (
202+
Observable(
203+
id="obs1",
204+
formula="x + y",
205+
noise_formula="x + y",
206+
non_petab=1,
207+
).non_petab
208+
== 1
209+
)
202210

203-
o = Observable(id="obs1", formula=x + y)
211+
o = Observable(id="obs1", formula=x + y, noise_formula=1)
204212
assert o.observable_placeholders == []
205213
assert o.noise_placeholders == []
206214

@@ -492,14 +500,14 @@ def test_modify_problem():
492500
problem.condition_df, exp_condition_df, check_dtype=False
493501
)
494502

495-
problem.add_observable("observable1", "1")
503+
problem.add_observable("observable1", "1", noise_formula=1)
496504
problem.add_observable("observable2", "2", noise_formula=2.2)
497505

498506
exp_observable_df = pd.DataFrame(
499507
data={
500508
OBSERVABLE_ID: ["observable1", "observable2"],
501509
OBSERVABLE_FORMULA: [1, 2],
502-
NOISE_FORMULA: [np.nan, 2.2],
510+
NOISE_FORMULA: [1, 2.2],
503511
}
504512
).set_index([OBSERVABLE_ID])
505513
assert_frame_equal(

tests/v2/test_lint.py

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

33
from copy import deepcopy
44

5+
import pysb
6+
import pytest
7+
58
from petab.v2 import Problem
69
from petab.v2.lint import *
10+
from petab.v2.models.pysb_model import PySBModel
711
from petab.v2.models.sbml_model import SbmlModel
812

913

14+
@pytest.fixture
15+
def uses_pysb():
16+
"""Cleanup PySB auto-exported symbols before and after test"""
17+
pysb.SelfExporter.cleanup()
18+
yield ()
19+
pysb.SelfExporter.cleanup()
20+
21+
1022
def test_check_experiments():
1123
"""Test ``CheckExperimentTable``."""
1224
problem = Problem()
@@ -43,7 +55,7 @@ def test_invalid_model_id_in_measurements():
4355
"""Test that measurements with an invalid model ID are caught."""
4456
problem = Problem()
4557
problem.models.append(SbmlModel.from_antimony("p1 = 1", model_id="model1"))
46-
problem.add_observable("obs1", "A")
58+
problem.add_observable("obs1", "A", 1)
4759
problem.add_measurement("obs1", experiment_id="e1", time=0, measurement=1)
4860

4961
check = CheckMeasurementModelId()
@@ -70,7 +82,7 @@ def test_undefined_experiment_id_in_measurements():
7082
"""Test that measurements with an undefined experiment ID are caught."""
7183
problem = Problem()
7284
problem.add_experiment("e1", 0, "c1")
73-
problem.add_observable("obs1", "A")
85+
problem.add_observable("obs1", "A", 1)
7486
problem.add_measurement("obs1", experiment_id="e1", time=0, measurement=1)
7587

7688
check = CheckUndefinedExperiments()
@@ -107,3 +119,43 @@ def test_validate_initial_change_symbols():
107119
problem.parameter_tables[0].parameters.remove(problem["p2"])
108120
assert (error := check.run(problem)) is not None
109121
assert "contains additional symbols: {'p2'}" in error.message
122+
123+
124+
def test_check_mapping_table(uses_pysb):
125+
"""Test checks related to the mapping table."""
126+
problem = Problem()
127+
128+
# PySB model with a compartment and a monomer, and mapping of model entity
129+
# to a valid PEtab id
130+
pysb_model = pysb.Model("test_model")
131+
pysb.Monomer("A_")
132+
pysb.Initial(A_() ** pysb.Compartment("C"), pysb.Parameter("a0", 1))
133+
problem.model = PySBModel(model=pysb_model, model_id="test_model")
134+
problem.add_mapping("A", "A_() ** C")
135+
136+
check = CheckMappingTable()
137+
assert check.run(problem) is None
138+
139+
check = CheckAllParametersPresentInParameterTable()
140+
assert check.run(problem) is None
141+
142+
# add a petab id without model id but with name for annotation
143+
problem.add_mapping(petab_id="p2", model_id=None, name="Parameter 2")
144+
problem.add_parameter("p2", estimate=True, nominal_value=1, lb=0, ub=10)
145+
146+
check = CheckMappingTable()
147+
assert check.run(problem) is None
148+
149+
# Invalid: petabEntityId is referenced in the model
150+
pysb.SelfExporter.cleanup()
151+
pysb_model_invalid = pysb.Model("test_model_invalid")
152+
pysb.Monomer("A_")
153+
pysb.Initial(A_() ** pysb.Compartment("C"), pysb.Parameter("A", 1))
154+
problem.model = PySBModel(
155+
model=pysb_model_invalid, model_id="test_model_invalid"
156+
)
157+
assert (error := check.run(problem)) is not None
158+
assert (
159+
"`A` is used in the mapping table and referenced directly"
160+
in error.message
161+
)

0 commit comments

Comments
 (0)