From eight parts to a heatmap: parsing the 10-minute CSI dataset

By Wu Jun · Updated 2026-10-29 · Wi-Fi CSI, dataset, Python, parsing, heatmap · 6 min read

The dataset is not a "download and open" archive: it is eight binary parts that must be verified, reassembled and parsed record by record. This walkthrough turns the 10-minute Zenodo capture into an amplitude matrix and a heatmap, and checks the record count, duration and whole-file SHA-256. All numbers come from the project's real capture [1]; the data and scripts are public [2] [3].

1. Download and dependencies

Download the eight parts, manifest.json and REASSEMBLE.md from https://zenodo.org/records/23434789 into one directory. On the Python side you only need the standard library, NumPy and Matplotlib:

python3 -m venv .venv
source .venv/bin/activate
python3 -m pip install numpy matplotlib

2. Verify before analysing

Do not skip verification. A missing or corrupted part can still produce plausible-looking but wrong numbers:

cat csi-10m.part-*.bin > csi_10m.bin
shasum -a 256 csi_10m.bin
# expected: 527466ceb8b5f835e55ff348f02808736b947db39e7baac1f34e375de146a440

You can also compare each part against the sha256 entries in manifest.json.

3. Read the records: outer container and inner header

The file is a record stream. Each record has two layers:

This dataset uses the v1 24-byte header. Keep chain_info as well, because bit 15 marks the end of a measurement:

import struct
from collections import Counter

import numpy as np

REC = struct.Struct("<dH")             # outer: file timestamp + payload length
HDR = struct.Struct("<I6sbBBBBBHHHH")  # inner 24-byte header

def parse(path):
    records, measurements, pending = [], [], []
    with open(path, "rb") as f:
        while True:
            raw = f.read(REC.size)
            if not raw:
                break
            if len(raw) != REC.size:
                raise EOFError("truncated outer record")

            file_ts, length = REC.unpack(raw)
            payload = f.read(length)
            if len(payload) != length:
                raise EOFError(f"truncated payload: want {length}, got {len(payload)}")
            if length < HDR.size:
                raise ValueError("payload smaller than CSI header")

            ts, ta, rssi, snr, bw, ch, mode, _pad, n, tx, rx, chain = (
                HDR.unpack_from(payload)
            )
            iq = np.frombuffer(
                payload, dtype="<i2", count=n * 2, offset=HDR.size
            )
            item = {
                "file_ts": file_ts, "ts": ts, "ta": ta, "rssi": rssi,
                "snr": snr, "n": n, "tx": tx, "rx": rx,
                "chain": chain, "iq": iq,
            }
            records.append(item)
            pending.append(item)

            if chain & 0x8000:
                measurements.append(pending)
                pending = []

    if pending:
        print("warning: incomplete tail records:", len(pending))
    return records, measurements

records, measurements = parse("csi_10m.bin")
print("records:", len(records))
print("complete measurements:", len(measurements))
print("chains per group:", Counter(map(len, measurements)))
print("first header:", {k: v for k, v in records[0].items() if k != "iq"})

Expected:

records: 57293
complete measurements: 7609
chains per group: Counter({8: 6714, 4: 895})
warning: incomplete tail records: 1

ts is in microseconds; the main data has 256 subcarriers, with four 64-subcarrier records. Do not assume that every measurement has all eight chains. This capture contains both 8-chain and 4-chain groups, plus one unterminated record at the end.

4. Select one valid chain per measurement

Chains from the same measurement share almost the same timestamp. The 57,293 records must not be treated as 57,293 independent samples. The code below prefers tx0/rx0, then falls back to the strongest valid chain:

def amplitude(iq):
    return np.hypot(iq[0::2].astype(float), iq[1::2].astype(float))

times, rows = [], []
for group in measurements:
    valid = [item for item in group if item["n"] == 256 and item["iq"].any()]
    if not valid:
        continue

    chosen = next(
        (item for item in valid if (item["tx"], item["rx"]) == (0, 0)),
        None,
    )
    if chosen is None:
        chosen = max(valid, key=lambda item: amplitude(item["iq"]).mean())

    times.append(chosen["file_ts"])
    rows.append(amplitude(chosen["iq"]))

t = np.asarray(times, dtype=float)
t -= t[0]
H = np.vstack(rows)
print("usable measurements:", H.shape)
print("measurement rate:", len(t) / (t[-1] - t[0]), "Hz")

This simple filter produces roughly a 7,608 × 256 matrix over 599.92 seconds, or about 12.68 independent measurements/s. The full analysis script also removes 25 groups with excessive zero values, leaving 7,583 measurements. Keeping those details is more faithful than claiming that every group always contains eight chains.

