Define New Waveforms¶
Overview¶
The waveform_base module was designed to be extensible. By leveraging polymorphism, we can coerce a uniformity
in treatment across various waveform types. The resulting framework furnishes Users with (virtually) painless
procedure to define custom parameterized waveforms which cooperate with the core routines for AWG interaction &
parallel processing.
In short, a User implements a class to describe his waveform; appending it to the waveform source file.
To meet minimal integration requirements, a User class must:
- Extend
wavgen.waveform_base.Waveform This base class represents a general waveform. Inheriting allows for consistency of treatment among all waveform types.
- Appropriately initiate the base constructor
The new constructor 1 must subsequently call the super constructor 2. It’s mandatory you pass an int value for the
sample_lengthargument, which configures the waveform’s length in samples. The second argument, amp, can optionally receive a float value from 0 to 1, applied as an overall scaling factor to the final waveform.- Override the
compute(self, p, q)method The heart of your definition; dictating how the waveform’s samples are calculated.
- Override the dual methods
config_dset&from_file The former method facilitates a packaging of descriptive waveform parameters for file storage; which should be, if retrieved at a later time sufficient for reconstruction of the waveform. Regarding the latter method, the super implementation is usually sufficient, obviating the need to override.
As a last resort, consult the Waveform source documentation.
Example¶
Below we present a modest example of a valid User defined class. We piece-wise analyze each of the methods overriding an inherited method.
Note
Variables in all CAPS are global values, being either a constant or parameter. See constants
The example code aims at defining a humble square wave. Notice how SquareWave(Waveform) is extending the
Waveform class:
class SquareWave(Waveform):
def __init__(self, f, arg1, arg2=0, optional_arg=None, sample_length=100, amp=1.0):
self.Period = SAMP_FREQ / f
self.Arg1 = arg1
self.SampleLength = arg2 if optional_arg else sample_length
super().__init__(self.SampleLength, amp)
def compute(self, p, q):
N = min(DATA_MAX, self.SampleLength - p*DATA_MAX)
waveform = np.empty(N, dtype=float)
for i in range(N):
n = i + p*DATA_MAX
phase = (n % self.Period) - self.Period/2
waveform[i] = (1 if phase < 0 else -1)
q.put((p, waveform, max(waveform.max(), abs(waveform.min()))))
def config_dset(self, dset):
## Contents ##
dset.attrs.create('f', data=SAMP_FREQ / self.Period)
dset.attrs.create('arg1', data=self.Arg1)
dset.attrs.create('sample_length', data=self.SampleLength)
## Table of Contents ##
dset.attrs.create('keys', data=['arg1', 'f', 'sample_length'])
return dset
@classmethod
def from_file(cls, **kwargs):
return cls(**kwargs)
Overriding¶
__init__(self, anything)¶
The User has nearly infinite freedom for creativity here. Although you may want to consider how your choice impacts the third & fourth sub-sections below.
The only real requirement has already been mentioned above; namely, super().__init__(self.SampleLength, amp).
It doesn’t quite matter how we determined self.SampleLength, just that it exists and is an integer.
compute(self, p, q)¶
This is the dispatch method used for parallelization. In short:
The waveform is divided into chunks of size
DATA_MAX, where the last chunk holds a remainder.pindicates which chunk to compute; which is stored in a numpy array of commensurate size.In final, we pair
p& the numpy array in a tuple which is submitted toq, an inter-process queue.All chunks are collected and ordered according to their
p, resulting in a monolithic array of the entire waveform.
If in doubt, follow this template which captures the aspects shared by most cases:
N = min(DATA_MAX, self.SampleLength - p*DATA_MAX) # Determines chunk size
waveform = np.empty(N, dtype=float) # Instantiates a numpy array
for i in range(N): # Iterate a relative index
n = i + p*DATA_MAX # Derive an absolute index
# something
waveform[i] = # something # Calculate & store each absolute data point
norm = max(waveform.max(), abs(waveform.min())) # Determines the greatest value, for normalization
q.put((p, waveform, norm)) # Places results on the Queue
Note
The numpy array is not restricted in terms of dtype, although it would seem that float type is probably always the optimal choice.
config_file(self, h5py_f)¶
Raw waveform samples are saved in HDF5 dataset structures; which is passed here as dset.
From this alone, it’s not obvious how we’d determine the waveform class, let alone defining parameters. We address the
issue by attaching directly to the dataset a number of attribute structures; composed of name & data
element, e.g. dset.attrs.create("arg1", data=[1, 5, 7, 9]).
There is freedom in implementation; the goal is to save enough information s.t. we can identify & reconstruct the original waveform object, using only saved information. A reliable technique is to choose a set of constructor arguments, through which you can effectively set each class attribute. The example achieves such a subset, compare the method body:
## Contents ##
dset.attrs.create('f', data=SAMP_FREQ / self.Period)
dset.attrs.create('arg1', data=self.Arg1)
dset.attrs.create('sample_length', data=self.SampleLength)
To the class constructor:
def __init__(self, f, arg1, arg2=0, optional_arg=None, sample_length=100, amp=1.0):
self.Period = SAMP_FREQ / f
self.Arg1 = arg1
self.SampleLength = arg2 if optional_arg else sample_length
super().__init__(self.SampleLength, amp)
Additionally, a mandatory Table of Contents attribute is created, holding an unordered list of all the attribute
keywords; it must be named 'keys' as shown:
## Table of Contents ##
dset.attrs.create('keys', data=['arg1', 'f', 'sample_length'])
The list of keywords need not match the constructor’s order. (although it does need to considered in the next sub-section).
Lastly you must end with return dset to return the handle on the dataset.
from_file(cls, **keys)¶
This function is, in spirit, achieves the converse of config_file().
It receives **keys, a dictionary between keywords & HDF5 attribute values, ordered according to the keyword
"keys" attribute, acting as our Table of Contents.
Most likely, you will be able to choose your **keys s.t. they each correspond to a constructor argument. In that
case, it is unnecessary to override this method’s inherited form:
@classmethod
def from_file(cls, **kwargs):
return cls(**kwargs)
For a terrific example of the contrary case, see the wavgen.waveform.Sweep template.
Attention
You need to put the @classmethod decorator above its function signature for somewhat unimportant reasons (see classmethod if curious!).