5@file source_expressions.py
6@brief The source expression language on the Python side: its functions and variables, validation,
7 constant substitution, region lowering and NumPy evaluation.
8@details The CLI checks every source with it before a run; `generators/ic.gen` evaluates one with
9 it. The solver evaluates the same language in `src/expression.c`, and
10 `tests/test_expression_language.py` holds the two to one conformance fixture.
27 "erf": np.vectorize(math.erf, otypes=[float]),
31 "maximum": np.maximum,
32 "minimum": np.minimum,
42FUNCTION_ARITY = {
"abs": 1,
"cos": 1,
"cosh": 1,
"erf": 1,
"exp": 1,
"floor": 1,
"log": 1,
"sin": 1,
43 "sinh": 1,
"sqrt": 1,
"tan": 1,
"tanh": 1,
"atan2": 2,
"maximum": 2,
"minimum": 2,
44 "where": 3,
"besselj": 2,
"bessely": 2,
"ber": 1,
"bei": 1,
"sum": 4}
47SOLVER_ONLY_FUNCTIONS = (
"besselj",
"bessely",
"ber",
"bei")
49MAX_SERIES_TERMS = 1000000
54RANDOM_FUNCTIONS = (
"uniform",
"normal")
56EULERIAN_EXPRESSION_NAMES = frozenset({
"x",
"y",
"z",
"xi",
"eta",
"zeta",
"i",
"j",
"k"})
59PARTICLE_EXPRESSION_NAMES = frozenset({
"x",
"y",
"z",
"t",
"xi",
"eta",
"zeta",
"L_xi",
"L_eta",
"L_zeta",
"pid"})
60ALLOWED_NAMES = set(ALLOWED_FUNCTIONS) | EULERIAN_EXPRESSION_NAMES | {
"pi"}
61_DECIMAL_LITERAL = re.compile(
r"^(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$")
94 @brief Reject anything outside the expression language the runtime also evaluates.
95 @details Python expression syntax restricted to decimal numbers, the given names, `pi`,
96 arithmetic, comparisons (chains included), `and`/`or`/`not`, and the
97 documented functions at their fixed arities. `uniform(n)`/`normal(n)` are
98 accepted only when @p random is true, with an optional stream `n` that must be
99 a non-negative integer literal.
100 @param[in] expression User expression.
101 @param[in] names Variables in scope, besides `pi`.
102 @param[in] random Whether per-particle random draws may be called.
103 @throws ValueError naming the first construct outside the language.
105 if not isinstance(expression, str)
or not expression.strip():
106 raise ValueError(
"an expression must be a non-empty string.")
107 if "\n" in expression
or "\r" in expression:
108 raise ValueError(
"an expression must fit on one line.")
110 tree = ast.parse(expression, mode=
"eval")
111 tokens =
list(tokenize.generate_tokens(io.StringIO(expression).readline))
112 except (SyntaxError, tokenize.TokenError)
as exc:
113 raise ValueError(f
"expression '{expression}' is not valid syntax: {exc}.")
from exc
115 if token.type == tokenize.NUMBER
and not _DECIMAL_LITERAL.match(token.string):
116 raise ValueError(f
"'{token.string}' is not a decimal number; write numbers like 1, 0.5, or 2e-3.")
122 @brief The integer a series bound denotes: an integer literal, possibly negated.
123 @param[in] node Bound node.
125 @throws ValueError for anything else.
128 if isinstance(node, ast.UnaryOp)
and isinstance(node.op, (ast.USub, ast.UAdd)):
129 sign, node = (-1
if isinstance(node.op, ast.USub)
else 1), node.operand
130 if (
not isinstance(node, ast.Constant)
or type(node.value)
not in (int, float)
131 or node.value != int(node.value)):
132 raise ValueError(
"series bounds must be integer numbers.")
133 return sign * int(node.value)
138 @brief Check one node of a parsed expression, with the series indices enclosing it in scope.
139 @param[in] node AST node.
140 @param[in] names Variables in scope, `pi` included.
141 @param[in] indices Indices of the enclosing series, outermost first.
142 @param[in] random Whether per-particle random draws may be called.
143 @throws ValueError naming the first construct outside the language.
145 if not isinstance(node, ALLOWED_AST_NODES):
146 raise ValueError(f
"unsupported expression syntax: {type(node).__name__}.")
147 if isinstance(node, ast.Constant)
and type(node.value)
not in (int, float):
148 raise ValueError(f
"only numbers may appear as constants, not {node.value!r}.")
149 if isinstance(node, ast.Name):
150 if node.id
not in names
and node.id
not in indices:
151 raise ValueError(f
"unsupported expression name '{node.id}'.")
153 if isinstance(node, ast.Call):
154 name = node.func.id
if isinstance(node.func, ast.Name)
else None
156 raise ValueError(
"functions take positional arguments only.")
157 if name
in RANDOM_FUNCTIONS
and random:
158 if len(node.args) > 1:
159 raise ValueError(f
"{name}() takes at most one stream argument.")
161 stream = node.args[0]
162 if (
not isinstance(stream, ast.Constant)
or type(stream.value)
not in (int, float)
163 or stream.value < 0
or stream.value != int(stream.value)):
164 raise ValueError(f
"the stream of {name}() must be a non-negative integer literal.")
166 if name
not in FUNCTION_ARITY:
167 raise ValueError(f
"'{name}' is not an available function.")
168 if len(node.args) != FUNCTION_ARITY[name]:
169 raise ValueError(f
"{name}() takes {FUNCTION_ARITY[name]} argument(s).")
171 index, lower, upper, body = node.args
172 if not isinstance(index, ast.Name):
173 raise ValueError(
"a series sum(k, lo, hi, body) needs an index name first.")
174 if index.id
in names
or index.id
in indices:
175 raise ValueError(f
"the series index '{index.id}' reuses a name already in scope.")
176 if len(indices) == MAX_SERIES_DEPTH:
177 raise ValueError(f
"series may nest at most {MAX_SERIES_DEPTH} deep.")
179 raise ValueError(f
"a series may add at most {MAX_SERIES_TERMS} terms.")
182 for argument
in node.args:
185 for child
in ast.iter_child_nodes(node):
191 @brief Reject Eulerian expression syntax outside the supported language.
192 @param[in] expression User expression.
199 @brief Rewrite logical operators and chained comparisons into array operations.
200 @details `and`, `or`, `not` and a chain `a < b < c` have no elementwise meaning for
201 arrays, so they become `logical_and`, `logical_or`, `logical_not` calls, the
202 same 1-or-0 semantics the runtime evaluator gives them.
208 @brief Build a call to one of the evaluation-only logical helpers.
209 @param[in] name Helper name.
210 @param[in] args Argument nodes.
213 return ast.Call(func=ast.Name(id=name, ctx=ast.Load()), args=
list(args), keywords=[])
217 @brief Rewrite `sum(k, lo, hi, body)` as a loop over `lambda k: body`.
218 @param[in] node Call node.
219 @return Rewritten node.
221 self.generic_visit(node)
222 if not (isinstance(node.func, ast.Name)
and node.func.id ==
"sum"):
224 index, lower, upper, body = node.args
225 loop = ast.Lambda(args=ast.arguments(posonlyargs=[], args=[ast.arg(arg=index.id)], vararg=
None,
226 kwonlyargs=[], kw_defaults=[], kwarg=
None, defaults=[]),
228 return self.
_call(
"series", [loop, lower, upper])
232 @brief Fold `a and b and c` into nested logical calls.
233 @param[in] node BoolOp node.
234 @return Rewritten node.
236 self.generic_visit(node)
237 name =
"logical_and" if isinstance(node.op, ast.And)
else "logical_or"
238 result = node.values[0]
239 for value
in node.values[1:]:
240 result = self.
_call(name, [result, value])
245 @brief Rewrite `not a` as `logical_not(a)`.
246 @param[in] node UnaryOp node.
247 @return Rewritten node.
249 self.generic_visit(node)
250 if isinstance(node.op, ast.Not):
251 return self.
_call(
"logical_not", [node.operand])
256 @brief Rewrite `a < b < c` as `logical_and(a < b, b < c)`.
257 @param[in] node Compare node.
258 @return Rewritten node.
260 self.generic_visit(node)
261 if len(node.ops) == 1:
263 operands = [node.left] + node.comparators
264 pairs = [ast.Compare(left=copy.deepcopy(operands[i]), ops=[op], comparators=[copy.deepcopy(operands[i + 1])])
265 for i, op
in enumerate(node.ops)]
267 for pair
in pairs[1:]:
268 result = self.
_call(
"logical_and", [result, pair])
274 @brief A stand-in for a function only the runtime evaluates.
275 @param[in] name Function name.
276 @return Callable that refuses evaluation.
278 def refuse(*arguments):
280 @brief Refuse to evaluate, naming the function and where it can be used.
281 @param[in] arguments The call's arguments, unused.
282 @throws ValueError always.
285 raise ValueError(f
"{name}() is evaluated only by the solver; use it in a velocity source, "
286 "not in an expression picurv evaluates before launch.")
292 @brief Evaluate an already validated expression over named arrays or scalars.
293 @param[in] expression Expression text.
294 @param[in] values Mapping of variable name to value or array.
295 @return Result as a float array, not yet checked for finiteness.
297 tree = ast.fix_missing_locations(
_LogicalRewriter().visit(ast.parse(expression, mode=
"eval")))
298 env = dict(ALLOWED_FUNCTIONS)
299 env.update({
"logical_and": np.logical_and,
"logical_or": np.logical_or,
300 "logical_not": np.logical_not,
"pi": math.pi,
301 "series":
lambda body, lo, hi: sum((body(k)
for k
in range(int(lo), int(hi) + 1)), 0.0)})
302 for name
in SOLVER_ONLY_FUNCTIONS:
305 with np.errstate(all=
"ignore"):
307 value = eval(compile(tree,
"<ic-expression>",
"eval"), dict(env, __builtins__={}))
308 return np.asarray(value, dtype=float)
312REGION_SHAPES = (
"half_space",
"slab",
"box",
"ball",
"cylinder")
316 "smoothstep":
"({s})*({s})*(3 - 2*({s}))",
317 "cosine":
"0.5*(1 - cos(pi*({s})))",
320 "half_space": {
"axis",
"at",
"side"},
321 "slab": {
"axis",
"from",
"to"},
322 "box": {
"min",
"max"},
323 "ball": {
"center",
"radius"},
324 "cylinder": {
"axis",
"center",
"radius"},
326_AXES = (
"x",
"y",
"z")
331 @brief Render one numeric region parameter, which may name a declared parameter.
332 @param[in] value Number, or the name of a parameter.
333 @param[in] params Declared parameters.
334 @param[in] where Configuration path, for diagnostics.
335 @return Parenthesized numeric text.
337 if isinstance(value, str)
and value
in params:
338 return repr(float(params[value]))
339 if isinstance(value, bool)
or not isinstance(value, (int, float))
or not math.isfinite(value):
340 raise ValueError(f
"{where} must be a finite number or a declared parameter name.")
341 return repr(float(value))
346 @brief Signed distance to a region's boundary, negative inside, as expression text.
347 @param[in] region Region mapping.
348 @param[in] params Declared parameters.
349 @param[in] where Configuration path, for diagnostics.
350 @return Expression text for the signed distance.
352 shape = region.get(
"shape")
353 if shape
not in REGION_SHAPES:
354 raise ValueError(f
"{where}.shape must be one of {', '.join(REGION_SHAPES)}.")
355 extra = set(region) - _REGION_KEYS[shape] - {
"shape",
"value",
"edge"}
356 missing = _REGION_KEYS[shape] - set(region) - ({
"side"}
if shape ==
"half_space" else set())
358 raise ValueError(f
"{where} ({shape}) takes {sorted(_REGION_KEYS[shape])}, plus value and edge.")
360 def num(key, value=None):
362 @brief Render a region number (a literal or a `params` name) as expression text.
363 @param[in] key Region key, for diagnostics and as the default source of the number.
364 @param[in] value Number to render in place of `region[key]`, if given.
365 @return Expression text of the number.
367 return _number_text(region[key]
if value
is None else value, params, f
"{where}.{key}")
369 def axis_of(key="axis"):
371 @brief Return the region's axis name, refusing anything but x, y, z.
372 @param[in] key Region key holding the axis.
375 axis = region.get(key)
376 if axis
not in _AXES:
377 raise ValueError(f
"{where}.{key} must be x, y, or z.")
382 @brief Render a three-coordinate region entry as a list of expression texts.
383 @param[in] key Region key holding the three coordinates.
384 @return List of three expression texts.
386 values = region.get(key)
387 if not isinstance(values, list)
or len(values) != 3:
388 raise ValueError(f
"{where}.{key} must be a list of three coordinates.")
389 return [num(key, v)
for v
in values]
391 if shape ==
"half_space":
392 axis, side = axis_of(), region.get(
"side",
"above")
393 if side
not in (
"above",
"below"):
394 raise ValueError(f
"{where}.side must be above or below.")
395 return f
"({num('at')} - {axis})" if side ==
"above" else f
"({axis} - {num('at')})"
398 return f
"maximum({num('from')} - {axis}, {axis} - {num('to')})"
400 lower, upper = triple(
"min"), triple(
"max")
401 terms = [f
"maximum({lo} - {a}, {a} - {hi})" for a, lo, hi
in zip(_AXES, lower, upper)]
402 return f
"maximum(maximum({terms[0]}, {terms[1]}), {terms[2]})"
404 center = triple(
"center")
405 return (f
"sqrt(({_AXES[0]} - {center[0]})**2 + ({_AXES[1]} - {center[1]})**2 + "
406 f
"({_AXES[2]} - {center[2]})**2) - {num('radius')}")
408 others = [a
for a
in _AXES
if a != axis]
409 center = region.get(
"center")
410 if not isinstance(center, list)
or len(center) != 2:
411 raise ValueError(f
"{where}.center must list the two coordinates across the axis ({', '.join(others)}).")
412 c0, c1 = (num(
"center", v)
for v
in center)
413 return f
"sqrt(({others[0]} - {c0})**2 + ({others[1]} - {c1})**2) - {num('radius')}"
418 @brief Replace declared parameter names in an expression by their values.
419 @param[in] expression Expression text.
420 @param[in] params Declared parameters.
421 @return Expression text with parameters inlined.
423 pieces, cursor = [], 0
424 for token
in tokenize.generate_tokens(io.StringIO(expression).readline):
425 if token.type == tokenize.NAME
and token.string
in params:
426 start, end = token.start[1], token.end[1]
427 pieces.append(expression[cursor:start])
428 pieces.append(f
"({float(params[token.string])!r})")
430 pieces.append(expression[cursor:])
431 return "".join(pieces)
436 @brief Validate an expression written in a configuration value, naming where it is.
437 @param[in] expression Expression text.
438 @param[in] names Variables the expression may use.
439 @param[in] random Whether random draws are allowed.
440 @param[in] where Configuration path of the value.
441 @throws ValueError naming `where` if the expression is outside the language.
445 except ValueError
as e:
446 raise ValueError(f
"{where}: {e}")
from None
449def lower_value(spec, names=PARTICLE_EXPRESSION_NAMES, random=True, where="value") -> str:
451 @brief Turn a field value - number, expression, or structured regions - into one expression.
452 @details A mapping may declare `params` (named numbers usable in its expressions and
453 region parameters) and gives either `value` or `background` with `regions`.
454 Regions paint in order, a later region over an earlier one: a sharp region by
455 `where(d <= 0, v, before)`, a smoothed one by `before*(1 - s) + v*s`, with `d`
456 the signed distance to the region and `s` its edge profile over `edge.width`.
457 The result is validated like any expression.
458 @param[in] spec Number, expression string, or mapping.
459 @param[in] names Variables in scope.
460 @param[in] random Whether random draws may be called.
461 @param[in] where Configuration path, for diagnostics.
462 @return Validated expression text.
465 if isinstance(spec, bool):
466 raise ValueError(f
"{where} must be a number, an expression, or a mapping.")
467 if isinstance(spec, (int, float)):
468 if not math.isfinite(spec):
469 raise ValueError(f
"{where} must be finite.")
470 return repr(float(spec))
471 if isinstance(spec, str):
474 if not isinstance(spec, dict):
475 raise ValueError(f
"{where} must be a number, an expression, or a mapping.")
476 unknown = set(spec) - {
"params",
"value",
"background",
"regions"}
478 raise ValueError(f
"{where} does not take {sorted(unknown)}; it takes params, value, background, regions.")
479 params = spec.get(
"params")
or {}
480 if not isinstance(params, dict):
481 raise ValueError(f
"{where}.params must be a mapping of names to numbers.")
482 for name, number
in params.items():
483 reserved = set(names) | {
"pi"} | set(FUNCTION_ARITY) | set(RANDOM_FUNCTIONS) | {
"and",
"or",
"not"}
484 if not isinstance(name, str)
or not name.isidentifier()
or name
in reserved:
485 raise ValueError(f
"{where}.params name '{name}' is not a free identifier.")
487 scope = set(names) | set(params)
489 def expression_of(value, path):
491 @brief Lower one value (number, `params` name, or expression) with parameters inlined.
492 @param[in] value Configured value.
493 @param[in] path Configuration path, for diagnostics.
494 @return Expression text.
496 if isinstance(value, str)
and value
not in params:
498 return f
"({_substitute_params(value, params)})"
502 if "background" in spec
or "regions" in spec:
503 raise ValueError(f
"{where} gives either value, or background with regions.")
504 lowered = expression_of(spec[
"value"], f
"{where}.value")
506 regions = spec.get(
"regions")
507 if not isinstance(regions, list)
or not regions:
508 raise ValueError(f
"{where}.regions must be a non-empty list.")
509 lowered = expression_of(spec.get(
"background", 0.0), f
"{where}.background")
510 for index, region
in enumerate(regions):
511 path = f
"{where}.regions[{index}]"
512 if not isinstance(region, dict)
or "value" not in region:
513 raise ValueError(f
"{path} must be a mapping with a shape and a value.")
515 value = expression_of(region[
"value"], f
"{path}.value")
516 edge = region.get(
"edge")
518 lowered = f
"where({distance} <= 0, {value}, {lowered})"
520 if not isinstance(edge, dict)
or set(edge) != {
"profile",
"width"}
or edge[
"profile"]
not in EDGE_PROFILES:
521 raise ValueError(f
"{path}.edge must give profile ({', '.join(EDGE_PROFILES)}) and width.")
522 width =
_number_text(edge[
"width"], params, f
"{path}.edge.width")
523 if float(width) <= 0.0:
524 raise ValueError(f
"{path}.edge.width must be positive.")
525 ramp = f
"maximum(0, minimum(1, 0.5 - ({distance})/{width}))"
526 blend = EDGE_PROFILES[edge[
"profile"]].format(s=ramp)
527 lowered = f
"({lowered})*(1 - {blend}) + {value}*{blend}"
Rewrite logical operators and chained comparisons into array operations.
visit_UnaryOp(self, node)
Rewrite not a as logical_not(a).
visit_BoolOp(self, node)
Fold a and b and c into nested logical calls.
_call(name, args)
Build a call to one of the evaluation-only logical helpers.
visit_Call(self, node)
Rewrite sum(k, lo, hi, body) as a loop over lambda k: body.
visit_Compare(self, node)
Rewrite a < b < c as logical_and(a < b, b < c).
None _validate_expression(str expression)
Reject Eulerian expression syntax outside the supported language.
_validate_at(str expression, names, bool random, str where)
Validate an expression written in a configuration value, naming where it is.
str lower_value(spec, names=PARTICLE_EXPRESSION_NAMES, random=True, where="value")
Turn a field value - number, expression, or structured regions - into one expression.
int _series_bound(node)
The integer a series bound denotes: an integer literal, possibly negated.
evaluate_values(str expression, dict values)
Evaluate an already validated expression over named arrays or scalars.
str _substitute_params(str expression, dict params)
Replace declared parameter names in an expression by their values.
str _region_distance(dict region, dict params, str where)
Signed distance to a region's boundary, negative inside, as expression text.
None validate_expression(str expression, names=EULERIAN_EXPRESSION_NAMES, random=False)
Reject anything outside the expression language the runtime also evaluates.
str _number_text(value, dict params, str where)
Render one numeric region parameter, which may name a declared parameter.
None _check_expression_node(node, names, indices, random)
Check one node of a parsed expression, with the series indices enclosing it in scope.
_solver_only(str name)
A stand-in for a function only the runtime evaluates.
Head of a generic C-style linked list.