-
Notifications
You must be signed in to change notification settings - Fork 108
Logic constructs on Soletta flows
When using Soletta™ Framework, one is able to write their code in flow based programming form , i.e., by defining it in terms of independent "processes" (that we call nodes) that interchange data (in the form of packets) between them, in order to accomplish a broader task.
Soletta contains various nodes doing input and output, but it also has a bunch of them doing only intermediate data/packets processing. Many of those nodes allow one to make logic operations with data. Although there is this support, in any way are they meant for one to translate code from a procedural language directly into an .fbp
file.
When doing flow based programming, one is advised to keep more than trivial logic controls inside their nodes, so that assembling a flow is only a matter of routing data through the right nodes.
May one need to do such translations, though, this document comes to illustrate some tricks on how to achieve some of them, along with the the problems the programmer might encounter.
The first procedural construct one might want to emulate on flows is a for loop:
for (int i = MIN_VAL; i < MAX_VAL; i++) {
do_something(i);
}
The nodes that directly map to those constructs are the accumulator ones. They got an INC
input port that, upon receiving a packet, will update its internal state and output a new value at the OUT
port. This output value could, then, have the role of the i
variable.
The first limitation on flow logic we get is on emulating a repeating loop with an action that only takes the time of its own computation (the do_something()
part). The "flow way" of writing that would be to chain a series of nodes doing the repeating steps in sequence. However, if that inner computation involves a timeout, we can mimic the original procedural behavior with the joint work of accumulator and timer nodes:
for (int i = MIN_VAL; i < MAX_VAL; i++) {
do_something(i);
sleep(TIMEOUT);
}
Here, since we got a timed repetition of the steps, we can achieve the same functionality in a flow like that:
timer(timer:interval=TIMEOUT)
acc(int/accumulator:setup_value=val:MIN_VAL|min:MIN_VAL|max:MAX_VAL|step:1)
timer OUT -> INC acc OUT -> IN do_something(console)
The function in the original code (procedural language) was made a console node here, but in a real use case it's just a matter of exchanging it to the actual type/port. Since accumulator nodes emit their initial state packets as soon as they have their OUT
port connected, that node after the accumulator would also get the first iteration before the first timeout, like in the original procedural code.
One important thing to notice is that in the original code we had a blocking wait inside the loop. In Soletta, the use of timer nodes is not blocking in any way — no other possible part of the flow would be affected by the timer's timeout to generate its ticks.
To translate the end of the loop condition (i < MAX_VAL
), one could use the int/equal node to get that comparison:
filter(boolean/filter)
constant_max(constant/int:value=MAX_VAL)
acc OUT -> IN0 comp_max(int/equal)
constant_max OUT -> IN1 comp_max
comp_max OUT -> IN filter TRUE -> IN _(boolean/not) OUT -> ENABLED timer
We also make use of a boolean filter, since we're only interested in the true packets coming from comp_max
, and we repeat the MAX_VAL
constant again to feed the int/equal node. The boolean/not node is there to negate that true value boolean packet and thus disable the timer. Note that its name is _
, meaning that we wont make any further reference to it — it’s an anonymous node. If the loop was decreasing instead, we'd have the constant MIN_VAL
replicated, and could rename this schema exchanging "max" to "min".
We have to add that these constructs are useful when you got a bunch of repetitions to make. If you only new a couple of them, you could achieve that result by using fewer nodes. Consider the Soletta nodes that receive ticks as input. If in a test you only want to tick it, say, twice, a simple .fbp
like follows would do:
tick_1(constant/empty)
tick_2(constant/empty)
temp(am2315/temperature:i2c_bus=0,i2c_slave=1)
tick_1 OUT -> TICK temp
tick_2 OUT -> TICK temp
As yet another way of achieving that, we have the test generator family of nodes, that produce pre-defined sequences of packages for you (for testing purposes mainly, thus the naming):
gen(test/boolean-generator:sequence="TT")
gen OUT -> TICK temp(am2315/temperature:i2c_bus=0,i2c_slave=1)
Note: Some sensors may not properly answer two consecutive read requests. So, code samples above may not work properly on all sensors. For those sensors, add a waiting time between two requests, as it'll be detailed on next sections.
For more information on test nodes, refer to Testing with Soletta flows.
Say, now, we have more complicated loop schemes, as in:
while (true) {
for (int i = MIN_VAL; i < MAX_VAL; i++) {
do_somenthing(i);
sleep(TIMEOUT_INNER);
}
sleep(TIMEOUT_MID);
for (int i = MAX_VAL; i >= MIN_VAL; i--) {
do_something(i);
sleep(TIMEOUT_INNER);
}
sleep(TIMEOUT_FINAL);
}
We can achieve that in a flow by using comp_max
's output also start the TIMEOUT_MID
timer, that in turn will start a timer to exercise acc
's DEC
port, decreasing the values instead of increasing. A minimum value comparison construct could then be used to wrap all things around. The translation starts to get a bit clumsy, though — four timers, two comparison node blocks, etc. We can do better with another type of node: the wave-generator:
timer(timer:interval=TIMEOUT_INNER)
wave(wave-generator/trapezoidal:min=MIN_VAL,max=MAX_VAL,ticks_inc=MAX_VAL,ticks_dec=MAX_VAL,ticks_at_min=TIMEOUT_FINAL/TIMEOUT_INNER,ticks_at_max=TIMEOUT_MID/TIMEOUT_INNER,start_tick=TIMEOUT_FINAL)
timer OUT -> TICK wave
wave OUT -> IN _(converter/float-to-int) OUT -> IN do_something(console)
As seen it its documentation, the wave-generator/trapezoidal node will generate discrete function outputs after each tick. If we consider the time we wait in that original procedural loop, what we get is a trapezoidal wave that goes from MIN_VAL
to MAX_VAL
, holds there for a while, goes back from MAX_VAL
to MIN_VAL
and holds there too, just to start again. The node is so that it starts holding ticks_at_min
ticks at the minimum value, so we must explicitly tell it to skip these initial ticks with the start_tick
option. This option is also handy to have things composed by others, like RGB colors with their individual components, produced in parallel according to some given logic. By dephasing three identical wave-generators, just differing in the start tick option, one can achieve interesting results. Note that it outputs float packets, thus the converter to integer again.
Our syntax does not support the division operations we did in the node options (like TIMEOUT_FINAL/TIMEOUT_INNER
) — they are there only to show how we can make use of only one timer now and have the very same behavior we had before with the accumulator and four timers.
Say, now, that we got procedural code that goes like this:
int var = MIN_VAL;
while (true) {
if (var > MAX_VAL) {
var = MIN_VAL;
do_something_else();
}
do_something(var);
sleep(TIMEOUT);
var++;
}
In loops that we wrap around a count to the initial value (from MAX_VAL
to MIN_VAL
again or vice-versa), we can capture that we reached the extreme values slightly better than using the int/equal idiom plus its helper nodes — we can use the accumulator's OVERFLOW
and UNDERFLOW
ports. If one keeps sending packets to the node's INC
port, at the time its state is the maximum value and a next increment operation happens, it outputs the minimum value again, together with an empty packet on its OVERFLOW
port (the underflow event is analogous). So we end up with the following translation:
timer(timer:interval=TIMEOUT)
acc(int/accumulator:setup_value=val:MIN_VAL|min:MIN_VAL|max:MAX_VAL|step:1)
acc OUT -> IN do_something(console:prefix=something)
timer OUT -> INC acc
acc OVERFLOW -> IN do_something_else(console:prefix=somethingelse)
Since do_something_else()
and do_something()
both happen when we pass the MAX_VAL
limit, we get exactly that by using the OVERFLOW
output port. This construct can't be used on loops where we don't wrap values, though — you'll have to resort to the int/equal idiom.
Consider now the following code:
int var = MIN_VAL;
while (true) {
if (var == (MAX_VAL + MIN_VAL)/2) {
do_something_else();
} else if (var > MAX_VAL) {
var = MIN_VAL;
}
do_something(var);
sleep(TIMEOUT);
var++;
}
Here we do not act on the loop's extreme values, but on a middle one, so the int/equal nodes idiom has to be used. One would feel tempted to write something like what follows to translate it:
timer(timer:interval=TIMEOUT)
do_something_else(console:prefix="do_something_else: ")
do_something(console:prefix="do_something: ")
acc(int/accumulator:setup_value=val:MIN_VAL|min:MIN_VAL|max:MAX_VAL|step:1)
acc OUT -> IN0 comp(int/equal)
_(constant/int:value=(MAX_VAL + MIN_VAL)/2) OUT -> IN1 comp
acc OUT -> IN do_something
timer OUT -> INC acc
comp OUT -> IN _(boolean/filter) TRUE -> IN do_something_else
The problem, here, is that do_something()
, at the round the comparison outputs true, will have its packet delivered to it before the other function! That is because the former has a shorter path from the accumulator to it. The following figure illustrates that last .fbp
file in a more pictorial way:
The way Soletta works, every time a packet arrives at a node and is processed, every output port that may send new packets will do so in the order of connections declared. The process goes on this way but, when one sends a packet, it goes in a packet queue (so the order is always preserved), and only on a next internal processing loop, the sent packages of past rounds are processed again to produce new output port packets.
This must never be a concern to the user using flow programming in its pure form, i.e., not dealing with logic and timing on the flow. Since we're explaining the traps one might fall in while doing this abuse, we have to expose the reasons. Here is another trick to surpass that:
By using a converter/empty-to-int node to store a state, not just to convert values, we delay the value delivered to do_something()
but keep the value we wanted to it on every iteration (OUTPUT_VALUE
port will only store the new value, with no new packets produced, while the IN
port will pass on the current state). This trick can be used with other packet types, naturally, by using other converters.
By exposing this, we hope that the reader understands the traps of trying to express too much logic in flow form. If the situation is so that it is unavoidable, the cited tricks may help, but bare in mind that you'll always be exposed to race conditions and logic flaws while doing that.