Do not turn that average back into a uniform clock without inspection. The capture-quality audit reports a median interval of 41 ms, a 95th percentile of 322 ms and 57 gaps above one second. Mask long gaps or analyse contiguous windows before estimating a low-frequency spectrum.

5. Build the amplitude heatmap

Stack the selected 256 subcarriers for each measurement over time:

import matplotlib.pyplot as plt

plt.figure(figsize=(12, 5))
plt.imshow(
    H.T, aspect="auto", origin="lower", cmap="viridis",
    extent=[t[0], t[-1], 0, H.shape[1]],
)
plt.xlabel("time (s)")
plt.ylabel("subcarrier")
plt.title("GL-MT6000 CSI amplitude (one valid chain per measurement)")
plt.colorbar(label="amplitude")
plt.tight_layout()
plt.savefig("csi_heatmap.png", dpi=150)

The result should look like the heatmap on the dataset page. If it is pure noise or a constant, check part order, the 24-byte header offset and I/Q endianness before adjusting colour limits.

6. Compute a one-second rolling motion metric

The raw measurement gaps are not perfectly uniform. Interpolate onto a 20 Hz grid, then average the frame-to-frame amplitude change over one second. Interpolation regularises time; it does not create new information:

FS = 20.0
uniform_t = np.arange(0.0, t[-1], 1.0 / FS)
uniform_H = np.column_stack([
    np.interp(uniform_t, t, H[:, i]) for i in range(H.shape[1])
])

delta = np.abs(np.diff(uniform_H, axis=0)).mean(axis=1)
window = int(FS)
motion = np.convolve(delta, np.ones(window) / window, mode="valid")
motion_t = uniform_t[1 + window // 2: 1 + window // 2 + len(motion)]

plt.figure(figsize=(12, 3))
plt.plot(motion_t, motion)
plt.xlabel("time (s)")
plt.ylabel("1-second mean |Δamplitude|")
plt.tight_layout()
plt.savefig("csi_motion.png", dpi=150)

Walking produces clear peaks; a quiet room stays near the baseline. This is only a cheap "activity" feature. It is not fall detection, and respiration or heart rate should not be estimated from this amplitude-difference metric alone.

7. Common mistakes

Symptom Cause Fix
Record count is not 57,293 missing part or wrong merge order sort by filename, merge, recompute SHA-256
Time axis is off by 1000× treating ts as milliseconds ts is microseconds; convert before plotting
256 subcarriers look like garbage wrong offset or endianness use <i2 little endian and start at byte 24
Group count ≈ record count measurements are not closed on chain_info bit 15 close a group when bit 15 is set
Group count is not 7,162 dividing all 57,293 chain records by eight the real complete-group count is 7,609; allow 4-chain groups and a tail fragment
Heatmap is all black/white integer type or missing scaling cast to float, set colour limits from percentiles

8. Next steps

9. Reproduction acceptance checks

Before building an algorithm on top of the parser, require the following:

If those checks disagree, fix parsing and grouping before applying a deep-learning model or changing plot colours.

References and data sources

Every external claim carries a source and an evidence grade (grading scheme). Grade E marks first-party, reproducible measurements from this project.

  1. Grade E · first-party measurement This project's 10-minute raw capture (57,293 records / 600.0 s, SHA-256 00373f83…95e2)
    Router CSI Lab · 2026
    okwujun.pw/en/datasets/ · verified: Reassembled chunks verified byte-identical (2026-10-11) · The local raw master has SHA-256 00373f83…95e2; the public de-identified derivative has SHA-256 527466ce…a440. They must not be confused.
  2. Grade E · first-party measurement Router CSI Lab — GL-MT6000 (MT7986) Wi-Fi CSI dataset, 10 s sample + 10 min capture
    Router CSI Lab · 2026
    doi:10.5281/zenodo.23434789 · verified: CC BY 4.0; SHA-256 published; Zenodo DOI 10.5281/zenodo.23434789 · The dataset has a Zenodo DOI (10.5281/zenodo.23434789) and can be cited directly.
  3. Grade E · first-party measurement Router CSI Toolkit — capture/analysis/watchdog scripts (MIT, public repository)
    Router CSI Lab · 2026
    github.com/jjjwu/router-csi-toolkit · verified: GitHub API verified the public repository, capture-quality preflight, successful CI and the v0.1.3 release on 2026-11-02; the archive SHA-256 is 59f144c0…adea7 · The public repository includes a dependency-free capture preflight; the browser checker uses the same container definition and evidence boundaries.