4.7. Developing a Timestamp Extractor Library

As was mentioned in Running the VMUSBReadout program, the user can pass a compiled library to VMUSBReadout at runtime. The path to the library is provided as the --timestamplib option and the contents of it are linked into the program dynamically. There are three functions that can be defined in that library and they will each be detailed in this section.

4.7.1. Implementing a getEventTimestamp() function

If the data from the VMUSBReadout program is intended for event building later, it must be labeled with a timestamp. This is particularly important for event data. To cause VMUSBReadout to output event data labeled with timestamps, the user must define a function whose signature is:

uint64_t getEventTimestamp(void *pBuffer);

The pBuffer parameter is a pointer to the very beginning of the event's body data. The responsibility of this function is to process the event data and return a 64-bit integer that will be used as the timestamp. The function can be defined to suit the needs of the experimenter and need not depend on the data at all. In most cases though, it will be extracted from the data referred to by the pBuffer pointer.

Example 4-3. A sample getEventTimestamp() implementation

The getEventTimestamp() function should be defined to meet the needs of a specific experiment. For our hypothetical experiment, we will be reading out a set of digitizers whose data should be timestamped with a value stored in a latching scaler. The details of the electronics are unimportant. In each readout cycle, the VM-USB will first read out two 32-bit words from the latching scaler containing the 64-bit timestamp followed by the digitized data. In other words our event buffer will look something like this when printed as a list of 16-bit words:


          
0x0007 
0x12ab 
0x0001 
0x0000
0x0000
0x1234 
0x1234 
0x1234
          
        

From the Understanding VMUSBReadout Output, we know the first 16-bit word is the event header for the subsequent data words. The header specifies that the event data was generated by stack 0 and that it consists of seven 16-bit words. The four 16-bits words that follow the event header correspond to the two 32-bit scaler values, which are then followed by the digitizer output. (This is hypothetical data...). Our task is to use the second, third, forth, and fifth words to reconstruct the complete 64-bit integer. Here is how we do that:


#include <cstdint>
#include <cstring>                        (1)
using namespace std;                            (2)

extern "C" {                                    (3)

  uint64_t getEventTimestamp(void* pBuffer) 
  {
    uint64_t tstamp = 0; 
    uint16_t header = 0;

    const char* pData = reinterpret_cast<const char*>(pBuffer); (4)

    memcpy(&header, pData, sizeof(header)); (5)

    if ((header&0x0fff)>=4) {            (6)
      pData += 2;                               (7)

      memcpy(&tstamp, pData, sizeof(tstamp)); 
    }
    return tstamp;                              (8)
  }

} // end of extern
        
(1)
Includes C++ headers for using the uint64_t, uint16_t, and memcpy. Both of these are C++11 conformant and require that the -std=c++11 flag be passed to the compiler to succeed. NSCLDAQ is conformant with C++11 and we recommend its use.
(2)
Technically, the uint64_t and uint16_t types and memcpy are defined only in the std namespace according to C++11. We bring them into scope with the using keyword.
(3)
C++ decorates or "mangles" the names of functions when they are compiled. VMUSBReadout demands that this is not the case. To avoid name mangling, we wrap our function in an extern block that declares the language linkage as C. In this way, the symbol table of the shared library we are creating will actually have an entry called "getEventTimestamp".
(4)
Void pointers are of little use beyond referencing a chunk of memory. Because we need to traverse the data buffer, we need to first cast the memory as type const char*. This is always a safe cast. In general, one should try to avoid reinterpret_cast in their code, because it is an easy way to introduce undefined behavior.
(5)
The C++ standard demands that data types adhere to certain memory alignment rules that are implementation defined. Violating these rules introduces undefined behavior. This demonstrates the correct way to access data in the buffer without introducing undefined behavior. Basically, we created properly aligned variables on the stack and then we copy the bytes of the buffer into our variables.
(6)
It is only gauranteed that 2 bytes worth of data exist in a buffer, those containing the event header information. Before looking deeper into the buffer for our timestamp, we have to ensure that the buffer actually contains that data. This check ensures that at least 64-bits (four 16-bit words) follow the event header.
(7)
The pointer pBuffer refers to the event header initially and our timestamp begin two bytes further into the body. This skips our pointer directly to the least significant 16-bits of the timestamp.
(8)
The value of the timestamp is returned. Without the return statement, the program exhibits undefined behavior. Note that if there was not enough data in the buffer to compute our timestamp, the returned value will be 0.

4.7.2. Implementing the getScalerTimestamp() function

Sometimes it may be useful to label the scaler data with a timestamp as well. It is not necessary but is useful. You have two options for implementing the scaler timestamp (these technically also applies to the event timestamp):

  1. Always return zero

  2. Returning computed value

