PICurv 0.1.0
A Parallel Particle-In-Cell Solver for Curvilinear LES
 
Loading...
Searching...
No Matches
source_expressions.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3
4"""!
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.
11"""
12
13import ast
14import copy
15import io
16import math
17import re
18import tokenize
19
20import numpy as np
21
22ALLOWED_FUNCTIONS = {
23 "abs": np.abs,
24 "atan2": np.arctan2,
25 "cos": np.cos,
26 "cosh": np.cosh,
27 "erf": np.vectorize(math.erf, otypes=[float]),
28 "exp": np.exp,
29 "floor": np.floor,
30 "log": np.log,
31 "maximum": np.maximum,
32 "minimum": np.minimum,
33 "sin": np.sin,
34 "sinh": np.sinh,
35 "sqrt": np.sqrt,
36 "tan": np.tan,
37 "tanh": np.tanh,
38 "where": np.where,
39}
40#: Arguments each function takes; the runtime evaluator (`src/expression.c`) accepts exactly
41#: the same calls.
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}
45#: Bessel functions of integer order (`besselj(n, x)`, `bessely(n, x)`) and the Kelvin
46#: functions `ber`, `bei`: validated here, evaluated only by the runtime, which has them.
47SOLVER_ONLY_FUNCTIONS = ("besselj", "bessely", "ber", "bei")
48#: Terms one series `sum(k, lo, hi, body)` may add; the runtime holds the same limit.
49MAX_SERIES_TERMS = 1000000
50#: Series that may nest inside one another; the runtime holds the same limit.
51MAX_SERIES_DEPTH = 4
52#: Per-particle random draws: evaluated only by the runtime, from a hash of the particle's
53#: identity; an optional non-negative integer literal names a stream shared across fields.
54RANDOM_FUNCTIONS = ("uniform", "normal")
55#: Variables an Eulerian expression may use.
56EULERIAN_EXPRESSION_NAMES = frozenset({"x", "y", "z", "xi", "eta", "zeta", "i", "j", "k"})
57#: Variables a particle initial-value expression may use: physical coordinates, domain-
58#: normalized coordinates, particle identity, and physical time.
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+)?$")
62ALLOWED_AST_NODES = (
63 ast.Expression,
64 ast.BinOp,
65 ast.UnaryOp,
66 ast.Call,
67 ast.Name,
68 ast.Load,
69 ast.Constant,
70 ast.Add,
71 ast.Sub,
72 ast.Mult,
73 ast.Div,
74 ast.Pow,
75 ast.Mod,
76 ast.Compare,
77 ast.Eq,
78 ast.NotEq,
79 ast.Lt,
80 ast.LtE,
81 ast.Gt,
82 ast.GtE,
83 ast.USub,
84 ast.UAdd,
85 ast.BoolOp,
86 ast.And,
87 ast.Or,
88 ast.Not,
89)
90
91
92def validate_expression(expression: str, names=EULERIAN_EXPRESSION_NAMES, random=False) -> None:
93 """!
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.
104 """
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.")
109 try:
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
114 for token in tokens:
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.")
117 _check_expression_node(tree.body, frozenset(names) | {"pi"}, (), random)
118
119
120def _series_bound(node) -> int:
121 """!
122 @brief The integer a series bound denotes: an integer literal, possibly negated.
123 @param[in] node Bound node.
124 @return The bound.
125 @throws ValueError for anything else.
126 """
127 sign = 1
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)
134
135
136def _check_expression_node(node, names, indices, random) -> None:
137 """!
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.
144 """
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}'.")
152 return
153 if isinstance(node, ast.Call):
154 name = node.func.id if isinstance(node.func, ast.Name) else None
155 if node.keywords:
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.")
160 if node.args:
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.")
165 return
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).")
170 if name == "sum":
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.")
178 if _series_bound(upper) - _series_bound(lower) + 1 > MAX_SERIES_TERMS:
179 raise ValueError(f"a series may add at most {MAX_SERIES_TERMS} terms.")
180 _check_expression_node(body, names, indices + (index.id,), random)
181 return
182 for argument in node.args:
183 _check_expression_node(argument, names, indices, random)
184 return
185 for child in ast.iter_child_nodes(node):
186 _check_expression_node(child, names, indices, random)
187
188
189def _validate_expression(expression: str) -> None:
190 """!
191 @brief Reject Eulerian expression syntax outside the supported language.
192 @param[in] expression User expression.
193 """
194 validate_expression(expression, EULERIAN_EXPRESSION_NAMES, random=False)
195
196
197class _LogicalRewriter(ast.NodeTransformer):
198 """!
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.
203 """
204
205 @staticmethod
206 def _call(name, args):
207 """!
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.
211 @return Call node.
212 """
213 return ast.Call(func=ast.Name(id=name, ctx=ast.Load()), args=list(args), keywords=[])
214
215 def visit_Call(self, node):
216 """!
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.
220 """
221 self.generic_visit(node)
222 if not (isinstance(node.func, ast.Name) and node.func.id == "sum"):
223 return node
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=[]),
227 body=body)
228 return self._call("series", [loop, lower, upper])
229
230 def visit_BoolOp(self, node):
231 """!
232 @brief Fold `a and b and c` into nested logical calls.
233 @param[in] node BoolOp node.
234 @return Rewritten node.
235 """
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])
241 return result
242
243 def visit_UnaryOp(self, node):
244 """!
245 @brief Rewrite `not a` as `logical_not(a)`.
246 @param[in] node UnaryOp node.
247 @return Rewritten node.
248 """
249 self.generic_visit(node)
250 if isinstance(node.op, ast.Not):
251 return self._call("logical_not", [node.operand])
252 return node
253
254 def visit_Compare(self, node):
255 """!
256 @brief Rewrite `a < b < c` as `logical_and(a < b, b < c)`.
257 @param[in] node Compare node.
258 @return Rewritten node.
259 """
260 self.generic_visit(node)
261 if len(node.ops) == 1:
262 return node
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)]
266 result = pairs[0]
267 for pair in pairs[1:]:
268 result = self._call("logical_and", [result, pair])
269 return result
270
271
272def _solver_only(name: str):
273 """!
274 @brief A stand-in for a function only the runtime evaluates.
275 @param[in] name Function name.
276 @return Callable that refuses evaluation.
277 """
278 def refuse(*arguments):
279 """!
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.
283 """
284 del arguments
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.")
287 return refuse
288
289
290def evaluate_values(expression: str, values: dict):
291 """!
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.
296 """
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:
303 env[name] = _solver_only(name)
304 env.update(values)
305 with np.errstate(all="ignore"):
306 # Globals, not locals: a series body is a lambda, which sees only the globals.
307 value = eval(compile(tree, "<ic-expression>", "eval"), dict(env, __builtins__={}))
308 return np.asarray(value, dtype=float)
309
310
311#: Region shapes a structured field value may paint, later regions over earlier ones.
312REGION_SHAPES = ("half_space", "slab", "box", "ball", "cylinder")
313#: Profiles of a smoothed region edge, each rising from 0 outside to 1 inside across `width`.
314EDGE_PROFILES = {
315 "linear": "({s})",
316 "smoothstep": "({s})*({s})*(3 - 2*({s}))",
317 "cosine": "0.5*(1 - cos(pi*({s})))",
318}
319_REGION_KEYS = {
320 "half_space": {"axis", "at", "side"},
321 "slab": {"axis", "from", "to"},
322 "box": {"min", "max"},
323 "ball": {"center", "radius"},
324 "cylinder": {"axis", "center", "radius"},
325}
326_AXES = ("x", "y", "z")
327
328
329def _number_text(value, params: dict, where: str) -> str:
330 """!
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.
336 """
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))
342
343
344def _region_distance(region: dict, params: dict, where: str) -> str:
345 """!
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.
351 """
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())
357 if extra or missing:
358 raise ValueError(f"{where} ({shape}) takes {sorted(_REGION_KEYS[shape])}, plus value and edge.")
359
360 def num(key, value=None):
361 """!
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.
366 """
367 return _number_text(region[key] if value is None else value, params, f"{where}.{key}")
368
369 def axis_of(key="axis"):
370 """!
371 @brief Return the region's axis name, refusing anything but x, y, z.
372 @param[in] key Region key holding the axis.
373 @return Axis name.
374 """
375 axis = region.get(key)
376 if axis not in _AXES:
377 raise ValueError(f"{where}.{key} must be x, y, or z.")
378 return axis
379
380 def triple(key):
381 """!
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.
385 """
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]
390
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')})"
396 if shape == "slab":
397 axis = axis_of()
398 return f"maximum({num('from')} - {axis}, {axis} - {num('to')})"
399 if shape == "box":
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]})"
403 if shape == "ball":
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')}")
407 axis = axis_of()
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')}"
414
415
416def _substitute_params(expression: str, params: dict) -> str:
417 """!
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.
422 """
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})")
429 cursor = end
430 pieces.append(expression[cursor:])
431 return "".join(pieces)
432
433
434def _validate_at(expression: str, names, random: bool, where: str):
435 """!
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.
442 """
443 try:
444 validate_expression(expression, names, random)
445 except ValueError as e:
446 raise ValueError(f"{where}: {e}") from None
447
448
449def lower_value(spec, names=PARTICLE_EXPRESSION_NAMES, random=True, where="value") -> str:
450 """!
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.
463 """
464 params = {}
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):
472 _validate_at(spec, names, random, where)
473 return spec
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"}
477 if unknown:
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.")
486 _number_text(number, {}, f"{where}.params.{name}")
487 scope = set(names) | set(params)
488
489 def expression_of(value, path):
490 """!
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.
495 """
496 if isinstance(value, str) and value not in params:
497 _validate_at(value, scope, random, path)
498 return f"({_substitute_params(value, params)})"
499 return _number_text(value, params, path)
500
501 if "value" in spec:
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")
505 else:
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.")
514 distance = _region_distance(region, params, path)
515 value = expression_of(region["value"], f"{path}.value")
516 edge = region.get("edge")
517 if edge is None:
518 lowered = f"where({distance} <= 0, {value}, {lowered})"
519 continue
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}"
528 validate_expression(lowered, names, random)
529 return lowered
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.
Definition variables.h:478