Creating Embedded Algorithms

Dataset Creation

Preprocess and create a dataset.

After collecting data we need to turn it into a dataset. To do so there are a few processing steps that need to occur:

  1. Extract labelled data from the rest of the unlabelled data.
  2. Perform any preprocessing (like pre-calculating magnitudes and subtractive static gravity)
  3. Condense into a single set
  4. Sample windows of data from the set (ensuring balanced amounts of data)
  5. Combine to create our dataset

Extract Labelled Data

Currently we have a large chunk of accelerometer data, containing both labelled (during experiment) and unlabelled data (outside of the experiment). First we can use the labelled data to identify the regions that are of interest (and dump the unlabelled data). To do that we can use the annotation events that we embedded. This is because the annotations contain:

  • The annotation label
  • The time of the annotation

We can use the annotation "start" and "stop" markers as window start and stop.

At the same time, we can also check that the data in each window is continuous. Data from the accelerometer is sensed at a consistent output sample rate. While streaming this data or recording, some of this data could have been dropped. Such artefacts must not taint the dataset and should be removed if present.

An easy way to check this is by ensuring that adjacent samples are a maximum duration apart, and that if they are, split the window, keeping both either side provided they are a minimum duration.

Here the gestures are about half a second long, so we can require that windows be a minimum of a second long.

The data_extract tool can be used to simplify this. We need to supply:

  • --annotations [ANNOTATION.csv]: The ANNOTATION.csv file
  • --data [ACC_2G.csv]: The Accelerometer data file
  • --label [labels.json]: The labels.json file and specify that
  • Mode: window that we're using windows marked by "start"/"end" suffixes and finally to output the results to
  • --output [output.csv]
data_extract.py
#!/usr/bin/env python3

"""Dataset Helper scripts"""

import argparse
import json
import sys

import polars

from infuse_iot.time import InfuseTime
from infuse_iot.util.argparse import ValidFile
from pathlib import Path

class Extract:
    def __init__(self, args):
        self.args = args

    def _windows(self, annotation: polars.DataFrame, labels: list[str]):
        """Return a list of (label, start_unix, end_unix) windows"""
        windows: list[tuple[str, float, float]] = []
        label_indices: dict[str, int] = {}

        if self.args.mode == "window":
            # Labels contain start and end suffixes for their corresponding windows.
            start_suffix = self.args.start_suffix
            end_suffix = self.args.end_suffix
            start_labels = {}
            end_labels = {}
            base_labels = []

            # Determine and match start and end labels from reference labels.
            for label in labels:
                start = label.removesuffix(start_suffix)
                end = label.removesuffix(end_suffix)
                base = label.removesuffix(start_suffix).removesuffix(end_suffix)
                if base == label:
                    # This is not a start/end label
                    continue
                if start != label:
                    start_labels[base] = label
                    label_indices[base] = len(label_indices)
                if end != label:
                    end_labels[base] = label
                base_labels.append(base)

            # Remove duplicates from base_labels
            base_labels = list(dict.fromkeys(base_labels))

            # Ensure labels are matched correctly
            for base in base_labels[:]:
                if base not in start_labels or base not in end_labels:
                    print(f"Warning: {base} is missing start or end labels")
                    sys.exit()

            if not base_labels:
                print("Warning: No start/end labels found")

            # Search for and extract start and end windows from the annotation
            for base in base_labels:
                start_event = start_labels[base]
                end_event = end_labels[base]
                starts = annotation.filter(polars.col("event") == f"b'{start_event}'").sort("unix_time")["unix_time"]
                ends = annotation.filter(polars.col("event") == f"b'{end_event}'").sort("unix_time")["unix_time"]

                if len(starts) != len(ends):
                    print(f"Warning: {base} has {len(starts)} start events but "
                          f"{len(ends)} end events. Check annotation pairs are correct.")
                    sys.exit()

                for start, end in zip(starts, ends, strict=False):
                    windows.append((base, float(start), float(end)))
        else:
            # Search for events and add the before and after padding time.
            for label in labels:
                label_indices[label] = len(label_indices)
                events = annotation.filter(polars.col("event") == f"b'{label}'").sort("unix_time")["unix_time"]
                for event_time in events:
                    windows.append((label, float(event_time) - self.args.before, float(event_time) + self.args.after))

        return label_indices, windows

    def run(self):
        def _parse_time_column(df: polars.DataFrame, column: str = "time") -> polars.Series:
            """Parse a CSV time column (unix seconds or UTC timestamp string) into unix seconds (float)"""
            try:
                return df[column].cast(polars.Float64)
            except polars.exceptions.InvalidOperationError:
                pass

            return (
                df[column]
                .str.to_datetime(time_unit="us", time_zone="UTC", strict=False)
                .dt.timestamp(time_unit="us")
                .cast(polars.Float64)
                / 1_000_000
            )

        with open(self.args.labels, encoding="utf-8") as f:
            labels: list[str] = json.load(f)

        annotation = polars.read_csv(self.args.annotation)
        gps_to_unix = InfuseTime.unix_time_from_gps_seconds
        annotation = annotation.with_columns(
            annotation["timestamp"].map_elements(gps_to_unix, return_dtype=polars.Int64).alias("unix_time")
        )

        label_indices, windows = self._windows(annotation, labels)
        if not windows:
            print("No matching windows found")
            return

        data = polars.read_csv(self.args.data)
        data = data.with_columns(_parse_time_column(data).alias("unix_time"))

        extracted: list[polars.DataFrame] = []
        for window_count, (label, start, end) in enumerate(windows):
            clean_label = label.strip()
            subset = data.filter((polars.col("unix_time") >= start) & (polars.col("unix_time") <= end))
            subset = subset.with_columns(
                polars.lit(clean_label).alias("label"),
                polars.lit(label_indices[label]).alias("label_id"),
                polars.lit(window_count).alias("window_id"),
            )
            extracted.append(subset)

        result = polars.concat(extracted).drop("unix_time")
        result.write_csv(self.args.output)
        print(f"Extracted {len(result)} rows across {len(windows)} windows to {self.args.output}")

