Input files for Hexed are written in the Hexed Interface Language (HIL). Most of your input files should consist of simple assignment statements of the form variable = expression
. E.g., x = 1 + 1
. You can create any variable you want, but some variables are parameters that affect the behavior of the solver, some are builtin, and some have side effects when you evaluate them. Strings are enclosed in {}
, e(.g., working_dir = {my_working_dir_name}
), and some string variables are actually macros which should be evaluated with a $
in front of them (e.g., $run
runs the simulation).
The Hexed Interface Language (HIL) is a simple scripting language designed for interfacing with Hexed. The Hexed project has two interpreters for it. There is the basic executable hil
, which supports all the basic features described on this page, and there is the full program hexecute which also supports the Solver Parameters that control the solver engine. Both interpreters can be run with no arguments, in which case they will start an interactive session, or with a single argument which is the path to a file containing HIL code. For examples of effective HIL scripting, see the samples
directory.
Creating a whole scripting language just to interface with a CFD solver may seem excessive, but in fact it addresses some of my greatest frustrations with computational analysis. Most CFD solvers behave roughly like a washing machine: you adjust a few numbers, hit "go", and hope it gives you what you want. Usually it does, but, also like a washing machine, occasionally it starts spiraling out of control and requires manual intervention. This design is generally adequate to solve easy problems, but more challenging cases often call for more granular control, such as various types of CFL ramping, complex termination conditions, local/global time step switching, etc. Because there is an infinite variety of convoluted pathways a simulation might need to take, many solvers implement a multitude of ridiculously specific input parameters and/or require you to manually babysit a simulation and tweak parameters as it runs. We need to recognize that the real purpose of a CFD solver is to do things that you can't do—in particular, running iterations and standard file IO tasks. You are perfectly capable of deciding when and how many times to do these things, and in fact probably have opinions about it that differ from mine. To address general use cases, the solver should expose the fundamental capabilities directly and give you full control over the high-level iteration process. Of course, there should be builtin defaults to let you run basic cases without any significant programming, but you should be able to modify it arbitrarily without dealing with the solver's source code.
Initially, I thought the best way to achieve this ideal would be to implement bindings to hexed in an existing scripting language (namely Python). However, this turned out to have several problems:
Technically, most CFD solvers already implement a custom scripting language, since input parameters are provided via a text file with custom syntax. Hexed simply adds support for operator expressions (=
, +
, *
, &
, etc.) and basic control flow to enable you to construct cases with arbitrarily complex logic. The goal of the HIL is to be as simple as possible (both to implement and to learn) while providing all the functionality that you strictly need.
Values in HIL have one of 3 types:
Note that there is no dedicated boolean type. The integers 0 and 1 are used to represent logical true and false. If a value is used in an expression where a value of a different type is required, it is automatically converted if possible. Integers can be automatically converted to floats and both numeric types can be automatically converted to strings (the resulting string will be a human-readable representation of the number). If neither of the above conversions are possible, an exception is thrown.
You can create one of these values with a literal. Integer and float literals are the same as in any language. Strings are enclosed in {}
instead of quotes to avoid exponential pileup of escape sequences in nested Macros. Matched pars of {}
in strings are included literally. To include an unmatched {
or }
in a string, you can escape it with a \
. To include a literal \
, you must escape it with a second \
. Strings can include newlines.
Examples:
5
and 012
are integer literals.1.0
, 4.
, 0.25
, .01
, and 5e-4
are float literals.{some chars}
is a string containing the characters some chars
.HIL supports a variety of operators, which in this context means things that take in input values (operands), which can be literals or variable references, and produce an output. Some of these operators are implemented as functions in other languages, but HIL has no concept of a function. So-called unary operators are placed directly before their operand and binary operators are placed between their operands. The operators are listed below with their precedence. Operators with a lower precedence level are evaluated before those with a higher precedence number, and operators with the same precedence level are evaluated left-to-right. ()
can be used to override the precedence order and evaluate the enclosed expression(s) first.
Syntax | Precedence level | Operator name | Operand types | Behavior |
---|---|---|---|---|
unary | 0 | - | integer, float | inverts sign |
! | integer | logical not (1 , if operand is zero, else 0 ) | ||
# | string | evaluates size of string | ||
sqrt | float | square root | ||
exp | float | exponential | ||
log | float | natural logarithm | ||
sin | float | sine | ||
cos | float | cosine | ||
tan | float | tangent | ||
asin | float | inverse sine | ||
acos | float | inverse cosine | ||
atan | float | inverse tangent | ||
floor | float | floor function | ||
ceil | float | ceiling function | ||
round | float | rounds to nearest integer | ||
abs | integer, float | absolute value | ||
read | string | operand is a file path; returns the file contents as a string | ||
print | string | prints to stdout (without adding a newline) and evaluates to the empty string | ||
println | string | prints to stdout and then prints a newline (like the Python print function) and evaluates to the empty string | ||
shell | string | executes the operand in the system shell and returns the return code (integer) | ||
$ | string | macro substitution | ||
binary | 1 | ^ | integer, float | raises left operand to power of right operand |
# | string-integer | returns the i th character of the left operand as a string where i is the right operand | ||
2 | % | integer | modulo | |
/ | integer, float | division | ||
* | integer, float | multiplication | ||
3 | - | integer, float | subtraction | |
+ | any | For numeric-numeric, addition. For strings, concatenation. | ||
4 | == | any | Evaluates to integer 1 if operands are equal, otherwise 0 . | |
!= | any | Evaluates to integer 1 if operands are unequal, otherwise 0 (including if both operands are NaN). | ||
>= | integer, float | 1 if left operand is greater than or equal to right, else 0 | ||
<= | integer, float | 1 if left operand is less than or equal to right, else 0 | ||
> | integer, float | 1 if left operand is greater than right, else 0 | ||
< | integer, float | 1 if left operand is less than right, else 0 | ||
5 | & | integer, float | logical and (0 if either operand is 0 , else 1 ) | |
| | integer, float | logical or (0 if both operands are 0 , else 1 ) | ||
6 | = | any | Assigns the value of the left operand to the right operand, which must be a variable name. Evaluates to the value of the right operand. |
Notes:
#
require the types of their operands to be the same.#
must be a string and the right must be an integer.;
(as long as this terminating character is not inside a string literal).read
will search for the target file in your current directory and lib/hexed
in some standard install paths. Specifically, it calls hexed::Path("lib/hexed").find
.The =
operator can be used to assign a value to a variable. Variable names may contain letters (case-sensitive), digits, and underscores, but cannot start with a digit. Variables are created and their type determined when they are assigned a value; variables need not be declared. Once a variable is created, its value can be referenced in subsequent expressions and its type cannot be changed. Attempting to assign a value to a variable of a different type will produce an exception, with the exception that an integer may be assigned to a float (it will be automatically converted and the variable will continue to be a float). However, HIL is technically dynamically typed since the type that a variable can depend on the path a program takes at runtime.
HIL comes with some builtin variables which are already defined at the start of your program, and special variables whose value influences the behavior of the solver (i.e. input parameters). There are also some variables, which I call Heisenberg variables, that produce side effects when you evaluate them. You can't assign to these variables, and exactly what happens when you evaluate them is specially defined for each one. They are called "Heisenberg" because the fact that observing their value changes the state of the program is reminiscent of the collapse of the wave function in quantum mechanics. See Builtin Variables and Solver Parameters for a list of such variables.
Technically, an assignment statement is an expression which evaluates to the value of the right-hand-side. Also, a sequence of statements (assignment or otherwise) evaluates to the value of the last one. So, for example,
prints hello world!
, then assigns 0 to both y
and z
, then assigns 15 to x
. However, these technicalities are unlikely to be relevant to using Hexed.
In HIL, transfer of control flow (e.g. conditionals, iteration, subroutines, recursion) is accomplished by a macro substitution mechanic. HIL is an interpreted language, meaning that the text of the program is executed as it is parsed. If the character $
is encountered while parsing (not inside a string literal), it must be immediately followed by a string value (literal, variable, or expression). This string is then prepended to the remaining text of the program. For example, suppose you have set my_string = {1 + 1}
at some point in the program. Then x = $my_string
is equivalent to x = 1 + 1
. This can be done recursively. For example, macro = {println {infinite loop!}; $macro}; $macro
will print "infinite loop!" repeatedly until you kill the process. The ability for a program to manipulate its own text is an extremely versatile tool which can be used to emulate pretty much all of the control flow mechanics that most languages explicitly implement, if you're willing to think a little harder about it. See Idioms below for examples.
The HIL includes a number of variables, both ordinary and Heisenberg, which are automatically defined at the start of your program. This section lists the builtin variables that are simply useful for scripting but not directly related to the input and output functions of the CFD solver itself. See Solver Parameters for definitions of variables that directly interact with the solver. In addition to the below variables, there are also some internal variables used in the implementation of some of the builtin macros. The names of these variables always start with hexed_
to avoid naming conflicts.
hexed_
—such variables are private to the implementation by convention.All of the quantities in the hexed::constants
namespace are included as float variables. The names are the same as in the C++ library, without the namespace. So if you want Boltzmann's constant, for instance, which in C++ would be hexed::constants::boltzmann
, you just have to write boltzmann
.
type: float
Maximum representable float value (equal to std::numeric_limits<double>::max
).
type: float
Not a Number
type: string
A string containing a single newline character, so you can write strings containing newlines on a single line.
type: integer
Alias for the integer 0, for more readable logical operations
type: integer
Alias for the integer 1, for more readable logical operations.
type: string
Macro which makes iteration more convenient, especially if you need nested loops.
type: integer
If true, the formatting of text printed with print
and println
will be modified to convey some form of emphasis (usually bold green). It is false by default, and every call to print
(ln
) will reset it to false. You really don't have to worry about this variable—it's mostly used in the implementation of the hexecute command line interface.
type: string
Controls whether the text printed with print
and println
is information, a warning, or an error. Valid values are {info}
, {warn}
, and {error}
. Info goes to the standard output, whereas warnings and errors go to the standard error. Furthermore, the emphasis formatting is green for info, yellow for warnings, and red for errors. The value of this variable is automatically reset to {info}
after every call to print
and println
. You really don't have to worry about this variable—it's mostly used in the implementation of the hexecute command line interface.
type: string
Macro which starts a Read-Evaluate-Print Loop (a.k.a. an interactive session). Statements executed in the REPL will have read/write access to the namespace of the program.
type: string
Macro which, when invoked from the REPL, quits the REPL. The program which invoked repl will continue executing. This is different from exit because it terminates only the REPL, not the interpreter itself.
type: Heisenberg string
Queries stdin (command line input) and gets characters until a newline is received. Evaluates to a string containing those characters.
type: string
Implements basic exception handling. If you set this variable to a non-empty string, and a HIL Exception occurs, instead of terminating the program it will execute except
as a macro. In case you wish to reference the text description of the exception, whatever that may be, it will be stored in the variable exception. except
defaults to {}
, and after the exception is handled, except
and exception will both be reset to {}
.
type: string
If a HIL Exception is thrown (either from C++ or from the HIL itself), this should be set to a description of what went wrong.
type: Heisenberg string
Terminates the HIL interpreter and evaluates to an empty string. This is different from quit in that it can be invoked from anywhere (not just the REPL) and even if it is invoked from the REPL, statements after the repl invocation will not be executed (because it terminates the whole interpreter).
type: Heisenberg string
Throws an exception in the C++ implementation of HIL. Potentially useful if the C++ code contains subsequent statements after the invocation of the HIL interpreter which you don't want to execute. You may consider setting the value of except before evaluating this variable.
type: Heisenberg float
Evaluates to the Unix time (in seconds) according to the system clock. Good for obtaining the calendar date/time, but not ideal for measuring small intervals (prefer steady_time ).
type: Heisenberg float
Evaluates to the time (in seconds) according to a steady clock. The difference between sequential calls is guaranteed to be an accurate measure of elapsed time, but the epoch is arbitrary. This clock is good for measuring small intervals, but cannot be used to obtain the calendar date/time (prefer system_time ).
HIL is missing some features that nearly every programming language has because there are workarounds and I am reluctant to implement (and require you to understand) anything that we can get away without. However, since these workarounds may not be obvious, this section gives some examples so that you know them when you see them or need them.
To include text addressed at a human reader which does not affect the behavior of the program, create a string literal and don't assign it to anything. Comments can be placed on the same line as a statement if you terminate the statement with a ;
. For example:
Conditional jumps (like if
and switch
statements) can be accomplished with macro substitution and integer-string conversion. For example, you can emulate the behavior of a basic if-else statement like this:
Note the two $
operators in the last statement: the first converts the string containing the variable name to the value of the variable, which is a string containing code, and the second executes the code in the string. For example, if predicate
is equal to 0, then the above example plays out as follows:
({case} + (predicate != 0))
evaluates to {case0}
$
converts {case0}
to case0
case0
evaluates to {println {predicate is false}}
.$
converts {println {predicate is false}}
to println {predicate is false}
println {predicate is false}
is then executed, resulting in predicate is false
being printed to the console.The above example conditional is essentially equivalent to the following Python code:
Iteration (like while
and for
loops) can be accomplished with recursive macros. If you're concerned about memory overhead, remember that macro substitution does not create a new scope; it literally inserts the text of the macro at the start of the remaining program text (and the text of statements that have already been processed is deleted). Thus tail recursion is inherently optimized. If your recursive macro contains trailing characters, these will add up over many iterations, but the program text happens to be allocated on the heap, so stack overflow in particular will not be an issue. With that in mind, here is an example of a basic for-loop equivalent:
Note that there are no trailing newlines at the end of the macro strings, which would pile up and consume memory if there were many iterations. The above loop would print:
However, the above method of iteration has two inconveniences. It is a little more verbose than I would prefer, and if you need nested loops you will need to explicitly give the macro variables different names to avoid conflicts. So, the preferred method is to use the builtin macro loop, which streamlines the process and can be nested (internally, it counts the number of nested levels and incorporates this into its variable names). To use it, define your termination condition in a variable named while
and the loop body in a variable named do
. Using loop, the above example would look like:
Note that this is a while loop, not a for loop, so you still have to manually define and increment your index variable.
HIL does not have a concept of a function as it exists in ordinary languages. However, you can accomplish basically the same things with macros, as long as you're okay with making the arguments and return value(s) global variables. For example, here is a "function" to compute an integer logarithm \( \lceil \log_{\mathtt{log\_base}} \mathtt{log\_arg} \rceil \). It's basically a port of hexed::math::log
. Of course, you could also implement it as ceil(log log_arg / log log_base)
, but where's the fun in that?
You would call it like this:
The above code would print 5
. Admittedly, this is an extremely clunky excuse for a function, especially since it you have to avoid naming conflicts between the parameters of different functions. However, the number of HIL use cases that are complex enough to require functions should be exceedingly small.