Part I: Foundations of Sensory AI
Chapter 5: Sensor Data Engineering and Leakage-Safe Datasets

Formats for telemetry, waveforms, event streams, and point clouds

"They handed me a folder of float32 arrays with no sample rate, no units, and no timezone. I did not have a dataset. I had a rumor."

A Disillusioned AI Agent

Prerequisites

This section assumes basic Python and the ideas of sampling rate, timestamps, and clock synchronization introduced in Chapter 3. It leans lightly on the notions of quantization and dynamic range from Chapter 2. No storage-engine background is needed; the columnar and chunked-array ideas are built from scratch here, and the full toolchain (Parquet, HDF5, Zarr, MCAP) is catalogued in Appendix D. Everything you store here is what later sections in this chapter window, split, and normalize.

The Big Picture

Before you can model a sensor, you have to store what it produced without quietly destroying it. Sensor data is not one shape: a temperature log, a vibration waveform, a CAN-bus trace, and a lidar sweep have almost nothing structurally in common, and forcing all four into the same CSV is how datasets die. This section names the four canonical shapes, matches each to the storage layout that fits its physics, and insists on one non-negotiable rule: a format must be self-describing. A file of numbers with no sample rate, units, device identity, or time base is not a compact dataset. It is a guess waiting to be misread.

Four shapes, not one

Almost every sensor stream you will meet falls into one of four structural families, distinguished by three questions: are the samples uniformly spaced in time, is the data dense or sparse, and does each record hold a fixed or variable number of values? Get the family right and the storage decision nearly makes itself.

Telemetry is low-rate, multivariate, timestamped scalars: engine RPM, battery voltage, GPS latitude, cabin temperature, each a named channel sampled from a fraction of a hertz up to roughly a hundred. It is naturally a wide table, one row per timestamp, one column per channel. Waveforms are dense, uniformly sampled continuous signals: audio at 44.1 kHz, an ECG trace at 500 Hz, a bearing accelerometer at 25.6 kHz. Here the sample rate is not data to store per row; it is a single number that, together with a start time, lets you reconstruct every timestamp implicitly. Event streams are asynchronous: things happen at irregular instants and you record a tuple when they do. A CAN message, a syslog line, a keystroke, or an event-camera pixel firing \((x, y, \text{polarity}, t)\) all share the trait that time between records is itself information. Point clouds are unordered sets: a single lidar sweep is a variable-size bag of points, each carrying \((x, y, z)\) plus intensity and often a per-point timestamp, with no meaningful row order and a different cardinality every frame.

Table 5.1.1 lays these four side by side. The reason this taxonomy matters is that each shape breaks a different default. Telemetry punishes row-major storage when you only need two of two hundred channels. Waveforms punish any format that stores a timestamp per sample. Event streams punish fixed-rate assumptions. Point clouds punish anything that expects a rectangular array. Choosing the layout is choosing which of these mistakes you refuse to make.

Table 5.1.1. The four canonical sensor-data shapes and the storage layout each one wants.
ShapeTimingDensityRecordTypical rateFitting layout
Telemetryregular or irregularsparse channelsfixed width0.1–100 Hzcolumnar table (Parquet)
Waveformuniformdensescalar per tick0.1–100 kHzchunked array (HDF5, Zarr, WAV, EDF)
Event streamasynchronoussparse in timefixed-width tupleburstyrow log (Parquet, MCAP, AEDAT)
Point cloudper-framesparse in spacevariable count10–20 Hz framespacked binary (PCD, LAS/LAZ, .bin)

Key Insight

A storage format is not a neutral container; it is a claim about what your data is. Store a 25 kHz vibration waveform as a CSV with a timestamp column and you have inflated the file tenfold, thrown away the guarantee of uniform spacing, and invited floating-point timestamps that drift out of step with reality. Match the layout to the shape and the format enforces the physics for you. Mismatch them and every downstream tool inherits a lie about the signal.

Rows, columns, and chunks: why layout is destiny