if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Use annotation to extract labelled data windows.")
    parser.add_argument("--annotation", "-a", required=True, type=ValidFile, help="Annotation CSV file (time, timestamp, event)")
    parser.add_argument("--data", "-d", required=True, type=ValidFile, help="Data CSV file to extract labelled windows from")
    parser.add_argument("--output", "-o", required=True, type=Path, help="Output CSV file")
    parser.add_argument("--labels", "-l", type=ValidFile, required=True, help="JSON file containing an ordered list of labels")

    mode_subcommands = parser.add_subparsers(dest="mode", required=True, title="extraction modes", metavar="<mode>")

    window_parser = mode_subcommands.add_parser("window", help="Labels mark the start and end of an event window")
    window_parser.add_argument("--start-suffix", default="start", help="Suffix appended to a label for the start of a window")
    window_parser.add_argument("--end-suffix", default="end", help="Suffix appended to a label for the end of a window")

    instant_parser = mode_subcommands.add_parser("instant", help="Labels mark the exact moment of an event")
    instant_parser.add_argument("--before", type=float, default=0.5, help="Seconds before the event to include")
    instant_parser.add_argument("--after", type=float, default=0.5, help="Seconds after the event to include")
    
    args = parser.parse_args()

    extractor = Extract(args)
    extractor.run()

Add in the Path and Infuse ID (without leading 0x) below:

Altogether this creates:

python data_extract.py --annotation ./123456789ABCDEF_ANNOTATION.csv --data ./123456789ABCDEF_ACC_2G.csv --labels ./labels.json --output ./extracted_acc.csv window

The same can be performed for the Gyroscope data.

Note that the firmware configures the IMU to sample at 30Hz and 15Hz for the accelerometer and gyroscope respectively. However, the IMU used on the nRF54L15 Tag does not support these exact sample rates.
Therefore the driver selects the next closest sample rate which is 25Hz in both cases.

Preprocessing

The next step is to preprocess the data which is a bit more custom and depends on the scenario. Here are some useful things that will need to be done:

  • Merge the two accelerometer and gyroscope streams (which have two different sample rates)
  • Create a new column for Accelerometer Magnitude.
  • Create a new column for Gyroscope angular velocity.
  • Make sure that each sample is temporally adjacent (there is not gaps in data).

