From e679c67ebed0dc0ba8a346ff97efa19b425bb3a4 Mon Sep 17 00:00:00 2001 From: yannrichet Date: Mon, 3 Aug 2026 15:49:57 +0200 Subject: [PATCH 1/2] Fix formula format specifier to support Java DecimalFormat patterns (#.###, 0.00E00) The @{expr | pattern} formula format specifier already worked for simple fixed-decimal patterns like 0.000/0.0000 (matching Java Funz's Formula.java which delegates to java.text.DecimalFormat), but patterns using '#' digits or scientific notation ('E') were silently mishandled: '#.###' behaved like a fixed-decimal pattern (no trailing-zero stripping) and '0.00E00' produced garbage (decimal count taken from all characters after the first '.', ignoring the E-exponent entirely). Add _format_decimal_pattern()/_format_number() helpers implementing the relevant DecimalFormat subset (0 = zero-padded digit, # = significant-only digit, E = scientific notation with configurable exponent digits) and use them in both the Python and R code paths of evaluate_formulas() and evaluate_single_formula(). Also documents the formula number-formatting feature in doc/formulas-and-interpreters.md and doc/INDEX.md, which previously had no user-facing documentation despite being implemented and tested. --- NEWS.md | 11 +++ doc/INDEX.md | 1 + doc/formulas-and-interpreters.md | 29 ++++++ fz/interpreter.py | 136 ++++++++++++++++++++------ tests/test_java_funz_compatibility.py | 31 ++++++ 5 files changed, 178 insertions(+), 30 deletions(-) diff --git a/NEWS.md b/NEWS.md index 3200286..ca30a5d 100644 --- a/NEWS.md +++ b/NEWS.md @@ -2,6 +2,17 @@ ## Unreleased +### Formula number formatting (`@{expr | pattern}`) + +- Formula format specifiers now support the full `java.text.DecimalFormat` + subset used by the original Java Funz, not just fixed-decimal patterns: + `#` digits strip insignificant trailing zeros (`@{3.1 | #.###}` → `3.1`, + `@{3.0 | #.###}` → `3`) and scientific notation is supported + (`@{123456.789 | 0.00E00}` → `1.23E05`). `0` digits still zero-pad as + before (`@{1/3 | 0.0000}` → `0.3333`). Documented in + `doc/formulas-and-interpreters.md` ("Basic Formula Syntax" → "Number + Formatting"). + ### Shared static files across cases (`input_static`) - `fzr()`/`fzc()`/`fzi()`/`fzd()` gain an `input_static` parameter (CLI diff --git a/doc/INDEX.md b/doc/INDEX.md index 06afc92..c7f06e2 100644 --- a/doc/INDEX.md +++ b/doc/INDEX.md @@ -55,6 +55,7 @@ Quick reference index for finding specific topics in the FZ context documentatio | Topic | File | Section | |-------|------|---------| | Python formulas | formulas-and-interpreters.md | "Basic Formula Syntax" → "Python Formulas" | +| Formula number formatting (`@{expr \| 0.000}`) | formulas-and-interpreters.md | "Basic Formula Syntax" → "Number Formatting" | | R formulas | formulas-and-interpreters.md | "Basic Formula Syntax" → "R Formulas" | | Context lines | formulas-and-interpreters.md | "Context Lines" | | Python context examples | formulas-and-interpreters.md | "Context Lines" → "Python Context" | diff --git a/doc/formulas-and-interpreters.md b/doc/formulas-and-interpreters.md index f857896..0b18c21 100644 --- a/doc/formulas-and-interpreters.md +++ b/doc/formulas-and-interpreters.md @@ -39,6 +39,35 @@ temperature=25 temperature_K=298.15 ``` +### Number Formatting + +Formulas support a Java-Funz-compatible format specifier: append `| ` +inside the delimiters, where `` is a `java.text.DecimalFormat`-style +pattern. This works with both the Python and R interpreters, and with any +expression (constants, variables, function calls, etc.) preceding the `|`. + +- **`0`**: always show this digit, zero-padded (fixed number of decimals). +- **`#`**: show this digit only if significant (insignificant trailing zeros + are stripped). +- **`E`**: scientific notation; digits after `E` set the minimum number of + exponent digits, digits after `.` set the mantissa decimals. + +**Input template**: +```text +pi_value=@{3.14159265 | 0.000} +third=@{1/3 | 0.0000} +trimmed=@{3.1 | #.###} +sci=@{123456.789 | 0.00E00} +``` + +**Result**: +```text +pi_value=3.142 +third=0.3333 +trimmed=3.1 +sci=1.23E05 +``` + ### R Formulas **Model configuration**: diff --git a/fz/interpreter.py b/fz/interpreter.py index d063f99..104d281 100755 --- a/fz/interpreter.py +++ b/fz/interpreter.py @@ -4,10 +4,98 @@ import re import json import ast +import math from pathlib import Path from typing import Dict, List, Union, Any, Set, Optional +def _format_decimal_pattern(value: float, pattern: str) -> str: + """ + Format a number using a (non-scientific) Java DecimalFormat-like pattern, + e.g. "0.000" (fixed decimals, zero-padded) or "#.###" (up to 3 decimals, + trailing insignificant zeros stripped). + + - '0' means "always show this digit" (zero-padded) + - '#' means "show this digit only if significant" + """ + negative = value < 0 + value = abs(value) + + if '.' in pattern: + int_pattern, frac_pattern = pattern.split('.', 1) + else: + int_pattern, frac_pattern = pattern, '' + + min_int = max(int_pattern.count('0'), 1) + min_frac = frac_pattern.count('0') + max_frac = len(frac_pattern) + + rounded = round(value, max_frac) + text = f"{rounded:.{max_frac}f}" if max_frac > 0 else f"{rounded:.0f}" + + if '.' in text: + int_part, frac_part = text.split('.', 1) + else: + int_part, frac_part = text, '' + + # Strip insignificant trailing zeros down to the minimum required decimals + while len(frac_part) > min_frac and frac_part.endswith('0'): + frac_part = frac_part[:-1] + + int_part = int_part.zfill(min_int) + + result = int_part + ('.' + frac_part if frac_part else '') + if negative and float(result) != 0: + result = '-' + result + return result + + +def _format_number(value: Any, format_spec: str) -> Optional[str]: + """ + Format a numeric value using a Java-Funz/DecimalFormat-compatible pattern, + e.g. "0.000", "#.###" or scientific notation "0.00E00". + + Returns None if value isn't numeric or format_spec is empty/invalid, + so callers can fall back to the unformatted value. + """ + format_spec = (format_spec or "").strip() + if not format_spec: + return None + try: + value = float(value) + except (TypeError, ValueError): + return None + + match = re.match(r'^(?P[0#]*(?:\.[0#]*)?)[Ee](?P[0#]+)$', format_spec) + if match: + mantissa_pattern = match.group('mantissa') or '0' + exp_digits = len(match.group('exp')) + frac_pattern = mantissa_pattern.split('.', 1)[1] if '.' in mantissa_pattern else '' + mantissa_decimals = len(frac_pattern) + + if value == 0: + mantissa, exponent = 0.0, 0 + else: + exponent = int(math.floor(math.log10(abs(value)))) + mantissa = value / (10 ** exponent) + mantissa = round(mantissa, mantissa_decimals) + if abs(mantissa) >= 10: + mantissa /= 10 + exponent += 1 + elif abs(mantissa) < 1: + mantissa *= 10 + exponent -= 1 + + mantissa_str = _format_decimal_pattern(mantissa, mantissa_pattern) + sign = '-' if exponent < 0 else '' + return f"{mantissa_str}E{sign}{abs(exponent):0{exp_digits}d}" + + if '.' in format_spec or '0' in format_spec or '#' in format_spec: + return _format_decimal_pattern(value, format_spec) + + return None + + def _get_comment_char(model: Dict) -> str: """ Get comment character from model with support for multiple aliases @@ -589,12 +677,13 @@ def evaluate_single_formula(formula: str, model: Dict, input_variables: Dict, in result = eval(formula, env) # Apply format if specified - if format_spec and '.' in format_spec: - decimals = len(format_spec.split('.')[1]) - try: - return float(f"{float(result):.{decimals}f}") - except (ValueError, TypeError): - return result + if format_spec: + formatted = _format_number(result, format_spec) + if formatted is not None: + try: + return float(formatted) + except (ValueError, TypeError): + return result return result except Exception as e: @@ -657,12 +746,13 @@ def evaluate_single_formula(formula: str, model: Dict, input_variables: Dict, in value = result if not (hasattr(result, '__len__') and len(result) == 0) else result # Apply format if specified - if format_spec and '.' in format_spec: - decimals = len(format_spec.split('.')[1]) - try: - return float(f"{float(value):.{decimals}f}") - except (ValueError, TypeError): - return value + if format_spec: + formatted = _format_number(value, format_spec) + if formatted is not None: + try: + return float(formatted) + except (ValueError, TypeError): + return value return value except Exception: @@ -786,15 +876,8 @@ def replace_formula(match): # Apply format if specified if format_spec: - # Parse format like "0.0000" → 4 decimals - if '.' in format_spec: - decimals = len(format_spec.split('.')[1]) - try: - return f"{float(result):.{decimals}f}" - except (ValueError, TypeError): - return str(result) - else: - return str(result) + formatted = _format_number(result, format_spec) + return formatted if formatted is not None else str(result) else: return str(result) except Exception as e: @@ -895,15 +978,8 @@ def replace_formula(match): # Apply format if specified if format_spec: - # Parse format like "0.0000" → 4 decimals - if '.' in format_spec: - decimals = len(format_spec.split('.')[1]) - try: - return f"{float(value):.{decimals}f}" - except (ValueError, TypeError): - return str(value) - else: - return str(value) + formatted = _format_number(value, format_spec) + return formatted if formatted is not None else str(value) else: return str(value) except Exception as e: diff --git a/tests/test_java_funz_compatibility.py b/tests/test_java_funz_compatibility.py index 7f0a7ed..ef5ad19 100644 --- a/tests/test_java_funz_compatibility.py +++ b/tests/test_java_funz_compatibility.py @@ -103,6 +103,37 @@ def test_formula_with_format_specifier(): assert "0.3333" in result +def test_formula_with_hash_pattern_strips_trailing_zeros(): + """Test Java DecimalFormat '#' pattern: @{expr | #.###} keeps up to 3 + decimals but strips insignificant trailing zeros""" + model = { + "formula_prefix": "@", + "formula_delim": "{}", + "commentline": "#", + } + content = "A: @{3.14159 | #.###}\nB: @{3.1 | #.###}\nC: @{3.0 | #.###}" + result = evaluate_formulas(content, model, {}, interpreter="python") + assert "A: 3.142" in result + assert "B: 3.1" in result + assert "C: 3" in result + + +def test_formula_with_scientific_format(): + """Test Java DecimalFormat scientific notation: @{expr | 0.00E00}""" + model = { + "formula_prefix": "@", + "formula_delim": "{}", + "commentline": "#", + } + content = ( + "A: @{123456.789 | 0.00E00}\n" + "B: @{0.000123456 | 0.00E00}" + ) + result = evaluate_formulas(content, model, {}, interpreter="python") + assert "A: 1.23E05" in result + assert "B: 1.23E-04" in result + + def test_function_declaration_with_colon_prefix(): """Test Java Funz function declaration: #@: func = ...""" model = { From dde1cfedca575d282e00dac70be4deaef41171b3 Mon Sep 17 00:00:00 2001 From: yannrichet Date: Mon, 3 Aug 2026 15:55:06 +0200 Subject: [PATCH 2/2] Add tests for mixed 0/# DecimalFormat patterns (0.00##, 0.00##E00) --- tests/test_java_funz_compatibility.py | 38 +++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/test_java_funz_compatibility.py b/tests/test_java_funz_compatibility.py index ef5ad19..bcbb330 100644 --- a/tests/test_java_funz_compatibility.py +++ b/tests/test_java_funz_compatibility.py @@ -134,6 +134,44 @@ def test_formula_with_scientific_format(): assert "B: 1.23E-04" in result +def test_formula_with_mixed_zero_hash_pattern(): + """Test Java DecimalFormat mixed pattern: @{expr | 0.00##} enforces a + minimum of 2 decimals (zero-padded) and a maximum of 4 (trailing + insignificant zeros beyond the minimum are stripped)""" + model = { + "formula_prefix": "@", + "formula_delim": "{}", + "commentline": "#", + } + content = ( + "A: @{3.14159265 | 0.00##}\n" + "B: @{3.1 | 0.00##}\n" + "C: @{3 | 0.00##}" + ) + result = evaluate_formulas(content, model, {}, interpreter="python") + assert "A: 3.1416" in result + assert "B: 3.10" in result + assert "C: 3.00" in result + + +def test_formula_with_mixed_zero_hash_scientific_format(): + """Test Java DecimalFormat mixed scientific pattern: @{expr | 0.00##E00}""" + model = { + "formula_prefix": "@", + "formula_delim": "{}", + "commentline": "#", + } + content = ( + "A: @{123456.789 | 0.00##E00}\n" + "B: @{123000000 | 0.00##E00}\n" + "C: @{0.000123456 | 0.00##E00}" + ) + result = evaluate_formulas(content, model, {}, interpreter="python") + assert "A: 1.2346E05" in result + assert "B: 1.23E08" in result + assert "C: 1.2346E-04" in result + + def test_function_declaration_with_colon_prefix(): """Test Java Funz function declaration: #@: func = ...""" model = {