From 0586d594bf34d1b2ac72d8798e8fc0b3698b7695 Mon Sep 17 00:00:00 2001 From: Bill Hlavacek Date: Sat, 20 Jun 2026 12:21:18 -0600 Subject: [PATCH] Fix PetabStrPrinter for non-integer rational exponents A non-integer Rational exponent (e.g. a square root, exponent 1/2) is a sympy Atom but prints as the multi-token "1/2", so PetabStrPrinter emitted `sqrt(a)` as the unparenthesized `a ^ 1/2`. Since `^` binds tighter than `/`, that re-parses as `(a^1)/2 = a/2` -- a silent round-trip corruption (`petab_math_str(sympify_petab(...))` is not the identity for square roots). The `not exp.is_Atom` guard added in #421 covers non-atomic exponents but not this atomic-yet-multi-token case; parenthesize a non-integer rational exponent explicitly, so `petab_math_str(sqrt(a)) == "a ^ (1/2)"`, which re-parses correctly. Integer powers and the #421 cases are unchanged. --- petab/v1/math/printer.py | 5 ++++- tests/v1/math/test_math.py | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/petab/v1/math/printer.py b/petab/v1/math/printer.py index a421989c..d1f7103e 100644 --- a/petab/v1/math/printer.py +++ b/petab/v1/math/printer.py @@ -41,7 +41,10 @@ def _print_Pow(self, expr: sp.Pow): str_exp = self._print(exp) if not base.is_Atom: str_base = f"({str_base})" - if not exp.is_Atom: + # A non-integer Rational exponent (e.g. sqrt -> 1/2) is an Atom but prints as the + # multi-token "1/2", so without parentheses "x ^ 1/2" re-parses as (x^1)/2. The + # `not exp.is_Atom` check above (#421) misses it; parenthesize it explicitly. + if not exp.is_Atom or (exp.is_Rational and not exp.is_Integer): str_exp = f"({str_exp})" return f"{str_base} ^ {str_exp}" diff --git a/tests/v1/math/test_math.py b/tests/v1/math/test_math.py index 60bb04b5..3c476c6e 100644 --- a/tests/v1/math/test_math.py +++ b/tests/v1/math/test_math.py @@ -43,6 +43,10 @@ def test_printer(): assert petab_math_str(BooleanTrue()) == "true" assert petab_math_str(BooleanFalse()) == "false" assert petab_math_str((a + b) ** (c + d)) == "(a + b) ^ (c + d)" + # A non-integer rational exponent must be parenthesized, else "a ^ 1/2" re-parses as + # (a^1)/2 (i.e. sqrt(a) would round-trip to a/2). See #421 for the base/exponent case. + assert petab_math_str(sp.sqrt(a)) == "a ^ (1/2)" + assert petab_math_str(a ** sp.Rational(2, 3)) == "a ^ (2/3)" def read_cases():