Since the preprocessing is often more bespoke and scenario-dependent, it doesn't make sense to have a standard script. A sample is provided below.

Your preferred coding AI tool is really useful to create and modify these sorts of scripts

We'll start with the basics: Importing the data. We'll make use of command line arguments to specify the --acc, --gyro, and --output file paths. We'll also need the sample rates of each accelerometer and gyroscope stream (more on that later).

import argparse
import pandas as pd

if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Preprocess accelerometer and gyroscope data")
    parser.add_argument("--acc", type=str, required=True, help="Path to the accelerometer CSV file")
    parser.add_argument("--gyro", type=str, required=True, help="Path to the gyroscope CSV file")
    parser.add_argument("--output", type=str, required=True, help="Path to the output merged CSV file")
    parser.add_argument("--acc-rate", type=float, required=True, help="Sample rate of the accelerometer data in Hz")
    parser.add_argument("--gyro-rate", type=float, required=True, help="Sample rate of the gyroscope data in Hz")
    args = parser.parse_args()

    # Only require the gyroscope sample rate if gyroscope data is provided
    if args.gyro and not args.gyro_rate:
        raise ValueError("Gyroscope sample rate (--gyro-rate) must be provided if gyroscope data (--gyro) is provided.")

    # Load the accelerometer and gyroscope data
    acc_data = pd.read_csv(args.acc, parse_dates=['time'], date_format="ISO8601", dtype={"sample.x": "int16", "sample.y": "int16", "sample.z": "int16"})
    gyro_data = pd.read_csv(args.gyro, parse_dates=['time'], date_format="ISO8601", dtype={"sample.x": "int16", "sample.y": "int16", "sample.z": "int16"}) if args.gyro else None

    # Rename axes to avoid confusion (sample prefix), but also avoid using '.' for Nordic Edge AI Lab
    acc_data.rename(columns={"sample.x": "acc_x", "sample.y": "acc_y", "sample.z": "acc_z"}, inplace=True)
    if gyro_data is not None:
        gyro_data.rename(columns={"sample.x": "gyro_x", "sample.y": "gyro_y", "sample.z": "gyro_z"}, inplace=True)

Merging the two streams

Depending on the firmware, both accelerometer and gyroscope streams may or may not have different sample rates, we need to both resample then and align them in time before merging. There are numerous resampling algorithms, however whatever we pick needs to also run on the embedded system. As a result some of the fancier algorithms may not be suitable for real-time processing. To keep things simple, this demo will just use a Zero Order Hold (ZOH) filter.

  # ...
  # Rename axes to avoid confusion (sample prefix), but also avoid using '.' for Nordic Edge AI Lab
      acc_data.rename(columns={"sample.x": "acc_x", "sample.y": "acc_y", "sample.z": "acc_z"}, inplace=True)
      if gyro_data is not None:
          gyro_data.rename(columns={"sample.x": "gyro_x", "sample.y": "gyro_y", "sample.z": "gyro_z"}, inplace=True)

