|
1 | | -"""ExpressionNode AST evaluator (Python port of the ESS evaluator surface). |
2 | | -
|
3 | | -Mirrors ``EarthSciSerialization.evaluate`` (see |
4 | | -``EarthSciSerialization.jl/src/expression.jl``) for the operator set used |
5 | | -by the discretization rule catalog. The bindings match |
6 | | -``src/rule_eval.jl`` in the Julia binding: ``eval_coeff(node, bindings)`` |
7 | | -takes a JSON-decoded AST node (number / variable string / op-dict) and |
8 | | -returns a Python ``float``. |
9 | | -
|
10 | | -Op coverage (kept in sync with the Julia ``evaluate`` path): |
11 | | -
|
12 | | -* arithmetic: ``+ - * / ^`` |
13 | | -* unary functions: ``sin cos tan exp log sqrt abs`` |
14 | | -* constants (zero-arg ops): ``pi`` (alias ``π``), ``e`` |
15 | | -
|
16 | | -Unbound variables raise :class:`UnboundVariableError`. Domain errors on |
17 | | -``log`` / ``sqrt`` raise :class:`ValueError` to match the ESS behaviour |
18 | | -where ``DomainError`` propagates out of ``evaluate``. |
19 | | -
|
20 | | -Ops that the catalog does not use yet (``fn``, ``const``, ``enum``, ``call``, |
21 | | -slope-ratio ``max``/``min`` until ESS lands them) are deliberately rejected |
22 | | -so that an unsupported op surfaces immediately rather than producing a |
23 | | -silent wrong answer. |
| 1 | +"""Coefficient evaluator (thin adapter over ``earthsci_toolkit.evaluate``). |
| 2 | +
|
| 3 | +The discretization rule catalog stores coefficients as JSON-decoded |
| 4 | +``ExpressionNode`` ASTs (``int`` / ``float`` / variable name / dict). ESS |
| 5 | +``earthsci_toolkit.evaluate`` consumes the typed |
| 6 | +:class:`earthsci_toolkit.ExprNode` form, so this module converts the dict |
| 7 | +shape to ``ExprNode`` once and delegates — there is no parallel |
| 8 | +operator-dispatch table here. The Julia binding does the same in |
| 9 | +``src/rule_eval.jl``. |
24 | 10 | """ |
25 | 11 |
|
26 | 12 | from __future__ import annotations |
27 | 13 |
|
28 | | -import math |
29 | 14 | from collections.abc import Mapping |
30 | 15 | from typing import Any |
31 | 16 |
|
32 | | -__all__ = ["UnboundVariableError", "eval_coeff"] |
33 | | - |
34 | | - |
35 | | -class UnboundVariableError(KeyError): |
36 | | - """Raised when an AST references a variable not present in ``bindings``.""" |
37 | | - |
38 | | - def __init__(self, name: str) -> None: |
39 | | - super().__init__(name) |
40 | | - self.variable_name = name |
41 | | - |
42 | | - def __str__(self) -> str: |
43 | | - return f"UnboundVariableError: variable {self.variable_name!r} not found in bindings" |
| 17 | +from earthsci_toolkit.esm_types import ExprNode |
44 | 18 |
|
| 19 | +from earthsci_toolkit import evaluate |
45 | 20 |
|
46 | | -_UNARY_FUNCS: dict[str, Any] = { |
47 | | - "sin": math.sin, |
48 | | - "cos": math.cos, |
49 | | - "tan": math.tan, |
50 | | - "exp": math.exp, |
51 | | - "abs": abs, |
52 | | -} |
| 21 | +__all__ = ["eval_coeff"] |
53 | 22 |
|
54 | | -_CONSTANTS: dict[str, float] = { |
55 | | - "pi": math.pi, |
56 | | - "π": math.pi, |
57 | | - "e": math.e, |
58 | | -} |
59 | 23 |
|
| 24 | +_EXPR_NODE_FIELDS = {f for f in ExprNode.__dataclass_fields__ if f != "args"} |
60 | 25 |
|
61 | | -def eval_coeff(node: Any, bindings: Mapping[str, float]) -> float: |
62 | | - """Evaluate a JSON-decoded ExpressionNode against scalar bindings. |
63 | | -
|
64 | | - Parameters |
65 | | - ---------- |
66 | | - node: |
67 | | - ``int`` / ``float`` literal, ``str`` variable name, or ``dict`` |
68 | | - with ``"op"`` and ``"args"`` keys (the JSON form produced by |
69 | | - ``json.load`` on a rule file's ``"coeff"`` field). |
70 | | - bindings: |
71 | | - Mapping of variable name to numeric value. Values are coerced to |
72 | | - ``float``. |
73 | | -
|
74 | | - Returns |
75 | | - ------- |
76 | | - float |
77 | | - The numeric value of the AST. |
78 | | - """ |
79 | 26 |
|
| 27 | +def _to_expr(node: Any) -> Any: |
80 | 28 | if isinstance(node, bool): |
81 | | - # ``bool`` subclasses ``int`` in Python; reject explicitly to match |
82 | | - # the ESS parser, which rejects boolean literals. |
| 29 | + # ``bool`` subclasses ``int``; reject so boolean literals don't sneak |
| 30 | + # through as 0/1 (mirrors ESS parser behaviour). |
83 | 31 | raise ValueError("Boolean literal is not a valid expression node") |
84 | | - if isinstance(node, (int, float)): |
85 | | - return float(node) |
86 | | - if isinstance(node, str): |
87 | | - try: |
88 | | - return float(bindings[node]) |
89 | | - except KeyError as exc: |
90 | | - raise UnboundVariableError(node) from exc |
| 32 | + if isinstance(node, (int, float, str)): |
| 33 | + return node |
91 | 34 | if isinstance(node, Mapping): |
92 | 35 | op = node.get("op") |
93 | | - if op is None: |
| 36 | + if not isinstance(op, str): |
94 | 37 | raise ValueError(f"AST dict node missing 'op' key: {node!r}") |
95 | | - return _eval_op(str(op), node, bindings) |
| 38 | + kwargs: dict[str, Any] = {"op": op} |
| 39 | + raw_args = node.get("args") |
| 40 | + if raw_args is not None: |
| 41 | + kwargs["args"] = [_to_expr(a) for a in raw_args] |
| 42 | + for k, v in node.items(): |
| 43 | + if k in {"op", "args"}: |
| 44 | + continue |
| 45 | + if k in _EXPR_NODE_FIELDS: |
| 46 | + kwargs[k] = v |
| 47 | + return ExprNode(**kwargs) |
96 | 48 | raise TypeError( |
97 | 49 | f"Invalid AST node type {type(node).__name__}; expected number, string, or mapping" |
98 | 50 | ) |
99 | 51 |
|
100 | 52 |
|
101 | | -def _eval_op(op: str, node: Mapping[str, Any], bindings: Mapping[str, float]) -> float: |
102 | | - # Constants are the only zero-arg ops the catalog uses today; check |
103 | | - # before walking ``args`` so a malformed args list doesn't shadow the |
104 | | - # zero-arg branch. |
105 | | - if op in _CONSTANTS: |
106 | | - args = node.get("args") or [] |
107 | | - if len(args) != 0: |
108 | | - raise ValueError(f"{op!r} constant takes no arguments, got {len(args)}") |
109 | | - return _CONSTANTS[op] |
110 | | - |
111 | | - raw_args = node.get("args") |
112 | | - if raw_args is None: |
113 | | - raise ValueError(f"AST op {op!r} missing 'args'") |
114 | | - args = [eval_coeff(a, bindings) for a in raw_args] |
115 | | - n = len(args) |
| 53 | +def eval_coeff(node: Any, bindings: Mapping[str, float]) -> float: |
| 54 | + """Evaluate a JSON-decoded ExpressionNode against scalar bindings. |
116 | 55 |
|
117 | | - if op == "+": |
118 | | - if n == 1: |
119 | | - return args[0] |
120 | | - if n == 0: |
121 | | - return 0.0 |
122 | | - return math.fsum(args) |
123 | | - if op == "-": |
124 | | - if n == 1: |
125 | | - return -args[0] |
126 | | - if n == 2: |
127 | | - return args[0] - args[1] |
128 | | - raise ValueError(f"Subtraction requires 1 or 2 arguments, got {n}") |
129 | | - if op == "*": |
130 | | - if n == 0: |
131 | | - return 1.0 |
132 | | - if n == 1: |
133 | | - return args[0] |
134 | | - product = 1.0 |
135 | | - for v in args: |
136 | | - product *= v |
137 | | - return product |
138 | | - if op == "/": |
139 | | - if n != 2: |
140 | | - raise ValueError(f"Division requires exactly 2 arguments, got {n}") |
141 | | - if args[1] == 0.0: |
142 | | - raise ZeroDivisionError("division by zero in AST evaluation") |
143 | | - return args[0] / args[1] |
144 | | - if op == "^": |
145 | | - if n != 2: |
146 | | - raise ValueError(f"Exponentiation requires exactly 2 arguments, got {n}") |
147 | | - return args[0] ** args[1] |
148 | | - if op in _UNARY_FUNCS: |
149 | | - if n != 1: |
150 | | - raise ValueError(f"{op!r} requires exactly 1 argument, got {n}") |
151 | | - return _UNARY_FUNCS[op](args[0]) |
152 | | - if op == "log": |
153 | | - if n != 1: |
154 | | - raise ValueError(f"'log' requires exactly 1 argument, got {n}") |
155 | | - if args[0] <= 0.0: |
156 | | - raise ValueError(f"log argument must be positive, got {args[0]}") |
157 | | - return math.log(args[0]) |
158 | | - if op == "sqrt": |
159 | | - if n != 1: |
160 | | - raise ValueError(f"'sqrt' requires exactly 1 argument, got {n}") |
161 | | - if args[0] < 0.0: |
162 | | - raise ValueError(f"sqrt argument must be non-negative, got {args[0]}") |
163 | | - return math.sqrt(args[0]) |
| 56 | + Returns the numeric value of the AST as a ``float``. Unbound variables |
| 57 | + raise :class:`ValueError` (propagated from |
| 58 | + :func:`earthsci_toolkit.evaluate`). |
| 59 | + """ |
164 | 60 |
|
165 | | - raise ValueError(f"Unsupported AST operator: {op!r}") |
| 61 | + return float(evaluate(_to_expr(node), dict(bindings))) |
0 commit comments