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():