+   # Find windows in both acc and gyro data with the same window ID (composed of label ID and sample ID columns)
+   if gyro_data is not None:
+       common_windows = set(acc_data['window_id']).intersection(set(gyro_data['window_id']))
+       acc_data = acc_data[acc_data['window_id'].isin(common_windows)]
+       gyro_data = gyro_data[gyro_data['window_id'].isin(common_windows)]
+
+   # Within the same window (using Window ID) Find the smallest offset between any two samples, and align the gyroscope data accordingly
+   if common_windows is not None and gyro_data is not None:
+       for window_id in common_windows:
+           acc_window = acc_data[acc_data['window_id'] == window_id].sort_values('time')
+           gyro_window = gyro_data[gyro_data['window_id'] == window_id].sort_values('time')
+           if not acc_window.empty and not gyro_window.empty:
+               time_offset = acc_window['time'].iloc[0] - gyro_window['time'].iloc[0]
+               gyro_data.loc[gyro_data['window_id'] == window_id, 'time'] += time_offset
+           # Remove samples where either gyro or acc data is outside the start/end of the other
+           gyro_data = gyro_data[~((gyro_data['window_id'] == window_id) & 
+                                   ((gyro_data['time'] < acc_window['time'].iloc[0]) | 
+                                   (gyro_data['time'] > acc_window['time'].iloc[-1])))]
+           acc_data = acc_data[~((acc_data['window_id'] == window_id) & 
+                               ((acc_data['time'] < gyro_window['time'].iloc[0]) | 
+                                   (acc_data['time'] > gyro_window['time'].iloc[-1])))]
+
+       # Resample the gyroscope data to match the accelerometer sample rate using Zero Order Hold (ZOH)
+       # merge_asof requires its `on` key to be globally sorted, not merely
+       # sorted within each window.  `window_id` still keeps matches isolated
+       # to their corresponding sub-window via `by` below.
+       gyro_data = gyro_data.sort_values(['time', 'window_id'])
+       acc_data = acc_data.sort_values(['time', 'window_id'])
+       # Match each accelerometer sample with the most recent gyro sample in
+       # the same contiguous sub-window (zero-order hold).
+       gyro_columns = [column for column in ('gyro_x', 'gyro_y', 'gyro_z')
+                       if column in gyro_data.columns]
+       gyro_data_resampled = pd.merge_asof(
+           acc_data[['window_id', 'time']],
+           gyro_data[['window_id', 'time'] + gyro_columns],
+           on='time', by='window_id', direction='backward',
+       ).drop(columns=['window_id', 'time'])
+       gyro_data_resampled.index = acc_data.index
+
+       # Finally, merge the two streams
+       merged_data = acc_data.join(gyro_data_resampled)
+       merged_data.to_csv(args.output, index=False)
+   else:
+       merged_data = acc_data

Creating 1D columns & output the result

After merging the accelerometer and gyroscope data, we want to create 1D columns for the magnitude and angular velocity.

  # ...
      # Finally, merge the two streams
      merged_data = acc_data.join(gyro_data_resampled)
      merged_data.to_csv(args.output, index=False)
  else:
      merged_data = acc_data

+   # Create 1D columns for magnitude and angular velocity (as uint32 to prevent overflow)
+   merged_data['acc_magnitude'] = ((merged_data['acc_x'].astype('uint32')**2 + merged_data['acc_y'].astype('uint32')**2 + merged_data['acc_z'].astype('uint32')**2)**0.5).astype('uint32')
+   if gyro_data is not None:
+       merged_data['gyro_magnitude'] = ((merged_data['gyro_x'].astype('uint32')**2 + merged_data['gyro_y'].astype('uint32')**2 + merged_data['gyro_z'].astype('uint32')**2)**0.5).astype('uint32')
+
+   metadata_columns = [column for column in ('label', 'label_id', 'window_id')
+                       if column in merged_data.columns]
+   merged_data = merged_data[[column for column in merged_data.columns
+                             if column not in metadata_columns] + metadata_columns]
+   merged_data.to_csv(args.output, index=False)

Contiguous Data

Gaps in the data can lead to anomalies in data, especially when performing spectral analysis. Although this isn't likely to occur when using the data logger, streaming the live data over Bluetooth can sometimes have gaps. As a result, we can and should verify that data does not have any gaps and when they are present, split the window around said gap. This gets a bit more complicated when both accelerometer and gyroscopic data are present and at different sample rates. This is where we use the sample rates provided earlier, to determine the acceptable length between each sample. The additions are available below.

End result

The resultant scripts looks like this (and can be downloaded).

preprocess_data.py
#!/usr/bin/env python3

import argparse
import pandas as pd


GAP_LEEWAY = 1.15

