Noise generation
The noise handling is split into two aspects. The first aspect is the definition of noises. This is handled by a collection of classes, one for each noise type. Those classes are simple data classes that have no methods to generate any noise. The second aspect is the generation of noise samples. There are different implementations of noise generation. One provides noise as simple numpy arrays. Another one provides noise as a stream. A third one also provides a stream, but internally makes use of the numpy-based noise generator.
The noise definitions and the numpy-based noise generator are located in the noisy subpackage. The stream-based noise generators are part of the streams subpackage.
Noise definitions
For each noise type used in the simulator, there is a corresponding class which stores the parameters of the noise model. All those are derived from the ABC NoiseDefBase, which stores the two parameters common to all noise types: sample rate and name. The name is important for noise generators, with the convention that noises with different names are uncorrelated.
NoiseDefBase also defines two abstract methods. The first one, discrete_psd_twosided is user-facing, allowing to compute the expected PSD for the discrete noise samples. The second one, elementary is crucial for all noise generation implementations, as described below.
Any noise definition can be passed to a noise generator without regard for the noise type. A crucial design principle is that once the noise definition is instantiated, all noise types are treated the same.
We distinguish two categories of noises: there is a small standard set of noises we call elementary, and other noises build from elementary ones by means of an arbitrary number of summing or filtering operations.
The elementary method implemented by each noise type expresses any noise in terms of elementary noises. For example, the elementary method of NoiseDefRed returns a NoiseDefFiltered instance instantiated with an NoiseDefWhite instance as the noise to be filtered. For consistency, also elementary noise types have the elementary method. This also allows for parameter-based runtime optimizations.
Note that the elementary method operates on noise definitions. This differs from previous designs which performed similar delegation tasks during the noise generation. Our approach has many advantages, one of which is that the multiple noise generators we have do not have to re-implement the same logic. The generators only need to know how to generate samples for elementary noise types, and call the elementary function first.
The elementary noise types, defined in module noise_defs, are
NoiseDefZero: Zero noise
NoiseDefWhite: White noise
NoiseDefFiltered: Applies IIR filter to samples from another noise
NoiseDefCumSum: Cumulative sum of noise samples from another noise
NoiseDefGradient: Difference of adjacent samples of another noise
NoiseDefScaled: Arbitrary noise type scaled by a constant factor
NoiseDefSum: Sum of two arbitrary other noises
The module noise_defs_lisa defines noise types modeling the LISA noises needed in the simulator.
Note there is a dedicated noise type NoiseDefZero for representing zero noise. This allows to optimise this case within the noise generator, without any extra code path in user code. The elementary methods propagate zero noise if possible. For example, instantiating a NoiseDefFiltered with NoiseDefZero as base, calling elementary also returns NoiseDefZero. Noises with zero amplitudes are also represented by NoiseDefZero.
One design goal was to allow the creation of custom noise types in user code without the need to modify the code of the lisainstrument package. Custom noise types need to be derived from NoiseDefBase, and implement the elementary and discrete_psd_twosided methods.
Noise generators
Numpy based
The noise_gen_numpy module provides a noise generator based on a simple interface using plain numpy arrays for the noise samples.
For any elementary noise definition, there is a corresponding generator class derived from NoiseGenStateless. A NoiseGenStateless allows to create a number of noise samples. Repeated calls generate subsequent noise. In other words, calling it twice with given sizes results in the same sequence of samples as calling it once with the combined size. This requires to keep track of a state. This state is not stored in the NoiseGenStateless (hence the name) but has to be passed on by the user. The motivation is given by the main use case inside the streams package, which requires pure functions without side effects or internal state.
An important aspect of the implementation design is the use of Python singledispatch mechanism. There is a _noise_generator function to obtain a suitable subclass of NoiseGenStateless for a given subclass of NoiseDefBase. For each elementary noise type, the module registers a single dispatch for this function. A tree of noise definitions is mapped to a tree of corresponding noise generators.
To use a custom noise type, one has to register a _noise_generator dispatch for the custom noise definition. Note this does not require modifying noise_gen_numpy, one can register dispatches elsewhere in user code.
An important aspect of the noise generation is the handling of random seeds. There is a helper function make_integer_seed to create a integer seed from any number of arguments. when calling the generator one passes a seed. When generating noises that are based on operations on one or more base noises, the nested noise gets passes a seed based on seed and name of the parent noise. At some point this call chain ends at white noise, which uses the seed for the random number generator from numpy.
For direct use outside the context of the simulator, there is a user-facing interface to the numpy-based noise generator. The NoiseGen class turns a noise definition and seed into a convenient generator function for noise samples.
Stream based
The streams package contains two different ways to generate a stream of noise, which can be used interchangeably. The difference is the granularity with which the noise components are mapped to streams.
The first approach is defined in the noisy.noise_gen_numpy module. It represents a noise as a single stream which internally uses the NoiseGenStateless generator of the noisy module. The function streams.noise.stream_noise provides the user interface to turn noise definitions into noise streams. Being hidden inside a single stream, the noise generation subtasks cannot be executed in parallel by the stream scheduler. The only optimization is that streams.noise.stream_noise checks if the noise is zero. In this case, it returns a zero-valued StreamConst. Zero streams in turn propagate through stream operations as far as possible due to other optimizations in the streams package.
The second alternative, defined in noise_alt, is not using the noisy.noise_gen_numpy. Instead, it defines a stream type StreamNoiseWhite for white noise generation. Streams for other noise types are build by applying IIR filters and/or weighted summation to the streams. This makes use of the corresponding tools provided by the streams package, which are not specific to noise generation. Because noises defined as sums of other noises are mapped to sums of streams, the scheduler can execute the generation of those other noises in parallel.
As for the noise generator module noisy.noise_gen_numpy, Python’s single dispatch mechanism is used to define a single function stream_noise that can be called with any noise definition and returns a stream for the corresponding noise. In order to use this function with custom noise definitions, one would have to register a dispatch for the _noise_stream function. Note this does not require modifying noise_alt, one can register dispatches elsewhere in user code.
Currently, streams.stream_noise defaults to the implementation in streams.noise_alt.stream_noise, and the instrument simulator (see instru.instru_model) uses the default.