tvx: An Introduction
Tvx is a package that implements time-varying quantities. For
example, tvx.Tvf
is a time-varying floating point class.
Similarly, tvx.Tvb
is a time-varying boolean class.
What exactly does it mean to be time-varying? It simply means that the value of an object changes over time.
Time-Varying Floats (Tvf)
A time-varying float (Tvf) is an object that has a floating-point value that changes over time. A Tvf might have a value of 0 before from time=0.0 to time=1.0 seconds, then it might ramp up linearly starting at time=1.0 and reaching a new value of 2.0 at time=2.0, then remaining at that value indefinitely. We call this kind of Tvf a ramp, and it looks as follows:
The horizontal axis represents time and the vertical axis represents the value at any given time.
We can construct a ramp using the tvx
package as follows:
import tvx
ramp = tvx.ramp(f0=0, f1=1, x0=1, width=1)
The object returned is a Tvf derived from the base class Tvf
.
There are a lot of different kinds of Tvfs we can construct. This one happens
to be of a subclass called TvfRamp
. We’ll rarely create Tvfs
by directly calling their constructors. Instead, we typically use helper functions
as we just did, or, more commonly, we create them with arithmetic expressions
of other Tvfs as we will see in Arithmetic Expressions on Tvfs.
There are many kinds of Tvfs. Let’s construct one representing a sinusoid that has a frequency of 2Hz (meaning it completes two full waves per second) as follows:
import tvx.utils
sinusoid = tvx.utils.sine_wave(frequency=2)
The value of the sinusoid over time looks like this:
It’s important to note that even though we’re looking at some plots
to help us understand what ramp
and sinusoid
look like as
functions of time, they have not actually computed anything. We can
ask a TVF what it’s value is at any given time by calling it like
a function, with a single argument that is the time at which we want
to evaluate it. For example:
ramp(0.5)
will return 0.0, while
ramp(1.5)
will return 0.5, and
ramp(2.5)
will return 1.0.
If we want to see values at regular intervals, we could do something like
import numpy as np
[(t, ramp(t)) for t in np.linspace(0.0, 3.0, 7)]
which returns the result
[(0.0, 0.0),
(0.5, 0.0),
(1.0, 0.0),
(1.5, 0.5),
(2.0, 1.0),
(2.5, 1.0),
(3.0, 1.0)]
We can do similar things with the sinusoid. For example:
sinusoid(0.125)
will return a value of 1.0.
So you might be wondering at this point what is so special about Tvfs?
They seem to operate much like ordinary Python functions. You give them
a time as an argument and they return the value of a defined function at
that time. In
our examples, one of these functions was a ramp and the other was a
sinusoid. Aside from the fact that these functions exist somewhere in
the tvx
package for us, we could have just written
def my_ramp(t: float):
if t <= 1.0:
return 0.0
if t <= 2.0:
return t - 1.0
else:
return 1.0
def my_sinusoid(t: float) -> float:
return np.sin(4 * np.pi * t)
and gotten the same results as we just saw above.
But Tvfs can do much more, as we will find out in the next section.
Arithmetic Expressions on Tvfs
This is where things start to get interesting. We can do math on Tvfs, adding them together, multiplying them, taking their square roots, and all kinds of other things. Each time we do that, we get a new Tvf. We’ll illustrate by adding the two Tvfs we constructed above.
total = ramp + sinusoid
What have we just done? We added the two functions together in a way that we could not do with ordinary Python functions. If we try to add ordinary Python functions together, for example
my_total = my_ramp + my_sinusoid
we get an error like:
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
test.py in <module>
----> 1 my_total = my_ramp + my_sinusoid
TypeError: unsupported operand type(s) for +: 'function' and 'function'
But that doesn’t happen with our Tvfs. Instead, we get a new Tvf that we can call. When we do so, it returns the sum of the values returned by the two Tvfs we added to construct it. So, for example,
total(1.325)
returns -0.625 because
ramp(1.325)
returns 0.375 and
sinusoid(1.325)
returns -1. The sum of 0.375 and -1 is -0.625.
If we were to plot the value of total
over time, it would look
like this:
We can see that for time less than 1.0, we have the same sinusoid we
had before. For time greater than 2.0, it is a similar sinusoid, but
centered aroud 1.0 instead of 0.0. And in between time 1.0 and 2.0, we
still have the sinusoidal waveform, but it is ramping up from being
centered at 0.0 to being centered at 1.0. This is exactly what we would
get if we evaluated ramp
and sinusoid
at a given time and
added the result. That is, if f and g are Tvfs and s = f + g,
then s(t) = f(t) + g(t).
Tvf arithmetic is not limited to addition. We can also multiple them. So if we do
product = ramp * sinusoid
The we get a function whose value over time looks like this:
For time less than 1.0 the value of the ramp is zero, so not matter what the value of the sinusoid is, the product of the ramp and the sinusoid is zero. After time 2.0, the ramp value is 1.0, so the product of the ramp value and the sinusoid is exactly the same as the value of the sinusiod. In between 1.0 and 2.0, the amplitude of the sinusoid ramps up from 0.0 to 1.0.
We can write all kinds of more complicated expressions. For example:
f = (-3 * ramp + 1.5) + sinusoid / 4
creates a function f
whose value depends on both that of the ramp and
that of the sinusoid, as specificed by the overall expression.
f
over time looks like:
The ramp has been flipped over and scaled up by being multiplied by -3 and then shifted up ny having 1.5 added to it. The sinusoid has had its amplitude cut down by a factor of 4, so the vaves it produces when added to the ramp are much smaller. This is a bit of a contrived example, but it shows that you can build a wide variety of differnt kinds of Tvfs by putting simple Tvfs into arithmetic expressions.
In addition to arithmetic operators you are used to in Python, like +
, -
, *
, /
,
and //
, there are utility math functions like min()
, max()
,
sqrt()
, sin()
, atanh()
and others you may be
interested in. Here are a couple of example of their use:
sqrt_ramp = tvx.sqrt(ramp)
is a Tvf that looks like
It’s the same as the original outside the ramp region because the square root of 0.0 is 0.0 and the square root of 1.0 is 1.0. But in the ramp region it is no longer linear.
Some of the utility functions take more than one Tvf as arguments. For example
upper = tvx.max(ramp, sinusoid)
produces a Tvf whose value at a given time is the whichever is larger, the value of the sinusoid at that time or the value of the ramp. The result, when plotted, looks like
At certain times the sinusoid is larger. At others the ramp is. When their values are equal the profile of the curve switches off from one to the other.
Time-Varying Boolean (Tvb) Expressions
Time-Varying Booleans (Tvb) values are a lot like Tvfs, but instead
of having a floating point value that changes over time, they
have a Boolean value that can be either True
or False
at
different times.
Tvbs are most commonly created using relational opertors like <
, >
,
and ==
on Tvfs. For example
sign = sinusoid >= 0
produces a Tvb whose value is True
when the sinusoid’s value is greater than
or equal to zero and False
otherwise. If we plot this Tvb with True
represented by a high value on the vertical axis and False
represented by a
low value, the result is:
Tvbs can be combined with one another using Boolean operators like &
, |
, and
^
and they can be inverted with not
like a normal bool
.
Implementation - pytvx and ctvx
There are actually two different implementations of time-varying values. One, called
pytvx
is writted in pure Python. The other, ctvx
is written in C++
and bound to Python using pybind11. ctvx
is
substantially faster than pytvx
. tvx
chooses between the two implementations
at import time.
Normally, you will simply
import tvx
If you do this you will get ctvx
if it is available in your environment. If it is
not available, tvx
will fall back to using pytvx
.
If ctvx
is available, but you want to force the use of the pure Python
pytvx
, you can set the environment variable TVX_PURE_PYTHON=1
before
importing tvx
. This may slow you down, but it’s a convenient way to debug
issues or run tests to ensure your code is working properly with either implementation.
The tvx
and gewel
builds use this environment variable to
run their test suites on both implementations.