def split_contiguous_windows(data, sample_rate, min_window, window_column="window_id"):
    """Split source windows wherever the timestamp gap indicates missing samples.

    ``min_window`` is a duration in seconds.  A window is kept when its first to
    last sample duration is at least that long.  The original window id is
    retained for an un-split window; split windows receive ``_0``, ``_1``, ...
    suffixes.
    """
    if sample_rate <= 0:
        raise ValueError("Sample rates must be greater than zero")
    if min_window < 0:
        raise ValueError("--min-window must not be negative")

    sample_period = pd.Timedelta(seconds=1.0 / sample_rate * GAP_LEEWAY)
    result = []
    window_offset = 0
    for original_id, window in data.groupby(window_column, sort=True):
        numeric_id = int(original_id)
        window = window.sort_values("time").copy()
        breaks = window["time"].diff().gt(sample_period).fillna(False)
        groups = breaks.cumsum()
        parts = [part for _, part in window.groupby(groups, sort=False)]
        kept = [part for part in parts
                if part["time"].iloc[-1] - part["time"].iloc[0] >= pd.Timedelta(seconds=min_window)]
        # Do not silently turn a short, unbroken source window into a missing
        # window: min-window is intended to reject fragments created by gaps.
        if len(parts) == 1 and min_window == 0:
            kept = parts
        for number, part in enumerate(kept):
            part = part.copy()
            part[window_column] = numeric_id + window_offset + number
            result.append(part)
        window_offset += max(0, len(kept) - 1)
    if not result:
        return data.iloc[0:0].copy()
    return pd.concat(result, ignore_index=True)


def count_discontinuities(data, sample_rate):
    period = pd.Timedelta(seconds=1 / sample_rate * GAP_LEEWAY)
    return sum(window.sort_values("time")["time"].diff().gt(period).sum()
              for _, window in data.groupby("window_id", sort=False))


def split_paired_windows(acc_data, gyro_data, acc_rate, gyro_rate, min_window):
    """Split both streams at every gap found in either stream."""
    acc_period = pd.Timedelta(seconds=1 / acc_rate * GAP_LEEWAY)
    gyro_period = pd.Timedelta(seconds=1 / gyro_rate * GAP_LEEWAY)
    acc_result, gyro_result = [], []
    discontinuities = 0
    window_offset = 0
    for window_id in sorted(set(acc_data.window_id) & set(gyro_data.window_id)):
        numeric_id = int(window_id)
        acc = acc_data[acc_data.window_id == window_id].sort_values("time").copy()
        gyro = gyro_data[gyro_data.window_id == window_id].sort_values("time").copy()
        boundaries = set(acc.loc[acc.time.diff().gt(acc_period), "time"])
        boundaries.update(gyro.loc[gyro.time.diff().gt(gyro_period), "time"])
        boundaries = sorted(boundaries)
        discontinuities += len(boundaries)
        starts = [None] + boundaries
        ends = boundaries + [None]
        number = 0
        for start, end in zip(starts, ends):
            acc_part = acc if start is None and end is None else acc[(acc.time >= start if start is not None else True) & (acc.time < end if end is not None else True)]
            gyro_part = gyro if start is None and end is None else gyro[(gyro.time >= start if start is not None else True) & (gyro.time < end if end is not None else True)]
            if acc_part.empty or gyro_part.empty:
                continue
            overlap = min(acc_part.time.iloc[-1], gyro_part.time.iloc[-1]) - max(acc_part.time.iloc[0], gyro_part.time.iloc[0])
            if overlap < pd.Timedelta(seconds=min_window):
                continue
            sub_id = numeric_id + window_offset + number
            acc_part = acc_part.copy()
            gyro_part = gyro_part.copy()
            acc_part["window_id"] = sub_id
            gyro_part["window_id"] = sub_id
            acc_result.append(acc_part)
            gyro_result.append(gyro_part)
            number += 1
        window_offset += max(0, number - 1)
    empty_acc, empty_gyro = acc_data.iloc[0:0].copy(), gyro_data.iloc[0:0].copy()
    return (pd.concat(acc_result, ignore_index=True) if acc_result else empty_acc,
            pd.concat(gyro_result, ignore_index=True) if gyro_result else empty_gyro,
            discontinuities)