The deepest split in sensor storage is row-major versus columnar. A row-major file (CSV, JSON lines, a naive database table) stores all fields of record 1, then all fields of record 2, and so on. A columnar file (Parquet, Arrow, ORC) stores all values of channel A contiguously, then all of channel B. For telemetry the columnar layout is transformative for three concrete reasons. First, selective reads: an analyst who wants only rpm and oil_temp from a two-hundred-channel truck log reads two columns off disk and skips the rest, instead of parsing every byte of every row. Second, compression: values within one channel are similar (temperature changes slowly), so a column of them compresses far better than interleaved heterogeneous rows; run-length and dictionary encoding routinely shrink telemetry by five to twenty times. Third, predicate pushdown: column statistics stored per block let a reader skip entire chunks whose value range cannot match a filter, so "give me the rows where RPM exceeded 6000" touches only the blocks that could contain them.

Waveforms want a different discipline. A dense uniform signal has no per-sample metadata worth storing, so the right structure is a chunked array: a long contiguous block of one dtype, split into fixed-size chunks along the time axis, with the sample rate, units, dtype, start time, and device identity held once as attributes. HDF5 and Zarr do exactly this; the venerable WAV and biosignal EDF (European Data Format) containers do a domain-specific version of the same idea. Chunking is what makes a multi-hour recording streamable: you read the ten-second window you need without materializing the whole file, which is precisely the access pattern Section 5.2 builds windows on. The recurring principle across both cases is that timestamps for a uniform signal are computed, never stored: sample \(n\) sits at time \(t_0 + n/f_s\), so persisting a timestamp column would waste space and, worse, let rounding error desynchronize the axis. Getting \(t_0\) and \(f_s\) right ties directly back to the synchronization discipline of Chapter 3.

In Practice: a wind-turbine fleet drowning in CSV

An industrial monitoring team streamed vibration and SCADA telemetry from three hundred wind turbines into hourly CSV files, one folder per turbine. Each accelerometer channel ran at 25.6 kHz, so a single ten-minute record was over fifteen million rows, and every row redundantly carried a text timestamp. Storage ballooned past a petabyte and a simple query, "pull the gearbox axial channel for turbine 44 last Tuesday," took twenty minutes because the reader parsed every column of every row. Re-engineering split the two shapes apart: high-rate vibration went into chunked HDF5 with sample_rate_hz, units, and turbine_id as dataset attributes, while the slow SCADA telemetry (power, wind speed, nacelle temperature) went into daily Parquet partitioned by turbine. The same query dropped to under two seconds and on-disk size fell by roughly eight times, purely from matching layout to shape. Not one bit of physics changed; the description of the data did. This is the raw material Chapter 36 later turns into remaining-useful-life models.

Event streams and point clouds: the irregular families

Event streams and point clouds resist the tidy rectangle, and pretending otherwise is where naive pipelines quietly corrupt data. An event stream carries its meaning in the gaps: a burst of CAN messages during hard braking and silence during cruise are both signal. Store events as a row log where the timestamp is a first-class column, never resample them onto a fixed grid at ingestion time (that decision belongs downstream and destroys information if baked in), and keep them monotonically ordered by time. Robotics leans on MCAP and the older rosbag containers precisely because they log heterogeneous, asynchronous messages with per-message timestamps and typed schemas; neuromorphic vision uses AEDAT and EVT to pack millions of \((x, y, p, t)\) tuples per second, the format that Chapter 46 builds event-camera perception on. A columnar log such as Parquet also serves general event data well, since the timestamp and payload columns compress and filter efficiently.

A point cloud is a set, and sets have two properties that break array-shaped storage: variable cardinality (each sweep has a different point count) and permutation invariance (no canonical order). You cannot stack frames into one rectangular tensor without padding, and you must never let a tool assume row order carries meaning. Automotive lidar pipelines commonly store each frame as a packed little-endian binary blob of \(N \times 4\) float32 values \((x, y, z, \text{intensity})\) with \(N\) recorded in a sidecar index; geospatial workflows use LAS and its compressed cousin LAZ; general 3D tooling uses PCD and PLY. Whatever the container, the metadata that must ride along is the sensor pose, the coordinate frame, and the per-point time offset, because a sweep is not instantaneous and the vehicle moved while it scanned. The models that consume these sets, and the reason their architectures must be permutation-invariant, are the subject of Chapter 42.

Self-describing or worthless

