Asking the fleet what it is doing…
monad-knowledge Wi-Fi sensing lab · FIIT STU

The capture format

A binary format you can read.

A capture is a binary file, and a binary file is readable exactly when you can put a finger on a byte and be told what it is. Below is one real capture from this project's archive, decoded in front of you by the same reader the specification documents. Pick a field on the left, or click any byte, and the two panes point at each other.

In plain words A capture is a binary file, and a binary file is readable exactly when you can put a finger on a byte and be told what it is. This is that finger: a real file from the archive on the left, its bytes in the middle, and the meaning of what you point at on the right. Point your own reader at the same file and compare.

This is the workbench without its interactive layer. Every byte, table, figure and note the app carries is below, in file order — what is missing is the ability to select a field and see its bytes light up.

The file head

000000 43 53 49 51 01 00 01 00 11 0a 00 00 7b 22 65 6e CSIQ........{"en
000010 64 65 64 5f 61 74 22 3a 22 32 30 32 36 2d 30 38 ded_at":"2026-08
000020 2d 33 30 54 31 37 3a 34 35 3a 30 38 5a 22 2c 22 -30T17:45:08Z","
000030 65 6e 76 69 72 6f 6e 6d 65 6e 74 22 3a 7b 22 62 environment":{"b
Twelve bytes of header, then the session block. A reader that does not recognise the magic must stop here rather than guess.

The first record

000a1d a1 2e 03 00 00 01 04 00 00 00 40 21 98 07 02 04 ..........@!....
000a2d 00 00 00 f2 0a 87 26 03 08 00 00 00 fc a5 7f d0 ......&.........
000a3d 24 a6 d0 18 04 04 00 00 00 04 c9 00 02 05 03 00 $...............
000a4d 00 00 01 04 01 11 02 00 00 00 01 03 14 10 01 00 ................
000a5d 00 a0 01 00 00 04 00 00 00 40 21 98 07 00 00 00 .........@!.....
000a6d 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
000a7d 00 00 00 00 00 00 00 00 00 03 00 00 01 00 00 02 ................
000a8d 01 02 00 02 03 34 00 00 00 00 00 00 00 36 00 00 .....4.......6..
000a9d 00 3c 00 00 00 00 00 5e 00 53 00 00 00 bd 00 00 .<.....^.S......
000aad 08 00 00 00 00 00 00 00 00 f2 0a 87 26 04 c9 00 ............&...
One tag byte, a four-byte length, then a chain of type-length-value fields. A reader skips what it does not know, which is why adding a type code is not a version bump.

The capture

A binary format you can read

CSIQ is what this project's nine radios write to disk. Its design goal is that a capture should be interpretable by somebody holding only the file and the specification. This page is that claim, tested: a real capture out of the archive, decoded in front of you.

Every number here came out of the file beside it. Nothing is written into the copy. The page decodes the committed capture with the same reader the specification documents, so a figure cannot drift from the bytes you can download.

This capture is the archive's session monad02_explore-vht-rx_20260830-174208 — monad02, explore-vht-rx, 5 GHz channel 44 at 80MHz, written by csid 0.2.0. It was selected from 36 candidates in its cell, by useful yield.

The conformance kit

Every sample here is downloadable as a valid, self-contained capture, with the decode this project's reader produces for its first records as JSON. Point your own reader at the file and diff against the answer key. Where the two disagree, the specification wins and both readers are suspect — that is the document's own rule, and it is why there is a published fixture at all.

Capture Records The file The expected decode
2.4 GHz · HT20 · csid 0.2.0 2,000 band-2.4__condition-clean__width-HT20.csiq expected decode
5 GHz · HT20 · csid 0.2.0 2,000 band-5__condition-clean__width-HT20.csiq expected decode
5 GHz · HT20 · quarantined · csid 0.2.0 2,000 band-5__condition-quarantined__width-HT20.csiq expected decode
5 GHz · HT40+ · csid 0.2.0 2,000 band-5__condition-clean__width-HT40+.csiq expected decode
5 GHz · 80MHz · csid 0.2.0 2,000 band-5__condition-clean__width-80MHz.csiq expected decode
5 GHz · HT20 · csid 0.2.0 2,000 envelope-csiq.csiq expected decode
5 GHz · HT20 · csid 0.2.0 2,000 envelope-csiq.zst.csiq expected decode
5 GHz · HT20 · csid 0.2.0 2,000 writer-modern.csiq expected decode
5 GHz · HT20 · csid 0.1.0 2,000 writer-legacy.csiq expected decode
6 GHz · HT20 · csid 0.2.0 2,000 band-6.csiq expected decode

Defects the format admits

  • SEQ is a driver record counter. Not the 802.11 sequence number its name suggests. Corrected 2026-08-24; the name is kept because changing it would break every existing reader.
  • Two reference readers rendered an absent width differently. One as an unknown code, one as its own default. Two sentinels for one fact, found only when both were decoded side by side. Neither is right: absence should be reported as absence.
  • The header version cannot say which fields a file carries. Adding a type code is not a version bump, so two captures both stamping version 1 can differ by five codes. The capability probe is the answer instead.

What the catalogue does not hold

The catalogue is declared by axis rather than by session, so a cell the archive does not populate is reported rather than quietly dropped. 7 declared cells are empty:

  • band=2.4,condition=quarantined,width=HT20
  • band=2.4,condition=clean,width=HT40+
  • band=2.4,condition=quarantined,width=HT40+
  • band=2.4,condition=clean,width=80MHz
  • band=2.4,condition=quarantined,width=80MHz
  • band=5,condition=quarantined,width=HT40+
  • band=5,condition=quarantined,width=80MHz

The 2.4 GHz wide-channel cells are empty because this deployment runs 2.4 GHz on channel 11 at HT20 only. That is a fact about the fleet.

Offset 0, 12 bytes.

Twelve bytes, then everything else

Four bytes of magic, a version, a flags word, and the length of the block that follows. A reader that does not recognise the magic must stop here rather than guess: everything after these twelve bytes is only meaningful if they said what they were supposed to say.

The header version cannot say which fields a file carries. Adding a type code is not a version bump, so two captures both stamping version 1 can differ by five codes. The capability probe answers that instead.

Field Offset Bytes Value
magic 000000 4 B CSIQ
version 000004 2 B 1
flags 000006 2 B 0x0001
session_len 000008 4 B 2577

Session block

Offset 12, 2,577 bytes.

Provenance travels with the data

One JSON object at the head of every capture, carrying how the measurement was taken: the radio's channel and width, the build of the daemon that wrote it, the host it ran on, and the filter that decided which frames were kept.

The block is opaque by design. A writer adds a group without a format version bump, so a reader that dropped what it did not recognise would silently lose the newest thing anybody recorded. Every group this capture carries is listed, known or not.

Field Offset Bytes Value
ended_at 00000d 33 B
environment 00002f 8 fields
experiment 0001ed 29 B
filter 00020b 6 fields
radio 000293 12 fields
run_id 000399 40 B
run_id_generated 0003c2 24 B
schema 0003db 25 B
session_id 0003f5 53 B
started_at 00042b 35 B
status 00044f 19 B
summary 000463 8 fields
tag 000721 75 B
timesync 00076d 6 fields
_redaction 000825 3 fields
environment 8
build{"built_at": "2026-08-24T19:32:09Z", "csiq_format_version": 1, "profile": "release", "revision": "", "revision_source": "none", "rustc": "rustc 1.97.1 (8bab26f4f 2026-07-14)"}
cpu_governorperformance
csid_version0.2.0
driver_module/lib/modules/6.8.0-1060-raspi/updates/dkms/iwlwifi.ko.zst
firmware78.3bfdc55f.0 ty-a0-gf-a0-78.uc
hostnamemonad02
kernel6.8.0-1060-raspi
regdomaincountry SK: DFS-ETSI
filter 6
countnull
fingerprintno-filter
frame_typesnull
rate_n_flags_masknull
rate_n_flags_valnull
timeout_usnull
radio 12
achieved_center_freq_mhz5210
achieved_control_freq_mhz5220
achieved_width_mhz80
band5
center_freq_mhz5210
channel44
control_freq_mhz5220
interfacewlp1s0
interval_us0
mac_filter(empty list)
monitorwlp1s0mon0
width80MHz
summary 8
capture_bytes38121644
empty_records0
live_dropped0
mean_rate_hz283.3139315939831
node_state{'at_s': 0, 'load_m': 520, 'spool_free_bytes': 39117365248, 'temp_mc': 57850, 'throttle_flags': 0}, {'at_s': 60, 'load_m': 190, 'spool_free_bytes': 39104655360, 'temp_mc': 56750, 'throttle_flags': 0}, {'at_s': 120, 'load_m': 370, 'spool_free_bytes': 39092068352, 'temp_mc': 55650, 'throttle_flags': 0}
records51013
timesync{"distinct_transmitters": 0, "frames_seen": 52311, "ftm_paired": 0, "malformed_log_lines": 0, "mean_rate_hz": 0.0, "own_transmissions": 0, "protected_frames": 23566, "rows": 0, "rows_app": 0, "rows_csid": 0, "rx_stamp_source": "none", "status": "failed", "unrecognised_frames": 9556}
tone_counts52, 114
timesync 6
artefacttime_transfer.parquet
durable_logtime_transfer.jsonl
ftm_tolerance_us2000
one_way_floor_us5000
parquet_schematime-transfer/1
requiredFalse
_redaction 3
schemerfc7042-documentation-range
fieldsSRC_MAC (0x09), the src_mac inside VENDOR_HDR (0x14), at Appendix A offset 68, every MAC-shaped string anywhere in the session block
noteSource addresses were relabelled into 00:00:5E:00:53:xx, a range reserved for documentation. Distinct transmitters remain distinct. Hostnames are unchanged: the site publishes node names already, and removing them would strip real provenance from a file that exists to demonstrate provenance.

Record 0

Offset 2,589, 819 bytes.

A self-describing record

A tag byte, a four-byte length, then a chain of type-length-value fields. Each field costs five bytes of header, and that is the price of the property below it: a reader skips any type code it does not know, so a writer can add one without breaking every existing reader. Adding a type code is therefore not a version bump.

A field this file does not carry is a fact about the writer, not a gap in the measurement. It changes which questions can be asked of the capture at all, so each absence below is printed with what it costs.

Field Offset Bytes Value
record tag 0xA1 000a1d 1 B 0xA1
record length 000a1e 4 B 814
FTM 000a22 5+4 B
US 000a2b 5+4 B
UNIX_TS_NS 000a34 5+8 B
RNF 000a41 5+4 B
PHY 000a4a 5+3 B
BW_ANTSEL 000a52 5+2 B
VENDOR_HDR 000a59 5+272 B
SEQ 000b6e 5+1 B
NRX 000b74 5+1 B
NTX 000b7a 5+1 B
NTONE 000b80 5+2 B
SRC_MAC 000b87 5+6 B
CHANNEL 000b92 5+4 B
WIDTH 000b9b 5+2 B
RSSI 000ba2 5+4 B
CSI_MATRIX 000bab 5+416 B

What this writer does and does not record

Presence was established by probing 2,000 records. Absence is only ever evidence over the records looked at.

Field In this file What it gives What its absence costs
MONO_US (0x12) recorded Separates received frames from the node's own transmissions.
BW_ANTSEL (0x11) recorded States the bandwidth directly instead of leaving it to be decoded from RNF.
VENDOR_HDR (0x14) recorded Keeps the 272-byte driver header verbatim, so a later reader can recover a field nobody has named yet.
NODE_* (0x40–0x43) not recorded Core temperature, throttling, spool headroom and load, per record. The conditions the measurement was taken under are not in the file.
NODE_NIC_TEMP_C (0x44) not recorded The radio's own temperature — a different sensor in a different unit from the core. Thermal phase drift cannot be attributed to the card.
RSSI (0x0C) recorded Per-chain received power, and the −127 stale-block sentinel.
PHY (0x05) recorded Modulation, MCS and spatial streams.
SRC_MAC (0x09) recorded Which transmitter each record came from.

FTM

Why one timestamp is not enough

A record carries up to five timestamps, and no one of them answers every question. Each series is drawn as seconds since its own first reading, because they have different epochs and one absolute axis would say nothing.

The FTM counter is 32 bits at 320 MHz, so it wraps. Differencing the stored form across a wrap gives a large negative interval that looks like a clock stepping backwards.

FTM, as stored

A 32-bit 320 MHz counter. It wraps every 13.42 s — 0 times in these records.

FTM, unwrapped

The same counter through Capture.clocked(). Monotonic, and the only form worth differencing.

US — the writer's own clock

Stamped by csid when it took the record, not by the radio when it heard the frame.

UNIX_TS_NS — wall clock

Steppable. These nodes carry no real-time clock, so it moves when chrony corrects it.

MONO_US — CLOCK_MONOTONIC on the receive path

Present on every received frame and absent on the node's own transmissions.

The FTM counter is 32 bits at 320 MHz, so it wraps every 13.42 s — 0 times in the 400 records above.

BW_ANTSEL

242 tones is HE20 or VHT80

A record says how many tones it carries. That number alone does not name the numerology: 242 tones is HE20 at 78.125 kHz spacing, or VHT80 at 312.5 kHz. Deriving the grid needs the bandwidth too.

The bandwidth is the field the reader rule exists for — prefer the explicit code, fall back to decoding the rate-and-flags word, and report genuine absence as absence rather than as 20 MHz.

Tones Bandwidth Spacing Numerology
52 20 MHz 312.5 kHz Legacy OFDM / HT20
56 20 MHz 312.5 kHz HT20
114 40 MHz 312.5 kHz HT40
242 20 MHz 78.125 kHz HE20
242 80 MHz 312.5 kHz VHT80
484 40 MHz 78.125 kHz HE40
996 80 MHz 78.125 kHz HE80

What the catalogue's records carry

Capture Declared width Width in the record Tones Bandwidth Spacing
2.4 GHz · HT20 · csid 0.2.0 HT20 HT20 52 20 MHz 312.5 kHz
5 GHz · HT20 · csid 0.2.0 HT20 HT20 52 20 MHz 312.5 kHz
5 GHz · HT20 · quarantined · csid 0.2.0 HT20 HT20 52 20 MHz 312.5 kHz
5 GHz · HT40+ · csid 0.2.0 HT40+ HT40+ 52 40 MHz 312.5 kHz
5 GHz · 80MHz · csid 0.2.0 80MHz 80MHz 52 40 MHz 312.5 kHz
5 GHz · HT20 · csid 0.2.0 HT20 HT20 52 20 MHz 312.5 kHz
5 GHz · HT20 · csid 0.2.0 HT20 HT20 52 20 MHz 312.5 kHz
5 GHz · HT20 · csid 0.2.0 HT20 HT20 52 20 MHz 312.5 kHz
5 GHz · HT20 · csid 0.1.0 HT20 HT20 52 20 MHz 312.5 kHz
6 GHz · HT20 · csid 0.2.0 HT20 HT20 52 20 MHz 312.5 kHz

SEQ

A name the format is stuck with

SEQ is a driver record counter, not the 802.11 sequence number its name suggests. Corrected 2026-08-24. The name is kept because changing it would break every existing reader.

SRC_MAC

The one thing that is not the archive's

Record framing and lengths are bit-identical to the original capture. The only bytes that changed are the six-byte MAC addresses, relabelled into the RFC 7042 documentation range — here, in the session block, and inside the 272-byte driver header that keeps its own copy of the source address.

Hostnames are not redacted: this site publishes node names already, and stripping them would remove the provenance these files exist to demonstrate.

RSSI

A sentinel is not a weak measurement

Received power is stored per chain in dBm. One value is not a measurement: −127 marks a stale block — the driver handed back the previous record's bytes unchanged.

Averaged in, it becomes the weakest signal the fleet ever recorded, tens of decibels below anything real, and it drags every mean it touches. It is counted separately everywhere it appears on this page.

-96 dBm -33 dBm

40,000 chain readings across the whole catalogue. The sentinel is not a bar in this chart. It is counted separately — 0 of them — and sits 31 dB below the weakest real reading.
Capture Chain readings Measured range Sentinels
2.4 GHz · HT20 · csid 0.2.0 4,000 -70 to -57 dBm 0
5 GHz · HT20 · csid 0.2.0 4,000 -96 to -58 dBm 0
5 GHz · HT20 · quarantined · csid 0.2.0 4,000 -82 to -65 dBm 0
5 GHz · HT40+ · csid 0.2.0 4,000 -91 to -54 dBm 0
5 GHz · 80MHz · csid 0.2.0 4,000 -90 to -54 dBm 0
5 GHz · HT20 · csid 0.2.0 4,000 -80 to -58 dBm 0
5 GHz · HT20 · csid 0.2.0 4,000 -82 to -33 dBm 0
5 GHz · HT20 · csid 0.2.0 4,000 -85 to -57 dBm 0
5 GHz · HT20 · csid 0.1.0 4,000 -96 to -58 dBm 0
6 GHz · HT20 · csid 0.2.0 4,000 -95 to -38 dBm 0

CSI_MATRIX

The CSI matrix, and the two ways to read it wrong

Complex coefficients across the tone grid, one block per antenna chain — the shape the channel imposed on a frame as it crossed the room.

Amplitude here is AGC-normalised, so it is channel shape only. The correlation between the squared amplitude and received power is about 0.01 on this hardware. The absolute scale is in the RSSI field, not in this one.

Two properties of the stored matrix are easy to get wrong, and getting either wrong is silent: the result looks healthy and is not. Both are visible in the impulse response — the same data seen in delay rather than in frequency — and in nothing else.

  1. Coefficients are imaginary first, then real. Reading them the other way round yields i·conj(H). Amplitude is untouched, so every amplitude plot stays byte-identical, and every phase is mirrored.
  2. The payload is chain-major. Chains are contiguous blocks of tones, not interleaved per tone. Read the other way, the impulse response smears instead of concentrating.
Amplitude per tone, 2 chains, 52 tones across 2×1 chains. The vertical scale carries no absolute level. The absolute scale is in the RSSI field, which this record gives as -54 and -60 dBm.

Chain-major, imaginary first

Energy concentrated at early delays. This is a channel. Early-delay energy over late: 12.42. Energy in the four strongest taps: 0.848.

Real first

Amplitude is byte-identical to the correct reading. The response is anti-causal. Early-delay energy over late: 0.08. Energy in the four strongest taps: 0.848.

Read as tone-interleaved

The response smears across the delay axis instead of concentrating. Early-delay energy over late: 18.35. Energy in the four strongest taps: 0.827.

The same measurement, across the catalogue

Capture Profile Records Imaginary first Real first Verdict
2.4 GHz · HT20 · csid 0.2.0 rate-ladder-sweep 400 14.74 0.069 as specified
5 GHz · HT20 · csid 0.2.0 exp-chan-36 400 26.59 0.058 as specified
5 GHz · HT20 · quarantined · csid 0.2.0 walk-abba-48-night 400 0.14 7.238 inverted
5 GHz · HT40+ · csid 0.2.0 explore-vht-rx-40 400 16.30 0.178 as specified
5 GHz · 80MHz · csid 0.2.0 explore-vht-rx 400 16.38 0.117 as specified
5 GHz · HT20 · csid 0.2.0 walk-raster-48 400 0.18 5.949 inverted
5 GHz · HT20 · csid 0.2.0 exp-chan-36 400 15.89 0.089 as specified
5 GHz · HT20 · csid 0.2.0 exp-chan-36 400 9.82 0.107 as specified
5 GHz · HT20 · csid 0.1.0 exp-chan-36 400 26.82 0.052 as specified
6 GHz · HT20 · csid 0.2.0 chan-6ghz-psc5 400 0.19 6.678 inverted

Record 1

Offset 3,408, 1,328 bytes.

Field Offset Bytes Value
record tag 0xA1 000d50 1 B 0xA1
record length 000d51 4 B 1323
FTM 000d55 5+4 B
US 000d5e 5+4 B
UNIX_TS_NS 000d67 5+8 B
RNF 000d74 5+4 B
PHY 000d7d 5+3 B
BW_ANTSEL 000d85 5+2 B
MONO_US 000d8c 5+8 B
VENDOR_HDR 000d99 5+272 B
SEQ 000eae 5+1 B
NRX 000eb4 5+1 B
NTX 000eba 5+1 B
NTONE 000ec0 5+2 B
SRC_MAC 000ec7 5+6 B
CHANNEL 000ed2 5+4 B
WIDTH 000edb 5+2 B
RSSI 000ee2 5+4 B
CSI_MATRIX 000eeb 5+912 B

Record 2

Offset 4,736, 819 bytes.

Field Offset Bytes Value
record tag 0xA1 001280 1 B 0xA1
record length 001281 4 B 814
FTM 001285 5+4 B
US 00128e 5+4 B
UNIX_TS_NS 001297 5+8 B
RNF 0012a4 5+4 B
PHY 0012ad 5+3 B
BW_ANTSEL 0012b5 5+2 B
VENDOR_HDR 0012bc 5+272 B
SEQ 0013d1 5+1 B
NRX 0013d7 5+1 B
NTX 0013dd 5+1 B
NTONE 0013e3 5+2 B
SRC_MAC 0013ea 5+6 B
CHANNEL 0013f5 5+4 B
WIDTH 0013fe 5+2 B
RSSI 001405 5+4 B
CSI_MATRIX 00140e 5+416 B

What changed in these files, and what did not

Every sample is a byte-preserving slice of a real capture from this project's archive. Record framing and lengths are bit-identical to the original. The only bytes that changed are the six-byte MAC addresses, relabelled into the RFC 7042 documentation range — in the SRC_MAC field, in the session block, and inside the 272-byte driver header that keeps its own copy of the source address. Hostnames are not redacted: this site publishes node names already, and stripping them would remove the provenance these files exist to demonstrate.

Compare two captures side by side · This capture (.csiq) · Its expected decode · The reader and the specification ↗