if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Preprocess accelerometer and gyroscope data")
    parser.add_argument("--acc", type=str, required=True, help="Path to the accelerometer CSV file")
    parser.add_argument("--gyro", type=str, help="Path to the gyroscope CSV file")
    parser.add_argument("--output", type=str, required=True, help="Path to the output merged CSV file")
    parser.add_argument("--acc-rate", type=float, required=True, help="Sample rate of the accelerometer data in Hz")
    parser.add_argument("--gyro-rate", type=float, help="Sample rate of the gyroscope data in Hz")
    parser.add_argument("--min-window", type=float, default=0.0,
                        help="Minimum contiguous sub-window duration in seconds (default: 0)")
    args = parser.parse_args()

    # Only require the gyroscope sample rate if gyroscope data is provided
    if args.gyro and not args.gyro_rate:
        raise ValueError("Gyroscope sample rate (--gyro-rate) must be provided if gyroscope data (--gyro) is provided.")

    # Load the accelerometer and gyroscope data
    acc_data = pd.read_csv(args.acc, parse_dates=['time'], date_format="ISO8601", dtype={"sample.x": "int16", "sample.y": "int16", "sample.z": "int16"})
    gyro_data = pd.read_csv(args.gyro, parse_dates=['time'], date_format="ISO8601", dtype={"sample.x": "int16", "sample.y": "int16", "sample.z": "int16"}) if args.gyro else None

    # Rename axes to avoid confusion (sample prefix), but also avoid using '.' for Nordic Edge AI Lab
    acc_data.rename(columns={"sample.x": "acc_x", "sample.y": "acc_y", "sample.z": "acc_z"}, inplace=True)
    if gyro_data is not None:
        gyro_data.rename(columns={"sample.x": "gyro_x", "sample.y": "gyro_y", "sample.z": "gyro_z"}, inplace=True)

    # Find windows in both acc and gyro data with the same window ID (composed of label ID and sample ID columns)
    discontinuities = 0
    if gyro_data is not None:
        common_windows = set(acc_data['window_id']).intersection(set(gyro_data['window_id']))
        acc_data = acc_data[acc_data['window_id'].isin(common_windows)]
        gyro_data = gyro_data[gyro_data['window_id'].isin(common_windows)]
    else:
        discontinuities = count_discontinuities(acc_data, args.acc_rate)
        acc_data = split_contiguous_windows(acc_data, args.acc_rate, args.min_window)
        common_windows = None

    # Within the same window (using Window ID) Find the smallest offset between any two samples, and align the gyroscope data accordingly
    if common_windows is not None and gyro_data is not None:
        for window_id in common_windows:
            acc_window = acc_data[acc_data['window_id'] == window_id].sort_values('time')
            gyro_window = gyro_data[gyro_data['window_id'] == window_id].sort_values('time')
            if not acc_window.empty and not gyro_window.empty:
                time_offset = acc_window['time'].iloc[0] - gyro_window['time'].iloc[0]
                gyro_data.loc[gyro_data['window_id'] == window_id, 'time'] += time_offset
            # Remove samples where either gyro or acc data is outside the start/end of the other
            gyro_data = gyro_data[~((gyro_data['window_id'] == window_id) & 
                                    ((gyro_data['time'] < acc_window['time'].iloc[0]) | 
                                    (gyro_data['time'] > acc_window['time'].iloc[-1])))]
            acc_data = acc_data[~((acc_data['window_id'] == window_id) & 
                                ((acc_data['time'] < gyro_window['time'].iloc[0]) | 
                                    (acc_data['time'] > gyro_window['time'].iloc[-1])))]

        acc_data, gyro_data, discontinuities = split_paired_windows(
            acc_data, gyro_data, args.acc_rate, args.gyro_rate, args.min_window
        )

        # Resample the gyroscope data to match the accelerometer sample rate using Zero Order Hold (ZOH)
        # merge_asof requires its `on` key to be globally sorted, not merely
        # sorted within each window.  `window_id` still keeps matches isolated
        # to their corresponding sub-window via `by` below.
        gyro_data = gyro_data.sort_values(['time', 'window_id'])
        acc_data = acc_data.sort_values(['time', 'window_id'])
        # Match each accelerometer sample with the most recent gyro sample in
        # the same contiguous sub-window (zero-order hold).
        gyro_columns = [column for column in ('gyro_x', 'gyro_y', 'gyro_z')
                        if column in gyro_data.columns]
        gyro_data_resampled = pd.merge_asof(
            acc_data[['window_id', 'time']],
            gyro_data[['window_id', 'time'] + gyro_columns],
            on='time', by='window_id', direction='backward',
        ).drop(columns=['window_id', 'time'])
        gyro_data_resampled.index = acc_data.index

        # Finally, merge the two streams
        merged_data = acc_data.join(gyro_data_resampled)
        merged_data.to_csv(args.output, index=False)
    else:
        merged_data = acc_data

    # Create 1D columns for magnitude and angular velocity (as uint32 to prevent overflow)
    merged_data['acc_magnitude'] = ((merged_data['acc_x'].astype('uint32')**2 + merged_data['acc_y'].astype('uint32')**2 + merged_data['acc_z'].astype('uint32')**2)**0.5).astype('uint32')
    if gyro_data is not None:
        merged_data['gyro_magnitude'] = ((merged_data['gyro_x'].astype('uint32')**2 + merged_data['gyro_y'].astype('uint32')**2 + merged_data['gyro_z'].astype('uint32')**2)**0.5).astype('uint32')

    metadata_columns = [column for column in ('label', 'label_id', 'window_id')
                        if column in merged_data.columns]
    merged_data = merged_data[[column for column in merged_data.columns
                              if column not in metadata_columns] + metadata_columns]
    merged_data.to_csv(args.output, index=False)
    print(f"Non-contiguous gaps detected: {discontinuities}")

