Siemens workshop · example 1 · vibration

A bearing fault, from signal to NPU

Which function makes which shape. What goes into the trainer. What every layer does with it. Every figure is the real output of the real code, run today.

next slide · space next step inside a slide · click right half = forward, left half = back

The task

Three machine states, as the sensor sees them

data.npz · one window per class
one window per class

A shaft at 3000 rpm = 50 Hz. An accelerometer at 1000 samples per second. One window = 128 samples = 128 ms. The job: for every window, name the state. imbalance is louder. bearing_fault is as loud as normal, but with spikes.

Chapter 1

How the data is made

steps/01_generate_data.py, function by function. Left: the code. Right: what that code returns.

Chapter 1 · Data

01_generate_data.py, step by step

each figure = the return value of the highlighted code
SAMPLE_RATE_HZ = 1000.0
WINDOW = 128

t = np.arange(WINDOW) / SAMPLE_RATE_HZ

Everything starts with 128 time stamps, one millisecond apart. Every generator function receives this array and returns one value per time stamp.

In generate() a random offset is added: t + rng.uniform(0, 1/50), so a window may start anywhere within one shaft turn.

time axis
def _window_normal(rng, t):
    fundamental = 1.0 * np.sin(2 * np.pi * SHAFT_FREQ_HZ * t)
    second_harmonic = 0.15 * np.sin(2 * np.pi * 2 * SHAFT_FREQ_HZ * t)
    return fundamental + second_harmonic + rng.normal(0.0, 0.05, t.shape)

Three terms, one return. The figure shows each term and the sum, for one call.

  • 50 Hz sine with amplitude 1.0: the shaft.
  • 100 Hz sine at 0.15: a small harmonic, machines are never pure.
  • Gaussian noise, standard deviation 0.05: the sensor.
normal decomposition
def _window_imbalance(rng, t):
    fundamental = (rng.uniform(2.2, 3.0)
                   * np.sin(2 * np.pi * SHAFT_FREQ_HZ * t))
    second_harmonic = 0.2 * np.sin(2 * np.pi * 2 * SHAFT_FREQ_HZ * t)
    return fundamental + second_harmonic + rng.normal(0.0, 0.05, t.shape)

Same three terms. The only real change: the shaft amplitude is drawn between 2.2 and 3.0 for every window (this call drew ).

Same y axis as the previous figure: the sum is simply two to three times taller. Nothing else differs.

imbalance decomposition
def _make_burst(rng, length):
    local_t = np.arange(length) / SAMPLE_RATE_HZ
    envelope = np.exp(-local_t * DECAY_PER_SECOND)          # 400
    burst = (rng.uniform(1.5, 2.5) * envelope
             * np.sin(2 * np.pi * RESONANCE_HZ * local_t))   # 220 Hz
    return burst - burst.mean()

One impact of one ball on the pit. Four panels, left to right: the decaying envelope, the 220 Hz ring, their product, and the product with its mean removed.

The envelope is at 6 % after 7 samples, so the burst is effectively 7 ms long although the array has 24 entries.

Without - burst.mean() every burst carries a positive offset (the yellow line in panel 3). Summed over a window that is a 0 Hz line no real accelerometer produces.

burst
def _window_bearing_fault(rng, t):
    signal = _window_normal(rng, t)
    spacing = int(SAMPLE_RATE_HZ / rng.uniform(90.0, 130.0))
    for start in range(rng.integers(0, spacing), len(t), spacing):
        length = min(BURST_SAMPLES, len(t) - start)
        signal[start : start + length] += _make_burst(rng, length)
    return signal

Literally normal plus impulses. Top: the normal window it starts from. Middle: the bursts that get added, dotted lines at every start (this call: spacing samples, impacts). Bottom: the sum, which is what the function returns.

Both classes carry the same 50 Hz energy. That is why loudness alone cannot separate them.

