|
2 | 2 |
|
3 | 3 | from __future__ import annotations
|
4 | 4 |
|
| 5 | +import re |
5 | 6 | from collections.abc import Sequence
|
6 | 7 | from enum import Enum
|
7 | 8 | from pathlib import Path
|
8 |
| -from typing import Annotated |
| 9 | +from typing import Annotated, Literal |
9 | 10 |
|
10 | 11 | import numpy as np
|
11 | 12 | import pandas as pd
|
|
46 | 47 | ]
|
47 | 48 |
|
48 | 49 |
|
49 |
| -def is_finite_or_neg_inf(v: float, info: ValidationInfo) -> float: |
| 50 | +def _is_finite_or_neg_inf(v: float, info: ValidationInfo) -> float: |
50 | 51 | if not np.isfinite(v) and v != -np.inf:
|
51 | 52 | raise ValueError(
|
52 | 53 | f"{info.field_name} value must be finite or -inf but got {v}"
|
53 | 54 | )
|
54 | 55 | return v
|
55 | 56 |
|
56 | 57 |
|
| 58 | +def _not_nan(v: float, info: ValidationInfo) -> float: |
| 59 | + if np.isnan(v): |
| 60 | + raise ValueError(f"{info.field_name} value must not be nan.") |
| 61 | + return v |
| 62 | + |
| 63 | + |
57 | 64 | def _convert_nan_to_none(v):
|
58 | 65 | if isinstance(v, float) and np.isnan(v):
|
59 | 66 | return None
|
@@ -149,6 +156,11 @@ class Observable(BaseModel):
|
149 | 156 | alias=C.NOISE_DISTRIBUTION, default=NoiseDistribution.NORMAL
|
150 | 157 | )
|
151 | 158 |
|
| 159 | + #: :meta private: |
| 160 | + model_config = ConfigDict( |
| 161 | + arbitrary_types_allowed=True, populate_by_name=True |
| 162 | + ) |
| 163 | + |
152 | 164 | @field_validator("id")
|
153 | 165 | @classmethod
|
154 | 166 | def _validate_id(cls, v):
|
@@ -183,10 +195,31 @@ def _sympify(cls, v):
|
183 | 195 |
|
184 | 196 | return sympify_petab(v)
|
185 | 197 |
|
186 |
| - #: :meta private: |
187 |
| - model_config = ConfigDict( |
188 |
| - arbitrary_types_allowed=True, populate_by_name=True |
189 |
| - ) |
| 198 | + def _placeholders( |
| 199 | + self, type_: Literal["observable", "noise"] |
| 200 | + ) -> set[sp.Symbol]: |
| 201 | + # TODO: add field validator to check for 1-based consecutive numbering |
| 202 | + t = f"{re.escape(type_)}Parameter" |
| 203 | + o = re.escape(self.id) |
| 204 | + pattern = re.compile(rf"(?:^|\W)({t}\d+_{o})(?=\W|$)") |
| 205 | + formula = ( |
| 206 | + self.formula |
| 207 | + if type_ == "observable" |
| 208 | + else self.noise_formula |
| 209 | + if type_ == "noise" |
| 210 | + else None |
| 211 | + ) |
| 212 | + return {s for s in formula.free_symbols if pattern.match(str(s))} |
| 213 | + |
| 214 | + @property |
| 215 | + def observable_placeholders(self) -> set[sp.Symbol]: |
| 216 | + """Placeholder symbols for the observable formula.""" |
| 217 | + return self._placeholders("observable") |
| 218 | + |
| 219 | + @property |
| 220 | + def noise_placeholders(self) -> set[sp.Symbol]: |
| 221 | + """Placeholder symbols for the noise formula.""" |
| 222 | + return self._placeholders("noise") |
190 | 223 |
|
191 | 224 |
|
192 | 225 | class ObservablesTable(BaseModel):
|
@@ -440,7 +473,7 @@ class ExperimentPeriod(BaseModel):
|
440 | 473 | """
|
441 | 474 |
|
442 | 475 | #: The start time of the period in time units as defined in the model.
|
443 |
| - time: Annotated[float, AfterValidator(is_finite_or_neg_inf)] = Field( |
| 476 | + time: Annotated[float, AfterValidator(_is_finite_or_neg_inf)] = Field( |
444 | 477 | alias=C.TIME
|
445 | 478 | )
|
446 | 479 | #: The ID of the condition to be applied at the start time.
|
@@ -588,7 +621,9 @@ class Measurement(BaseModel):
|
588 | 621 | #: The time point of the measurement in time units as defined in the model.
|
589 | 622 | time: float = Field(alias=C.TIME)
|
590 | 623 | #: The measurement value.
|
591 |
| - measurement: float = Field(alias=C.MEASUREMENT) |
| 624 | + measurement: Annotated[float, AfterValidator(_not_nan)] = Field( |
| 625 | + alias=C.MEASUREMENT |
| 626 | + ) |
592 | 627 | #: Values for placeholder parameters in the observable formula.
|
593 | 628 | observable_parameters: list[sp.Basic] = Field(
|
594 | 629 | alias=C.OBSERVABLE_PARAMETERS, default_factory=list
|
@@ -794,6 +829,13 @@ def __getitem__(self, petab_id: str) -> Mapping:
|
794 | 829 | return mapping
|
795 | 830 | raise KeyError(f"PEtab ID {petab_id} not found")
|
796 | 831 |
|
| 832 | + def get(self, petab_id, default=None): |
| 833 | + """Get a mapping by PEtab ID or return a default value.""" |
| 834 | + try: |
| 835 | + return self[petab_id] |
| 836 | + except KeyError: |
| 837 | + return default |
| 838 | + |
797 | 839 |
|
798 | 840 | class Parameter(BaseModel):
|
799 | 841 | """Parameter definition."""
|
@@ -893,3 +935,8 @@ def __getitem__(self, item) -> Parameter:
|
893 | 935 | if parameter.id == item:
|
894 | 936 | return parameter
|
895 | 937 | raise KeyError(f"Parameter ID {item} not found")
|
| 938 | + |
| 939 | + @property |
| 940 | + def n_estimated(self) -> int: |
| 941 | + """Number of estimated parameters.""" |
| 942 | + return sum(p.estimate for p in self.parameters) |
0 commit comments