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:

A time-varying function that ramps up from 0 to 1 from time 1 to time 2.

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:

A sinusoid with a frequency of 2Hz.

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:

The sum of a ramp and a sinusoid.

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:

The product of a ramp and a sinusoid.

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:

A complex function of a ramp and a sinusoid.

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

The square root of a ramp.

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

The max of a ramp and a sinusoid.

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:

When the sinusoid is greater than or equal to zero.

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.