Skip to content

Commit dcf393d

Browse files
Merge polecat/quartz-moinn85r (dsc-wmq)
2 parents bb8f1fa + 25de67a commit dcf393d

7 files changed

Lines changed: 117 additions & 308 deletions

File tree

python/src/earthsci_discretizations/rules/__init__.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@
88
99
Cross-binding contract:
1010
11-
* :func:`eval_coeff` matches the Julia ``EarthSciDiscretizations.eval_coeff``
12-
passthrough (which delegates to ``EarthSciSerialization.evaluate``) for
13-
every op the rule catalog actually uses today: ``+ - * / ^``, the unary
14-
function set (``sin cos tan exp log sqrt abs``), and the constants
15-
``pi`` / ``e``. Unbound variables raise :class:`UnboundVariableError`.
11+
* :func:`eval_coeff` is a thin adapter over
12+
``earthsci_toolkit.evaluate`` (ESS): JSON dict nodes are converted to
13+
the typed :class:`earthsci_toolkit.ExprNode` form once, then ESS's
14+
evaluator returns the scalar value. The Julia binding's
15+
``EarthSciDiscretizations.eval_coeff`` does the same passthrough to
16+
``EarthSciSerialization.evaluate``. Unbound variables raise
17+
:class:`ValueError` (propagated from ESS).
1618
* :func:`load_rule` reads a rule JSON file and returns a :class:`Rule`.
1719
Single-stencil rules expose entries via :attr:`Rule.stencil`;
1820
multi-stencil rules (PPM-style; ESS §7.5) expose them via
@@ -35,15 +37,14 @@
3537
reference_samples,
3638
resolve_sub_stencil,
3739
)
38-
from .evaluator import UnboundVariableError, eval_coeff
40+
from .evaluator import eval_coeff
3941
from .loader import Rule, StencilEntry, load_rule
4042
from .stencil import apply_stencil_latlon
4143

4244
__all__ = [
4345
"OUTPUT_KINDS",
4446
"Rule",
4547
"StencilEntry",
46-
"UnboundVariableError",
4748
"apply_stencil_latlon",
4849
"apply_stencil_periodic_1d",
4950
"eval_coeff",
Lines changed: 36 additions & 140 deletions
Original file line numberDiff line numberDiff line change
@@ -1,165 +1,61 @@
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``.
2410
"""
2511

2612
from __future__ import annotations
2713

28-
import math
2914
from collections.abc import Mapping
3015
from typing import Any
3116

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
4418

19+
from earthsci_toolkit import evaluate
4520

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"]
5322

54-
_CONSTANTS: dict[str, float] = {
55-
"pi": math.pi,
56-
"π": math.pi,
57-
"e": math.e,
58-
}
5923

24+
_EXPR_NODE_FIELDS = {f for f in ExprNode.__dataclass_fields__ if f != "args"}
6025

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-
"""
7926

27+
def _to_expr(node: Any) -> Any:
8028
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).
8331
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
9134
if isinstance(node, Mapping):
9235
op = node.get("op")
93-
if op is None:
36+
if not isinstance(op, str):
9437
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)
9648
raise TypeError(
9749
f"Invalid AST node type {type(node).__name__}; expected number, string, or mapping"
9850
)
9951

10052

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.
11655
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+
"""
16460

165-
raise ValueError(f"Unsupported AST operator: {op!r}")
61+
return float(evaluate(_to_expr(node), dict(bindings)))

python/tests/test_centered_2nd_uniform_latlon_rule.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,10 +118,8 @@ def test_stencil_application(rule, fixtures, golden):
118118

119119

120120
def test_eval_coeff_unbound_variable_raises(rule):
121-
from earthsci_discretizations.rules import UnboundVariableError
122-
123121
entry = rule.stencil[0]
124-
with pytest.raises(UnboundVariableError):
122+
with pytest.raises(ValueError, match="[Uu]nbound variable"):
125123
eval_coeff(entry.coeff, {"R": 1.0, "dlon": 0.1}) # missing cos_lat
126124

127125

typescript/package-lock.json

Lines changed: 40 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)