Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions doc/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -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" |
Expand Down
29 changes: 29 additions & 0 deletions doc/formulas-and-interpreters.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,35 @@ temperature=25
temperature_K=298.15
```

### Number Formatting

Formulas support a Java-Funz-compatible format specifier: append `| <pattern>`
inside the delimiters, where `<pattern>` 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**:
Expand Down
136 changes: 106 additions & 30 deletions fz/interpreter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<mantissa>[0#]*(?:\.[0#]*)?)[Ee](?P<exp>[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}"

Comment on lines +69 to +92
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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
69 changes: 69 additions & 0 deletions tests/test_java_funz_compatibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,75 @@ 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
Comment on lines +114 to +118


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
Comment on lines +128 to +134


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 = {
Expand Down
Loading