bearing fault construction
GENERATORS = (_window_normal, _window_imbalance, _window_bearing_fault)

def generate(count_per_class, seed, window_length=WINDOW):
    rng = np.random.default_rng(seed)
    t = np.arange(window_length) / SAMPLE_RATE_HZ
    for class_index, generator in enumerate(GENERATORS):
        for _ in range(count_per_class):                 # 2000
            offset = rng.uniform(0.0, 1.0 / SHAFT_FREQ_HZ)
            windows.append(generator(rng, t + offset))
            labels.append(class_index)
    X = np.stack(windows)                                # (6000, 128)
    y = np.array(labels, dtype=np.int64)                 # (6000,)

What it is. A table with 6000 rows. One row = one window = 128 numbers. Next to it a list y with one number per row: 0, 1 or 2.

How it works. Each generator function is called 2000 times. Every call returns 128 numbers. np.stack lays the returned windows under each other, in the order they were made: first all normal, then all imbalance, then all bearing_fault.

Why it is there. A network trains on many examples at once. The table is the form Keras expects: one example per row, the answer in a separate list.

six stacked windows with labels
    shuffle = rng.permutation(len(y))
    X, y = X[shuffle], y[shuffle]

What it is. A new random order of the 6000 rows.

How it works. rng.permutation(6000) is a list of the numbers 0 to 5999 in random order. Indexing X and y with the same list moves every window together with its answer, like shuffling cards with the name written on each card.

Why it is there. Step 2 cuts the table by position: the first 4800 rows train, the last 1200 test. Without shuffling, the test rows would all be bearing_fault and the training rows would never contain one.

the same six windows, shuffled
    X = X.reshape(-1, 1, window_length, 1)
    return X, y                                # X: (6000, 1, 128, 1)

What it is. The same 768 000 numbers, sorted into four labelled axes instead of two.

How it works. Keras treats a signal like a picture: rows × columns × colour channels. A window becomes a picture with 1 row, 128 columns and 1 channel. No number moves, only the labels change.

Why it is there. Every layer in the network is written as a 2-D picture operation, because that is what the NPU converter maps reliably. Height 1 from here to the end.

one window as row
scale = float(np.percentile(np.abs(X), 99.5))       # 2.9239
X = np.clip(X / scale, -1.0, 1.0).astype(np.float32)

np.savez_compressed("data.npz", X=X, y=y, scale=scale,
                    fs=np.float32(SAMPLE_RATE_HZ), classes=np.array(CLASSES))

What it is. One division. Every number in X is divided by the same constant, scale = 2.9239.

How it works. scale is picked so that 99.5 % of all values fit inside ±scale. After the division everything lies between −1 and 1. The shape of every window is unchanged. The constant is saved into data.npz next to X and y.

Why it is there. The NPU stores each value as an integer from −128 to 127. Spread over ±1 all 256 steps are used. The board later divides its live signal by the same constant, so the number has to travel with the model.

one window before and after normalisation
Chapter 2

Into the trainer

steps/02_train_model.py: what it loads, how it splits, what a batch looks like, what comes out.

Chapter 2 · Trainer

02_train_model.py, step by step

training data, validation data, test data
data = np.load("data.npz", allow_pickle=False)
X, y = data["X"], data["y"]
classes = [str(c) for c in data["classes"]]   # 3 names

_, height, window, channels = X.shape          # 1, 128, 1
assert height == 1

What the trainer receives:

keyshapecontent
X(6000, 1, 128, 1) float326000 windows in [-1, 1], shuffled
y(6000,) int64class index per window: 0, 1, 2
scalescalar, for the board
classes(3,) strthe names, in index order
labels
cut = int(0.8 * len(y))                 # 4800
X_train, y_train = X[:cut], y[:cut]      # 4800 windows
X_test,  y_test  = X[cut:], y[cut:]      # 1200 windows

