Skip to content

Waveform analysis

A Waveform contains three aligned arrays:

  • .value: sampled signal values;
  • .clock: absolute clock-cycle numbers, starting at cycle 0 for the first sampling edge in the file;
  • .time: timestamps in the waveform file's native time unit.

Operations return new Waveform objects. They preserve the clock and time axes when filtering or transforming samples, so analysis results can still be traced back to the original simulation.

Load and operate on waveforms

from wavekit import VcdReader

with VcdReader('simulation.vcd') as reader:
    write_pointer = reader.load_waveform(
        'tb.fifo.w_ptr',
        clock='tb.clk',
    )
    read_pointer = reader.load_waveform(
        'tb.fifo.r_ptr',
        clock='tb.clk',
    )

    depth = 8
    occupancy = (write_pointer + depth - read_pointer) % depth

Waveforms support element-wise arithmetic, comparison, bitwise, and shift operations with other waveforms or numeric scalars.

Loaded waveforms are interpreted as unsigned by default. Pass signed=True to load_waveform() when a signal should be interpreted as signed. Two waveform operands must have the same signedness. Convert an existing waveform with .as_signed() or .as_unsigned() when necessary.

Filter and slice

Use mask() with a boolean NumPy array or a one-bit waveform. Use filter() when the condition is easiest to express as a scalar callback:

known = data.filter(lambda value: value != 0)
active = data.mask(valid == 1)

first_cycles = data.cycle_slice(0, 100)
window = data.time_slice(begin=10_000, end=20_000)

By default, cycle_slice(begin, end) and time_slice(begin, end) use a half-open range: the start is included and the end is excluded. Their bounds are absolute cycle numbers or simulation timestamps, not array indices. Use slice() or take() when you need array-index operations.

Relative access

Use relative(), ahead(), and back() when a calculation needs neighboring samples while retaining the original axes:

changed = data != data.back(1)
future_valid = valid.ahead(1)

The default boundary behavior repeats the first or last value. Use the pad and pad_value arguments when a different boundary policy is required.

Bit operations and composition

Bit selection follows Verilog-style indexing:

low_byte = data[7:0]
ready = valid[0]

split_bits() accepts either an integer for equal-width groups or a list of explicit widths. Groups are returned from least significant to most significant:

byte_fields = data.split_bits(8)
# byte_fields[0] contains bits [7:0], byte_fields[1] contains bits [15:8], and so on.

low, middle, high = data.split_bits([4, 8, 20])
# low contains bits [3:0], middle bits [11:4], and high bits [31:12].

Use Waveform.concatenate() to combine same-length fields into a wider waveform. The first field occupies the least-significant bits:

reconstructed = Waveform.concatenate([low, middle, high])

Use Waveform.merge() to combine corresponding samples with a custom function:

# Compute a majority vote across three 1-bit signals at each sample.
majority = Waveform.merge(
    [a, b, c],
    lambda values: int(sum(values) >= 2),
    width=1,
    signed=False,
)

Transitions and reduction

Use changed() to detect value changes in signals of any width:

occupancy_changes = occupancy.changed()
change_cycles = occupancy_changes.clock[occupancy_changes.value]

For one-bit signals, use rising_edge() and falling_edge() to locate the start and end of an active interval:

starts = valid.rising_edge()
stops = valid.falling_edge()

start_times = starts.time[starts.value]
stop_times = stops.time[stops.value]

Use any_edge() when the transition direction does not matter.

Use unique_consecutive() to keep the first sample of each consecutive run, or compress() to also preserve the final sample of the waveform. Use downsample() to aggregate consecutive chunks:

one_per_run = data.unique_consecutive()
compact = data.compress()
summary = data.downsample(100)  # average each 100-sample chunk

Keep unknown values visible

Ordinary loading replaces X/Z states with xz_value (zero by default). To preserve X/Z information, load a companion mask:

value = reader.load_waveform('tb.data[7:0]', clock='tb.clk', xz_value=0)
unknown = reader.load_unknown_mask('tb.data[7:0]', clock='tb.clk')
known_value = value.mask(unknown == 0)

The mask has one bit per selected source bit. Its values are unsigned, even if the value waveform is signed.

Extract NumPy results

Use .value when handing data to NumPy or another analysis library:

import numpy as np

print(f'Average active value: {np.mean(active.value):.2f}')
print(f'Maximum active value: {np.max(active.value)}')

Keep the Waveform itself until you no longer need cycle or timestamp context.