Run the script to preprocess the data (accelerometer only):

python preprocess_data.py --acc extracted_acc.csv --acc-rate 25 --output acc_only_windows.csv --min-window 1

Or alternately include the Gyro data too:

python preprocess_data.py --acc extracted_acc.csv --gyro extracted_gyr.csv --acc-rate 25 --gyro-rate 25 --output acc_gyro_windows.csv --min-window 1
Here --min-window specifies the minimum number of consecutive samples required to form a valid window. This is used when there is a gap to ensure a subwindow is still large enough to be useful.

Sampling

We aren't done just yet though, since the data needs to be balanced to prevent bias when data for each label isn't evenly distributed. We'll use a final script to perform sampling and balance the dataset.

balance_windows.py
import argparse
import random
from pathlib import Path

import pandas as pd

REQUIRED_COLUMNS = {"time", "label", "label_id", "window_id"}


def positive_int(value: str) -> int:
    try:
        number = int(value)
    except ValueError as error:
        raise argparse.ArgumentTypeError("must be an integer") from error
    if number <= 0:
        raise argparse.ArgumentTypeError("must be greater than zero")
    return number


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("-i", "--input", required=True, type=Path, help="input CSV file")
    parser.add_argument("-o", "--output", required=True, type=Path, help="output CSV file")
    parser.add_argument(
        "--num-samples",
        required=True,
        type=positive_int,
        help="maximum target number of rows for each regular label_id",
    )
    parser.add_argument(
        "--exception-label-id",
        "--exception-label-ids",
        action="append",
        default=[],
        metavar="LABEL_ID",
        help=(
            "label_id excluded from the common minimum; repeat this option "
            "for multiple exception labels"
        ),
    )
    parser.add_argument(
        "--exception-sample-count",
        type=positive_int,
        metavar="COUNT",
        help=(
            "target row count for exception labels; omit this option to "
            "include all their windows"
        ),
    )
    parser.add_argument(
        "--drop-non-numerical",
        action="store_true",
        help="omit non-numerical columns and time from the output CSV",
    )
    return parser.parse_args()


def choose_window_positions(
    window_positions: list[list[int]],
    target_rows: int | None,
) -> set[int]:
    """Return positions from whole windows until the target is reached."""

    windows = list(window_positions)
    if target_rows is None:
        return {position for window in windows for position in window}

    random.shuffle(windows)
    selected: set[int] = set()
    for window in windows:
        selected.update(window)
        if len(selected) >= target_rows:
            break
    return selected