model.fit(X_train, y_train, validation_split=0.1, ...)   # 4320 fit, 480 validation
model.evaluate(X_test, y_test)                            # 1200 test, once

Three disjoint sets, cut by position (step 1 already shuffled):

  • fit set, windows: the weights are adjusted on these.
  • validation set, windows: scored after every epoch, never trained on. Shows overfitting while it happens.
  • test set, windows: touched exactly once, at the end. This is the number you report.
split
inputs = keras.Input(shape=(1, window, channels), name="signal")
x = layers.Conv2D(16, (1, 7), strides=(1, 2), padding="same", activation="relu")(inputs)
x = layers.Conv2D(32, (1, 5), strides=(1, 2), padding="same", activation="relu")(x)
x = layers.Conv2D(64, (1, 3), strides=(1, 2), padding="same", activation="relu")(x)
x = layers.AveragePooling2D(pool_size=(1, window // 8))(x)
x = layers.Reshape((64,))(x)
outputs = layers.Dense(num_classes, name="logits")(x)
model = keras.Model(inputs, outputs, name="timeseries_cnn")

This is what model.summary() printed in today's run. Chapter 3 goes through every row with its real output.

layertypeoutput shapeparams
signalInput(None, 1, 128, 1)0
conv2dConv2D (1,7) s2(None, 1, 64, 16)128
conv2d_1Conv2D (1,5) s2(None, 1, 32, 32)2 592
conv2d_2Conv2D (1,3) s2(None, 1, 16, 64)6 208
average_pooling2dAveragePooling2D (1,16)(None, 1, 1, 64)0
reshapeReshape(None, 64)0
logitsDense(None, 3)195
Total
model.compile(
    optimizer=keras.optimizers.Adam(1e-3),
    loss=keras.losses.SparseCategoricalCrossentropy(from_logits=True),
    metrics=["accuracy"],
)

Loss: how wrong the network is on a batch. Cross-entropy is small when the true class has the largest logit by a wide margin, large when another class wins. Sparse because y holds integers, not one-hot vectors.

from_logits=True: the model outputs raw scores. The loss applies the softmax itself, during training only. That is why the saved graph has no softmax layer, and why the board computes it in numpy.

Adam decides how far to move each weight per step. Learning rate 0.001 is its default.

model.fit(X_train, y_train,
          validation_split=0.1,
          epochs=15,
          batch_size=64,
          verbose=2)

The fit set is cut into batches of 64 windows. The figure is the first batch of epoch 1, exactly as the network sees it: 64 rows, 128 columns, one colour per amplitude, with the 64 labels on the left.

For each batch: forward pass, loss, gradient, one Adam step on all weights. batches make one epoch. Then the 480 validation windows are scored. 15 epochs.

one batch
Epoch 1/15
68/68 - 0s - accuracy: 0.5289 - loss: 0.8164 - val_accuracy: 1.0000 - val_loss: 0.4196
Epoch 2/15
68/68 - 0s - accuracy: 1.0000 - loss: 0.1279 - val_accuracy: 1.0000 - val_loss: 0.0124
...
Epoch 15/15
68/68 - 0s - accuracy: 1.0000 - loss: 7.7510e-05 - val_accuracy: 1.0000 - val_loss: 7.5123e-05

Today's log, plotted. Blue: the fit set. Green: the validation set. Both fall together, so nothing is memorised. After epoch 2 every window is already classified correctly and the loss only sharpens the margins.

On a real dataset the green curve would flatten or rise while the blue keeps falling. That gap is overfitting, and the validation set exists to show it.

training curves
loss, accuracy = model.evaluate(X_test, y_test, verbose=0)
if accuracy < 0.90:
    print("WARNING: below 90% - add epochs or data before quantising.")

predicted = np.argmax(model.predict(X_test), axis=1)
# confusion matrix: row = true, column = predicted
model.save("model.keras")

1200 windows the network has never seen. Rows: the true class from y_test. Columns: argmax of the three logits. The diagonal is right, everything else is wrong.

The matrix says which class fails. The accuracy hides that. The cell to watch is always imbalance ↔ bearing_fault, the pair a threshold could not separate.

Below 90 % stop here. INT8 can only lose accuracy, never gain it.

confusion matrix
Chapter 3

What a layer is

The network uses five kinds of layer. Each one is a small, fixed piece of arithmetic. First each kind on its own, with the real numbers from this model. Then the six layers in order.

Chapter 3 · Layers

The five layer types

each computed with the trained weights on one bearing_fault window
layers.Conv2D(16, (1, 7), strides=(1, 2),
              padding="same", activation="relu")

What it is. A kernel: 7 weights in a row. Weights are numbers the training found.

How it works. Lay the kernel over 7 neighbouring samples. Multiply each sample by the weight above it. Add the seven products and a bias. That is one output number. Move two samples to the right, repeat. 128 samples give 64 numbers. 16 kernels give 16 such rows, the channels.

Why it is there. It is a pattern detector. A kernel shaped like a short ring gives a large number wherever the signal has that ring. The network needs a detector that works at any position in the window, and a sliding kernel is exactly that.

convolution worked example
activation="relu"        # relu(x) = max(0, x)

What it is. A rule for every single number: below zero becomes 0, above zero stays.

How it works. It is applied to every one of the 64 × 16 numbers a convolution produces, right after the sum. Nothing is learned here, there are no weights.

Why it is there. After it, a positive number means "the kernel's pattern is here" and 0 means "not here". Without this cut, three convolutions in a row would be mathematically one convolution, and the network could not build a large pattern (a train of rings) out of small ones (one ring).

relu worked example
layers.AveragePooling2D(pool_size=(1, 16))

What it is. An average. For each channel, its 16 numbers along time become one number.

How it works. Channel 57 has 16 values, one per 8 ms slice of the window. Their mean is 1.64. That is done for all 64 channels. 16 × 64 numbers in, 64 numbers out. No weights.

Why it is there. After this the network knows how much each pattern occurred in the window, but no longer when. That is wanted: a bearing fault at 20 ms is the same fault as at 90 ms. And 64 numbers are enough for a decision.

pooling worked example
layers.Reshape((64,))        # (1, 1, 1, 64)  →  (1, 64)

What it is. A re-labelling. The 64 numbers stay exactly the same.

How it works. After pooling the 64 numbers sit in a 4-axis box with shape (1, 1, 1, 64): one window, one row, one time step, 64 channels. Reshape drops the three axes of length 1 and leaves a plain list of 64.

Why it is there. The Dense layer that follows expects a plain list. Zero weights, zero arithmetic. It exists only to make the shapes fit.

same values
layers.Dense(3, name="logits")

What it is. Three weighted sums. Each output has its own 64 weights and one bias.

How it works. Output = in[0]·w0 + in[1]·w1 + … + in[63]·w63 + bias. A red line in the picture is a positive weight (pushes the output up), a blue line a negative one (pulls it down). For this window the bearing_fault sum is 6.28 + 0.02 = 6.29. The other two sums are negative.

Why it is there. It turns 64 numbers of the kind "how much of pattern k" into 3 votes, one per class. The biggest vote is the answer. These are the logits.

dense worked example
Chapter 3 · Layers

Why Conv2D with height 1, and what the last Reshape does

two export routes, one converter

What it isThe network is written with Conv2D layers whose kernels are 1 row high, (1, 7), (1, 5), (1, 3), instead of the Conv1D a time series would normally get. The signal is treated as a picture with one row.

How it worksTFLite has no 1-D convolution. On export every Conv1D becomes a Conv2D anyway, wrapped in Reshape and Squeeze nodes that add and remove the height axis around each layer. Written as height-1 Conv2D from the start, nothing has to be wrapped: the exported graph is exactly the layers we wrote.

Why it is thereThe Neutron converter maps convolutions, not Reshape or Squeeze. Every such node between two convolutions cuts the chain and lowers the Gate A ratio, and every cut is a CPU to NPU hand-over with its own fixed cost. Route B keeps one unbroken chain: 9 / 9, one NeutronGraph op. The single Reshape we keep sits after the last convolution, where it can cut nothing, and only relabels the 64 pooled numbers for the Dense layer.

Conv1D route versus Conv2D route
Chapter 3 · Layers

The layers, one at a time

pick the window class →
inputs = keras.Input(shape=(1, window, channels), name="signal")

The window enters as one row of 128 values. Below it is drawn twice: as the curve you know, and as the one-row image every following layer figure uses. Blue is negative, red is positive.

input
x = layers.Conv2D(16, (1, 7), strides=(1, 2), padding="same", activation="relu")(inputs)

Conv2D: a kernel of 7 numbers slides along the row. At every second position it multiplies the 7 samples under it with its 7 weights and sums them. One kernel gives one output row of 64 values. 16 kernels give 16 rows: the 16 channels. ReLU then clips negative sums to zero.

Output: 64 time steps × 16 channels. Parameters: 7 × 16 weights + 16 biases = 128. The vertical stripes in the fault window are the bursts, found by kernels that match a sharp edge.

conv1 output
model.layers[1].get_weights()[0].shape   # (1, 7, 1, 16)

These are the learned weights of the first layer, seven bars per kernel. Some look like a piece of a sine, some like a difference of neighbours (an edge detector), some like a bump. Nobody designed them. Training moved them there in 15 epochs.

kernels
x = layers.Conv2D(32, (1, 5), strides=(1, 2), padding="same", activation="relu")(x)

Same operation, but each kernel now reads all 16 channels at once: 5 positions × 16 channels = 80 weights per kernel, 32 kernels. It combines the edge and sine detectors of layer 1 into shapes. 5 steps of the half-speed axis cover 10 ms of the original signal on top of the 7 ms each input already saw.

Output: 32 × 32. Parameters 5 × 16 × 32 + 32 = 2 592. The stripes are sparser: fewer channels react, each more specific.

conv2 output
x = layers.Conv2D(64, (1, 3), strides=(1, 2), padding="same", activation="relu")(x)

64 kernels, 3 wide, reading 32 channels: 96 weights each, 6 208 parameters, two thirds of the network. One output value here depends on 7 + 4·2 + 2·4 = 23 samples = 23 ms of the original signal. Bursts come every 7 to 11 ms, so one value can see two or three of them. That is the difference between "one loud spike" and "a train of rings".

Output: 16 × 64. Switch the class at the top right: each class lights a different set of channels.

conv3 output
x = layers.AveragePooling2D(pool_size=(1, window // 8))(x)   # (1, 16)

AveragePooling2D: for each of the 64 channels, take the mean of its 16 time steps. 16 × 64 becomes 1 × 64. The result answers "how strongly did detector k fire over the whole window", and where in the window no longer matters. A fault at 20 ms is the same fault as at 90 ms.

No parameters. Fixed size (1, 16) instead of GlobalAveragePooling2D because the global variant lowers to a Mean op the NPU converter maps worse.

pooled
x = layers.Reshape((64,))(x)     # (1, 1, 1, 64) → (1, 64)

Reshape computes nothing. The same 64 numbers from the previous figure are re-labelled from a 4-axis tensor to a flat vector, because a Dense layer reads vectors. Zero parameters, zero cost.

pooled vector
outputs = layers.Dense(num_classes, name="logits")(x)    # 3

Dense: every one of the 64 inputs is connected to every one of the 3 outputs with its own weight. Three weighted sums plus three biases: 64 × 3 + 3 = 195 parameters. The three outputs are the logits, one raw score per class. The largest wins.

Left: the logits for the selected window. Right: their softmax, which the board computes in numpy after the model. No softmax is in the graph.

logits
model.get_layer("logits").get_weights()[0].shape   # (64, 3)

The 192 weights of the last layer as an image: one row per class, one column per pooled channel. Red means "this channel votes for this class", blue "against". Compare with the pooled bars two steps back: the channels that were high for bearing_fault are the red cells in the bottom row.

dense weights
Chapter 4

All layers together

Press space six times. One window walks through the network. Change the class and watch a different path light up.

Chapter 4 · Together

One forward pass

window # from the test set
input(1, 1, 128, 1)
conv2d16 kernels × 7, stride 2, ReLU → 64 × 16
conv2d_132 kernels × 5 × 16, stride 2, ReLU → 32 × 32
conv2d_264 kernels × 3 × 32, stride 2, ReLU → 16 × 64
average_pooling2dmean over 16 steps → 64
logitsDense 64 → 3, then argmax
Chapter 5

From model.keras to the NPU

Quantise, convert, run. Three gates against three silent failures.

Chapter 5 · NPU

Step 3: INT8 quantisation

tools/quantize_int8.py
# 1. rebuild with a fixed batch axis, copy the trained weights in
fixed_input = keras.Input(batch_shape=(1, height, window, channels),
                          dtype="float32", name="signal")
inference_model = keras.models.clone_model(model, input_tensors=fixed_input)
inference_model.set_weights(model.get_weights())

# 2. calibrate on 300 real windows, emit int8 in and out
def representative_data(X, count):
    def generator():
        for window in X[:count]:
            yield [window[np.newaxis, ...].astype(np.float32)]
    return generator

converter = tf.lite.TFLiteConverter.from_keras_model(inference_model)
converter.optimizations = [tf.lite.Optimize.DEFAULT]
converter.representative_dataset = representative_data(X, 300)
converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8]
converter.inference_input_type  = tf.int8
converter.inference_output_type = tf.int8
open("model_int8.tflite", "wb").write(converter.convert())

The NPU computes with 8-bit integers, −128 to 127. Every tensor gets a scale and a zero point: real = (int8 − zero) × scale.

  • The weights are known, so their range is known. The activations depend on the input, so 300 real windows are run through the float network and the min and max of every intermediate tensor is recorded. That is calibration. Step 1 shuffled, so the first 300 hold all three classes.
  • Batch pinned to 1: the trained model has a dynamic batch axis. Fine for training, fatal for conversion. batch_shape=(1, …) is the whole difference.
  • int8 in and out: the caller must quantise the input itself. That is the board code.
The script checks shape and dtype itself and reports float-vs-INT8 accuracy on 600 windows. Here: zero loss. The task is too easy to lose anything, which is fine for teaching the chain.
Chapter 5 · NPU

Step 4: convert for the NPU, Gate A

tools/convert_for_neutron.sh
neutron_converter --input model_int8.tflite --target imx95 \
                  --output model_neutron.tflite 2>&1 | tee "$LOG"

# read the ratio out of the log ...
RATIO="$(sed -n 's/.*Operator conversion ratio.*=[[:space:]]*\([0-9.eE+-]*\).*/\1/p' "$LOG" | tail -n 1)"

# ... and abort hard below the threshold. The converter itself never does.
if awk -v r="$RATIO" -v t="${THRESHOLD:-0.80}" 'BEGIN { exit !(r + 0 < t + 0) }'; then
  echo "ERROR: ratio ${RATIO} is below the threshold."
  echo "The file exists and is valid - it would run on the board, but mostly on the CPU."
  exit 3
fi
echo "Gate A passed."

Measured for this model on 2026-09-19: Operator conversion ratio = 9 / 9 = 1

The trap. The i.MX 95 has no runtime compilation. A plain INT8 .tflite handed to the NPU delegate raises no error. It runs on the CPU. So the model goes through the offline neutron_converter, and that tool exits with code 0 even when it mapped zero operations. NXP TechSupport: "the converter silently falls back to CPU execution."
  • Linux x86_64 wheel only (eiq-neutron-sdk==3.1.2, pinned to the board's BSP). On macOS the script builds a Docker container and runs it there.
  • It replaces the whole mapped chain with one custom op, NeutronGraph. Only libneutron_delegate.so can resolve it. Without the delegate the file does not load at all. Ship both files.
Chapter 5 · NPU

On the board: load, Gate B

board/inference_stream.py
from tflite_runtime.interpreter import Interpreter, load_delegate

delegates = [load_delegate("/usr/lib/libneutron_delegate.so")]
interpreter = Interpreter(model_path="model_neutron.tflite",
                          experimental_delegates=delegates,
                          num_threads=1)
interpreter.allocate_tensors()

The delegate prints this on load. This line is Gate B:

INFO: NeutronDelegate delegate: 1 nodes delegated out of 1 nodes with 1 partitions.
  • The board runs the factory image: python3 3.13, tflite_runtime 2.19, the delegate library, /dev/neutron0. Nothing to install.
  • 1 out of 1 is the optimum: after conversion the graph is that one NeutronGraph node.
  • The failure looks like this and produces no error: 0 nodes delegated out of 31 nodes. Reproduced live with an unconverted MobileNet. It runs, the results are right, and everything is on the CPU.
Chapter 5 · NPU

One inference on the board

TFLiteClassifier.classify
def classify(self, window):
    value = window[np.newaxis, ...].astype(np.float32)      # (1, 1, 128, 1)
    if self._input["dtype"] == np.int8:
        scale, zero = self._input["quantization"]
        value = np.clip(np.round(value / scale + zero), -128, 127).astype(np.int8)
    self.interpreter.set_tensor(self._input["index"], value)
    self.interpreter.invoke()                              # the NPU
    raw = self.interpreter.get_tensor(self._output["index"])[0].astype(np.float32)
    if self._output["dtype"] == np.int8:
        scale, zero = self._output["quantization"]
        raw = (raw - zero) * scale                          # logits, float again
    shifted = np.exp(raw - raw.max())                        # softmax, on the CPU
    scores = shifted / shifted.sum()
    return int(np.argmax(raw)), [float(s) for s in scores]
  1. Quantise the input with the scale and zero point the converter stored in the model.
  2. invoke() runs the one NeutronGraph op on the NPU. All six layers of chapter 3, in one call.
  3. Dequantise the three INT8 logits back to float.
  4. Softmax in numpy. argmax alone gives the class. The softmax only makes the displayed score.

Measured end to end from a live stream: p50 1.427 ms, p95 1.684 ms per window against a 32 ms budget.

Gate C: read /sys/class/remoteproc/remoteproc0/state before and after. offline right after a run proves the NPU computed nothing. running proves only that the delegate reached the driver. Gate B says how much moved.
Chapter 5 · NPU

The result

measured on the FRDM-IMX95, 2026-09-19
ConfigurationParametersCPU medianNPU medianFactor
--window 128 --width 1 (this deck)9 1230.042 ms0.075 ms0.6×
--window 1024 --width 4140 9311.309 ms0.119 ms11.0×

The first row is the important one. All three gates green, the NPU demonstrably computed, and it was still slower than the CPU.

The second row explains it: NPU time grew from 0.075 to 0.119 ms while CPU time exploded 31×. There is a fixed per-call charge of under 0.1 ms. Only above that does throughput matter.

Delegated does not mean faster. An NPU needs enough work per call. Below roughly 10 000 parameters the CPU wins. --window and --width are the two knobs that make the model big enough.

All code from seminare/siemens-schulung at 4b44b51. Figures from a run of steps 1 and 2 on 2026-09-21. Board numbers from the example's README.

Bearing fault detection on the i.MX 95 Neutron NPU