Skip to content

Commit 663b383

Browse files
dweindlclaude
andauthored
Fix invalid and lossy output of petab_math_str (#545)
Make `petab_math_str` produce valid PEtab math expressions that round-trip, and use it when writing v2 condition and measurement tables. `PetabStrPrinter` now: * prints `&&`, `||`, `!`, `==`, `!=` instead of sympy's `&`, `|`, `~`, `Eq(...)`, `Ne(...)` * parenthesizes negative and non-integer rational bases of powers (`(-2) ^ x` was printed as `-2.0 ^ x`) * prints n-ary `Min`/`Max` as nested binary `min`/`max` * prints `pi` as its double value and `E` as `exp(1)` * prints double-precision floats with the shortest exact representation (`0.6666666666666666` instead of `0.666666666666667`), and integer-valued floats as integers (`100*x` instead of `100.0*x`) * raises for `Piecewise` without a default condition instead of dropping the last condition `ConditionTable.to_df` and `MeasurementTable.to_df` now use `petab_math_str` instead of `str`, so expressions are written in PEtab syntax and float values are not truncated. Fixes #544. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 539333b commit 663b383

5 files changed

Lines changed: 176 additions & 8 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@
2929
https://github.com/PEtab-dev/libpetab-python/pull/533,
3030
https://github.com/PEtab-dev/libpetab-python/pull/535, and
3131
https://github.com/PEtab-dev/libpetab-python/pull/536)
32+
* `petab_math_str` now produces valid PEtab math expressions for boolean
33+
operators, (in)equality, `min`/`max`, powers with negative bases, and
34+
`pi`/`e`, and prints floats without loss of precision. PEtab v2 condition
35+
and measurement tables now write expressions in PEtab syntax
36+
(by @dweindl in https://github.com/PEtab-dev/libpetab-python/pull/545)
3237

3338
**Features**
3439
* `petab1to2` and `petab_files_1to2` can skip validation via `validate=False`

‎petab/v1/math/printer.py‎

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
"""A PEtab-compatible sympy string-printer."""
22

33
import typing
4+
from functools import reduce
45
from itertools import chain, islice
56

67
import sympy as sp
8+
from sympy.logic.boolalg import Boolean
79
from sympy.printing.str import StrPrinter
810

911
__all__ = ["PetabStrPrinter", "petab_math_str"]
@@ -35,12 +37,51 @@ def _print_BooleanTrue(self, expr):
3537
def _print_BooleanFalse(self, expr):
3638
return "false"
3739

40+
def _print_Float(self, expr: sp.Float):
41+
"""Print double-precision floats such that they round-trip exactly."""
42+
if expr._prec != 53:
43+
return super()._print_Float(expr)
44+
value = float(expr)
45+
if value.is_integer() and abs(value) < 1e16:
46+
return str(int(value))
47+
return repr(value)
48+
49+
def _print_bool_operand(self, expr) -> str:
50+
"""Print an operand of a boolean or relational operator."""
51+
if isinstance(expr, Boolean) and not expr.is_Atom:
52+
return f"({self._print(expr)})"
53+
return self._print(expr)
54+
55+
def _print_Relational(self, expr):
56+
"""Custom printing for comparisons (sympy prints ``Eq(a, b)``)"""
57+
lhs = self._print_bool_operand(expr.lhs)
58+
rhs = self._print_bool_operand(expr.rhs)
59+
return f"{lhs} {expr.rel_op} {rhs}"
60+
61+
def _print_And(self, expr):
62+
"""Custom printing for logical AND"""
63+
return " && ".join(map(self._print_bool_operand, expr.args))
64+
65+
def _print_Or(self, expr):
66+
"""Custom printing for logical OR"""
67+
return " || ".join(map(self._print_bool_operand, expr.args))
68+
69+
def _print_Not(self, expr):
70+
"""Custom printing for logical NOT"""
71+
return f"!{self._print_bool_operand(expr.args[0])}"
72+
3873
def _print_Pow(self, expr: sp.Pow):
3974
"""Custom printing for the power operator"""
4075
base, exp = expr.as_base_exp()
4176
str_base = self._print(base)
4277
str_exp = self._print(exp)
43-
if not base.is_Atom:
78+
# A negative or non-integer rational base is an Atom, but without
79+
# parentheses "-2 ^ x" or "1/2 ^ x" would re-parse as -(2^x) or 1/(2^x)
80+
if (
81+
not base.is_Atom
82+
or (base.is_number and base.is_negative)
83+
or (base.is_Rational and not base.is_Integer)
84+
):
4485
str_base = f"({str_base})"
4586
# A non-integer Rational exponent (e.g. sqrt -> 1/2) is an Atom but
4687
# prints as the multi-token "1/2", so without parentheses "x ^ 1/2"
@@ -49,6 +90,14 @@ def _print_Pow(self, expr: sp.Pow):
4990
str_exp = f"({str_exp})"
5091
return f"{str_base} ^ {str_exp}"
5192

93+
def _print_Pi(self, expr):
94+
"""PEtab has no pi constant; print its double-precision value"""
95+
return repr(float(expr))
96+
97+
def _print_Exp1(self, expr):
98+
"""Custom printing for Euler's number (no constant in PEtab)"""
99+
return "exp(1)"
100+
52101
def _print_Infinity(self, expr):
53102
"""Custom printing for infinity"""
54103
return "inf"
@@ -70,6 +119,11 @@ def _print_Function(self, expr):
70119

71120
def _print_Piecewise(self, expr):
72121
"""Custom printing for Piecewise function"""
122+
if expr.args[-1].cond != sp.true:
123+
raise ValueError(
124+
"Piecewise without a default value cannot be represented "
125+
f"in PEtab: {expr}"
126+
)
73127
# merge the tuples and drop the final `True` condition
74128
str_args = map(
75129
self._print,
@@ -78,12 +132,18 @@ def _print_Piecewise(self, expr):
78132
return f"piecewise({', '.join(str_args)})"
79133

80134
def _print_Min(self, expr):
81-
"""Custom printing for Min function"""
82-
return f"min({', '.join(map(self._print, expr.args))})"
135+
"""Custom printing for Min function (binary in PEtab)"""
136+
return reduce(
137+
lambda acc, arg: f"min({acc}, {arg})",
138+
map(self._print, expr.args),
139+
)
83140

84141
def _print_Max(self, expr):
85-
"""Custom printing for Max function"""
86-
return f"max({', '.join(map(self._print, expr.args))})"
142+
"""Custom printing for Max function (binary in PEtab)"""
143+
return reduce(
144+
lambda acc, arg: f"max({acc}, {arg})",
145+
map(self._print, expr.args),
146+
)
87147

88148

89149
def petab_math_str(expr: sp.Basic | sp.Expr | None) -> str:

‎petab/v2/core.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -561,7 +561,7 @@ def to_df(self) -> pd.DataFrame:
561561
record[C.TARGET_VALUE] = (
562562
float(record[C.TARGET_VALUE])
563563
if record[C.TARGET_VALUE].is_number
564-
else str(record[C.TARGET_VALUE])
564+
else petab_math_str(record[C.TARGET_VALUE])
565565
)
566566
return (
567567
pd.DataFrame(records)
@@ -840,10 +840,10 @@ def to_df(self) -> pd.DataFrame:
840840
records = self.model_dump(by_alias=True)["elements"]
841841
for record in records:
842842
record[C.OBSERVABLE_PARAMETERS] = C.PARAMETER_SEPARATOR.join(
843-
map(str, record[C.OBSERVABLE_PARAMETERS])
843+
map(petab_math_str, record[C.OBSERVABLE_PARAMETERS])
844844
)
845845
record[C.NOISE_PARAMETERS] = C.PARAMETER_SEPARATOR.join(
846-
map(str, record[C.NOISE_PARAMETERS])
846+
map(petab_math_str, record[C.NOISE_PARAMETERS])
847847
)
848848

849849
return pd.DataFrame(records)

‎tests/v1/math/test_math.py‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,65 @@ def test_printer():
4747
# re-parses as (a^1)/2 (i.e. sqrt(a) would round-trip to a/2).
4848
assert petab_math_str(sp.sqrt(a)) == "a ^ (1/2)"
4949
assert petab_math_str(a ** sp.Rational(2, 3)) == "a ^ (2/3)"
50+
# negative or non-integer rational bases must be parenthesized
51+
assert petab_math_str(sp.Float(-2) ** a) == "(-2) ^ a"
52+
assert petab_math_str(sp.Rational(2, 3) ** a) == "(2/3) ^ a"
53+
# boolean and relational operators
54+
assert petab_math_str(sp.And(a > 0, b < 1)) == "(a > 0) && (b < 1)"
55+
assert petab_math_str(sp.Or(a > 0, b < 1)) == "(a > 0) || (b < 1)"
56+
assert petab_math_str(sp.Not(sp.And(a > 0, b < 1))) == (
57+
"!((a > 0) && (b < 1))"
58+
)
59+
assert petab_math_str(sp.Eq(a, 1)) == "a == 1"
60+
assert petab_math_str(sp.Ne(a, 1)) == "a != 1"
61+
# min/max are binary in PEtab
62+
assert petab_math_str(sp.Min(a, b, c)) == "min(min(a, b), c)"
63+
assert petab_math_str(sp.Max(a, b, c)) == "max(max(a, b), c)"
64+
# no pi / e constants in PEtab
65+
assert petab_math_str(sp.pi) == "3.141592653589793"
66+
assert petab_math_str(sp.E) == "exp(1)"
67+
assert petab_math_str(sp.E * a) == "exp(1)*a"
68+
# floats round-trip exactly; integer-valued floats are printed as integers
69+
assert petab_math_str(sp.Float(2.0) * a) == "2*a"
70+
assert petab_math_str(sp.Float(2 / 3) * a) == "0.6666666666666666*a"
71+
assert petab_math_str(sp.Float(1e-300)) == "1e-300"
72+
assert petab_math_str(sympify_petab("1.2345678901234567 * a")) == (
73+
"1.2345678901234567*a"
74+
)
75+
76+
with pytest.raises(ValueError, match="default"):
77+
petab_math_str(sp.Piecewise((1, a > 0)))
78+
79+
80+
@pytest.mark.parametrize(
81+
"expr_str",
82+
[
83+
"100 * x",
84+
"x ^ 2",
85+
"2 * x / 3",
86+
"log(x, 10)",
87+
"1.2345678901234567 * x",
88+
"(-2) ^ x",
89+
"(1 / 3) ^ x",
90+
"x ^ -0.5 * y",
91+
"piecewise(1, x > 0 && y < 2 || x == y, 0)",
92+
"piecewise(1, !(x > 0 && y < 2), 0)",
93+
"piecewise(1, x != y, 0)",
94+
"min(min(x, y), 3) + max(x, max(y, 2))",
95+
"2 * arccos(0) + exp(1)",
96+
],
97+
)
98+
@pytest.mark.parametrize("evaluate", [True, False])
99+
def test_print_parse_round_trip(expr_str, evaluate):
100+
"""Parsing, printing, and parsing again gives the same value."""
101+
expr = sympify_petab(expr_str, evaluate=evaluate)
102+
printed = petab_math_str(expr)
103+
reparsed = sympify_petab(printed, evaluate=evaluate)
104+
x, y = sp.symbols("x y", real=True)
105+
values = {x: 0.7, y: -1.3}
106+
expected = complex(expr.subs(values))
107+
actual = complex(reparsed.subs(values))
108+
assert np.isclose(actual, expected, rtol=1e-15, atol=0), printed
50109

51110

52111
def read_cases():

‎tests/v2/test_core.py‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,50 @@ def test_condition_table():
461461
).free_symbols == {x, y}
462462

463463

464+
def test_to_df_math_expressions():
465+
"""Expressions are written in PEtab syntax and without precision loss.
466+
467+
Previously, floats were written with only 15 significant digits.
468+
"""
469+
conditions = ConditionTable(
470+
[
471+
Condition(
472+
id="c1",
473+
changes=[
474+
Change(target_id="k1", target_value="k2 ^ 2"),
475+
Change(
476+
target_id="k3",
477+
target_value="piecewise(1, k2 > 0 && k2 < 2, 0)",
478+
),
479+
],
480+
)
481+
]
482+
)
483+
df = conditions.to_df()
484+
assert df[TARGET_VALUE].tolist() == [
485+
"k2 ^ 2",
486+
"piecewise(1, (k2 < 2) && (k2 > 0), 0)",
487+
]
488+
assert_frame_equal(ConditionTable.from_df(df).to_df(), df)
489+
490+
measurements = MeasurementTable(
491+
[
492+
Measurement(
493+
observable_id="obs1",
494+
time=1,
495+
experiment_id="exp1",
496+
measurement=1,
497+
observable_parameters="x ^ y;p1",
498+
noise_parameters=2 / 3,
499+
)
500+
]
501+
)
502+
df = measurements.to_df()
503+
assert df[C.OBSERVABLE_PARAMETERS].tolist() == ["x ^ y;p1"]
504+
# full double precision, not str(sp.Float(2 / 3)) == "0.666666666666667"
505+
assert df[C.NOISE_PARAMETERS].tolist() == ["0.6666666666666666"]
506+
507+
464508
def test_load_remote():
465509
"""Test loading remote files"""
466510
from jsonschema.exceptions import ValidationError

0 commit comments

Comments
 (0)