If you have implemented a timestamp extractor for event data and you do not care about have a precise timestamp labeling your scaler data, the first option is a good one. A zero timestamp is treated specially by the event builder in that the corresponding event will simply be labeled with timestamp of the event preceding it. In other words, it gets dealt with in the order it was received.

If on the other hand, you have not defined an extractor for your event data, and you want to send your scaler data to the event builder, it is quintessential that you return a non-zero timestamp. Consider what happens when the event timestamps are always zero or the event data is non-existent. In such a case, the timestamp will always be zero because the event preceding will have a timestamp of 0 or never have happened. The event builder will then queue data in ways you did not originally expect. The other reason you might want to define a scaler timestamp extractor would be for having precise scaler information. The readout period of the scaler stack in the VM-USB historically has not been particularly precise, which makes comparing its value to data non-trivial at times. If instead, the data is timestamped, you can know for sure how it should be interpreted with the event data.

There is very little different to defining this function in comparison to the getEventTimestamp() function. Actually, if the first two scaler channels of your scaler data contained a 64-bit timestamp, you would define it in in an identical way. The prototype of the getScalerTimestamp is:

uint64_t getScalerTimestamp(void *pBuffer);

Example 4-4. Defining a null getScalerTimestamp()

Probably the most common case for the getScalerTimestamp() function will be to return zero. To do this, you would add three lines for your function just prior to the close of the extern block in the previous example. Here is what the end of that file might look like with that addition.


// ...
extern "C" {
  uint64_t getEventTimestamp(void* pBuffer)
  {
   // ...
  }

  uint64_t getScalerTimestamp(void* pBuffer) {
    return uint64_t(0);
  }

} // end of extern
        

4.7.3. Implementing an onBeginRun() function

The third function that can be defined in the timestamp extractor library has the prototype:

void onBeginRun(void);

This can be used for whatever you can think of. A sample usage might be to define an offset for the timestamp that gets read from a file every begin run. This could be done by declaring a global variable that is referred to in the getEventTimestamp() function.

Example 4-5. Adding an offset to the timestamp

Consider the following scenario. The data streams of two Readout programs are being merged together using the event builder and their clocks have been synchronized at the hardware level. In this way, the offset between the timestamps for these systems is fixed from run to run besides maybe a single clock tick variation. If the offset is 10 clock ticks and it is repeatable, the user might want to add an offset to one of the timestamps to remove the offset. The utility in doing this is questionable but let's consider that someone really needed to do so. We could accomplish this by storing the offset in a file that gets read in everytime a run begins. The getEventTimestamp() function could then correct its timestamp using this user-provided offset. Here is how we might do that.


#include <fstream>    (1)
#include <cstring>
#include <cstdint>

using namespace std;

uint64_t offset = 0         (2)

extern "C" {

  uint64_t getEventTimestamp(void* pBuffer) {
    uint64_t tstamp = 0; 
    uint16_t header = 0;

    const char* pData = reinterpret_cast<const char*>(pBuffer);

    memcpy(&header, pData, sizeof(header)); 

    if ((header&0x0fff)<=4) {
      pData += 2; 

      memcpy(&, pData, sizeof(tstamp)); 
    }

    return tstamp + offset;               (3)
  }

  void onBeginRun() {
    ifstream myOffsetFile("offset.dat");  (4)

    myOffsetFile >> offset;         (5)
  }

} // end of extern
        
(1)
Input operations from a file require the inclusion of this header. It defines the std::ifsream class.
(2)
The offset variable is declared in the global scope as to be accessible in all of the functions.
(3)
This is where we add the offset correction to the timestamp. Only positive corrections are supported by this implementation for the purpose of keeping the logic simple.
(4)
Open the file offset.dat for reading.
(5)
Extracts the offset from the file. This expects that the first non-whitespace string in the file is intepretable as a uint64_t. If this is not the case, an error will occur in the stream. In production code you should check for this and ensure proper behavior in the event of an error.

This is arguably a very naive implementation because the onBeginRun() does not check for any errors that might have happened when opening the file offset.dat. However, as long as the file exists and is readable, it should do what we expect. On the other hand, if there is a failure opening the file, the offset will have been initialized to zero. In a production experiment, you would probably want to alert the user by throwing an exception or printing a message to stderr.

4.7.3.1. Compiling the shared library

Unless have introduced dependencies on other software in your implementation of these methods, you should not need any complicated build system to compile it. Let's assume we put the code in a file named mytstamplib.cpp. We can compile it with nothing more than the following command


spdaqXX> g++ -std=c++11 -fPIC -shared -o mytstamplib.so mytstamplib.cpp