def balance_dataframe(
    data: pd.DataFrame,
    num_samples: int,
    exception_label_ids: set[str],
    exception_sample_count: int | None,
) -> tuple[pd.DataFrame, dict[str, int], int]:
    """Select complete windows while retaining the input row order."""

    data = data.reset_index(drop=True)
    label_counts = data.groupby("label_id", sort=False).size()
    regular_counts = label_counts[~label_counts.index.isin(exception_label_ids)]
    common_target = (
        min(num_samples, int(regular_counts.min()))
        if not regular_counts.empty
        else num_samples
    )

    grouped_windows = data.groupby(["label_id", "window_id"], sort=False).indices
    windows_by_label: dict[str, list[list[int]]] = {}
    for (label_id, _window_id), positions in grouped_windows.items():
        windows_by_label.setdefault(label_id, []).append(list(positions))

    selected_positions: set[int] = set()
    selected_counts: dict[str, int] = {}
    for label_id, windows in windows_by_label.items():
        target = (
            exception_sample_count
            if label_id in exception_label_ids
            else common_target
        )
        selected = choose_window_positions(windows, target)
        selected_positions.update(selected)
        selected_counts[label_id] = len(selected)

    positions = sorted(selected_positions)
    return data.iloc[positions], selected_counts, common_target


def main() -> None:
    args = parse_args()
    exception_label_ids = set(args.exception_label_id)
    if args.exception_sample_count is not None and not exception_label_ids:
        raise SystemExit("--exception-sample-count requires --exception-label-id")

    try:
        # dtype=str plus keep_default_na=False prevents numeric coercion and
        # prevents values such as "NA" from becoming missing values.
        data = pd.read_csv(args.input, dtype=str, keep_default_na=False)
        missing = REQUIRED_COLUMNS - set(data.columns)
        if missing:
            names = ", ".join(sorted(missing))
            raise ValueError(f"{args.input} is missing required column(s): {names}")

        selected, counts, common_target = balance_dataframe(
            data,
            args.num_samples,
            exception_label_ids,
            args.exception_sample_count,
        )
        if args.drop_non_numerical:
            non_numerical = [
                column
                for column in selected.columns
                if not pd.to_numeric(selected[column], errors="coerce").notna().all()
            ]
            selected = selected.drop(columns=[*non_numerical, "time"], errors="ignore")
        selected.to_csv(args.output, index=False)
    except (OSError, pd.errors.ParserError, ValueError) as error:
        raise SystemExit(str(error)) from error

    print("Input rows by label_id:")
    for label_id, count in data.groupby("label_id", sort=False).size().items():
        print(f"label_id={label_id!r}: {count} row(s)")
    print(f"Common target: {common_target} row(s)")
    for label_id in sorted(counts):
        print(f"label_id={label_id!r}: selected {counts[label_id]} row(s)")
    print(f"Wrote {len(selected)} row(s) to {args.output}")

if __name__ == "__main__":
    main()

and run

python balance_windows.py -i acc_only_windows.csv --num-samples 500 -o gesture_acc_dataset.csv --drop-non-numerical
The Nordic Edge AI Lab does not like non-numerical columns in the dataset, such as the label string and time columns. As a result, using the --drop-non-numerical flag is recommended.

This script will determine how many samples are available under each label (e.g. printing out):

Input rows by label_id:
label_id='2': 2968 row(s)
label_id='3': 2542 row(s)
label_id='4': 2696 row(s)
label_id='5': 3240 row(s)
label_id='6': 2744 row(s)
label_id='7': 2614 row(s)
label_id='8': 3908 row(s)
Common target: 500 row(s)
label_id='2': selected 1249 row(s)
label_id='3': selected 1184 row(s)
label_id='4': selected 658 row(s)
label_id='5': selected 1177 row(s)
label_id='6': selected 789 row(s)
label_id='7': selected 725 row(s)
label_id='8': selected 1509 row(s)

Select a number to use with --num-samples to maximize the number of samples for each label while maintaining balance. In this dataset, the fewest sample count is for label 3, containing 2500 samples. Therefore around 2500 would be a good choice.

Finally the dataset is ready and we can get started with Nordic Edge AI Lab to train a model.