Across all four shapes, one property separates a dataset from a rumor: the file must carry enough metadata to reconstruct physical meaning without external knowledge. The minimum viable descriptor is the tuple in the code below, and its absence is the single most common way sensor data arrives broken. A bare float32 array cannot be interpreted without its dtype and endianness (is that little-endian float or big-endian int?), its sample rate and start time (or the time axis is unknowable), its units and scale (millivolts or volts, raw ADC counts or calibrated), its device and session identity (or you cannot later build the device-disjoint splits that Section 5.4 requires), and its timezone or an unambiguous epoch (or midnight is a coin flip). Listing 5.1.1 writes a waveform to Parquet with exactly this descriptor attached as schema metadata, then reads it back and reconstructs the time axis from \(f_s\) and \(t_0\) alone.

import numpy as np, pyarrow as pa, pyarrow.parquet as pq

fs, t0 = 25600.0, 1_726_000_000.0          # Hz, and a UTC epoch second
sig = np.random.randn(256_000).astype(np.float32)   # ~10 s of vibration

# Attach the self-describing descriptor as column-schema metadata.
table = pa.table({"accel_axial": sig})
table = table.replace_schema_metadata({
    b"sample_rate_hz": b"25600.0",
    b"start_epoch_utc": b"1726000000.0",
    b"units": b"m/s^2",
    b"device_id": b"turbine-044-gearbox",
    b"session_id": b"2026-07-14T09:00Z",
})
pq.write_table(table, "vib.parquet", compression="zstd")

# A reader anywhere reconstructs the time axis with no side channel.
back = pq.read_table("vib.parquet")
meta = back.schema.metadata
fs_r = float(meta[b"sample_rate_hz"])
n    = back.num_rows
t    = float(meta[b"start_epoch_utc"]) + np.arange(n) / fs_r   # implicit, exact
Listing 5.1.1. A waveform stored so that any reader can recover its physical meaning. The signal is one contiguous column; the sample rate, epoch, units, and device and session identity travel with it as schema metadata. The time axis is reconstructed from \(f_s\) and \(t_0\), never stored per sample, which keeps the file small and the axis exact.

As Listing 5.1.1 shows, carrying device_id and session_id is not bookkeeping pedantry. Those fields are the hooks that make leakage-safe splitting possible at all; drop them at ingestion and no downstream care can recover a device-disjoint test set, which is why Section 5.3 treats missing provenance as a first-class dataset defect.

The Right Tool

Rolling your own self-describing binary format (a header struct, a magic number, an offset index, per-column compression, endianness handling, and a reader that streams chunks) is a few hundred lines and a rich source of subtle bugs at the block boundaries. A mature columnar library collapses the whole thing to a single call:

import pandas as pd
df.to_parquet("telemetry.parquet", compression="zstd")   # schema, stats,
                                                          # chunking, all handled
cols = pd.read_parquet("telemetry.parquet", columns=["rpm", "oil_temp"])
Listing 5.1.2. The same job as a hand-rolled columnar writer plus reader, in two lines. Parquet handles per-column compression, block statistics for predicate pushdown, and selective column reads.

Listing 5.1.2 replaces roughly 300 lines of format engineering with 2, and hands you column statistics and predicate pushdown for free. What the library will not do is decide which shape your data is or what metadata it deserves; that judgment stays yours.

Exercise

Take a dataset you have on hand (or download one accelerometer HAR recording). Classify each of its streams into one of the four shapes in Table 5.1.1, and for each, write down the storage layout it deserves and the minimum self-describing descriptor it needs. Then find one stream that is currently stored in a mismatched format (a waveform in CSV, or telemetry in row-major JSON) and estimate the size reduction from re-encoding it correctly. Keep the descriptors; you will attach them to windows in the next section.

Self-Check

1. Why is storing a per-sample timestamp column for a uniformly sampled waveform both wasteful and dangerous?

2. Give two concrete reasons columnar storage beats row-major storage for wide, multi-channel telemetry.

3. A point cloud and a telemetry table are both "sensor data." Name the two structural properties of a point cloud that forbid storing it as a fixed-width rectangular table.

What's Next

In Section 5.2, we take these correctly-stored streams and carve them into the windows a model actually trains on: how long a window should be, how much they overlap, and how a label gets attached to a slice of time without smearing across boundaries. The self-describing descriptors you built here are exactly what make that windowing reproducible instead of a pile of magic numbers.