damsma.dev / notes / sleep study live

Reading my sleep study before the hospital did

· 7 min read

At the start of October I did a home sleep study, to find out whether I have sleep apnea. You pick up a recorder at the hospital in the afternoon, where a nurse shows you how to attach all the sensors, wire yourself up at bedtime and bring it back the next morning. Two belts around chest and belly, a nasal cannula, a clip on your finger, three ECG stickers, and the recorder itself, a SOMNOscreen plus, that logs all of it.

Then you wait for the results, which the nurse said would take up to six weeks.

The recorder had to be back before half past eight. So when I woke up, the first thing I did was pull the CompactFlash card out of it, copy it to my laptop, put the card back and bring the whole thing to the hospital.

Two files and no software

The card held two files: DATEN1.DTX of about 45 MB, and konfig.dt2 of exactly 2 KB.

The software that reads them is DOMINO, SOMNOmedics' analysis package for sleep clinics. It needs an activation key from the manufacturer, and as far as I could find there's no free viewer a patient can use. There's no public description of the format either.

The config file was surprisingly readable. Next to my name, birth date, height and weight it holds the hospital's order number and my BSN, the Dutch citizen service number. It also keeps a log of the recorder's own life: in September it went out for about five other recordings, nearly always programmed in the morning and read out the next morning around eight. Only timestamps, nothing about the other patients.

It also has a table with every channel's name and how many samples it takes per quarter second. That table was the key to figuring out the rest.

Taking the format apart

DATEN1.DTX is a short preamble followed by a 154-byte block for every 0.25 s of recording. The first 15 bytes of each block are a header you can read as is: body position, battery voltage in millivolts, pulse and SpO2.

The last 18 bytes are six groups of three: a 16-bit value and a byte that never goes above 88. That ceiling is a fingerprint. IMA ADPCM, a 4-bit audio codec from 1992, has a step table with exactly 89 entries, and its whole state is a 16-bit predictor plus a step index from 0 to 88. So the block ends with the codec state of six channels, and the 121 bytes in between are 242 four-bit codes. The recorder stores breathing, airflow and ECG as if they were sound.

The config table makes the arithmetic work. Three channels at 256 Hz, pleth (the pulse wave from the finger clip) at 128 Hz and three at 32 Hz is 31 samples per 1/32 second, so 248 per block. A block has room for 242 codes, one of which is always zero. 248 minus 241 is 7, and there are 7 waveform channels: the last sample of each one isn't stored as a code, because the stored state already is that sample.

What the arithmetic doesn't tell you is which of the 31 slots belongs to which channel. That's where the stored state pays off. Decode a block with a candidate layout, and exactly one more code has to take each channel to the state the recorder saved. If none of the 16 possible codes does, the layout is wrong.

def reachable(state, stored):
    return any(step(state, code) == stored for code in range(16))

A wrong layout fails on nearly every block. The right one passes on 99.999% of the 296,000 blocks in my recording, and the few that fail are at the very end, after I'd unplugged the sensors. The file grades every guess, four times for every second of the night. The full layout is in FORMAT.md.

Wrong twice

Decoding correctly is not the same as reading correctly, and the first look at the signals got two things wrong.

The ECG channel showed no heartbeat at all, just tones at 37.5, 75 and 106 Hz. The obvious conclusion was that nothing was connected to it. Except there had been three stickers on my chest all night. Looking again, the heartbeat was there, about a hundred times weaker than interference the recorder makes itself. That interference is locked to the sample clock: it repeats exactly every 512 samples, plus a short spike every 6 seconds. Subtract a running median of the pattern and a clean ECG comes out, upside down because of where the stickers were. The heart rate from it matches the pulse from the finger clip to within about a beat per minute.

The snore sensor seemed to pick up mostly heartbeats. That turned out to be the fragment I happened to look at, a quiet stretch. The rest of the night had long blocks of snoring, most of it right in the inhale, and the nasal cannula picks up the same vibration.

Both times the data was fine and the conclusion came from too small a sample of a twenty-hour recording.

My own estimate

A home study has no EEG, so nobody can see actual sleep stages, not the clinic either. What you can do is the same thing the scoring software does: look for stretches where airflow drops by at least 30% for ten seconds or more and oxygen dips by 3% after it, and check whether the belts show I was still trying to breathe. Split those by body position and you have most of the picture.

By that afternoon I had my estimate: mild obstructive sleep apnea, most of it while lying on my back. I wrote that down before the hospital's report existed.

My Apple Watch had been on all night too, with its own idea of when I slept and when I was in REM. At first I wanted to use it to decide which moments counted as awake. I didn't, because I trust the sensors on my body more than Apple's black-box algorithm. It did make a useful check afterwards. Sleep onset from the recording came out at 00:03, close to when I remember falling asleep, and the watch's total sleep time was within three minutes of mine. The quiet, irregular stretches without snoring lined up with what the watch called REM in 16 out of 21 cases.

The report

When the hospital's report came in, it said the same: mild obstructive sleep apnea, mostly when on my back. Recalculated over the exact window the clinic analysed, my count of breathing events and theirs were one apart. Theirs came from the manufacturer's own software in a sleep clinic. Mine came from my self-made decoder, and that felt pretty cool, to be honest.

The report also checked the decoder in ways I couldn't. Start and end time match to the second, and body position matches minute by minute, including left and right, which I'd only had from a SOMNOmedics knowledge base article until then.

One line in it stands out. The ECG section lists hundreds of arrhythmias per hour, with almost a third of the signal rejected as artefact. The written conclusion doesn't mention a rhythm problem, and with the recorder's interference removed the rhythm is regular, with no pauses and no real extra beats. I'm fairly sure the clinic's software was counting the 37.5 Hz tone.

Making it a tool

Someone else will end up with a DATEN1.DTX and no way to open it, so the decoder is now somnoscreen-dtx on GitHub. It converts a recording to EDF, the format EDFbrowser, MNE and most other sleep tools read, and somnoscreen-dtx info prints the self-check per channel, so a file that decodes wrongly says so.

A few choices came straight from this story. The EDF is anonymous unless you ask for the patient details, because konfig.dt2 holds more than you'd expect. The repository contains no recording at all: the tests build synthetic ones with an encoder that mirrors the recorder. And it only knows the one montage I had, a 12-channel home polygraphy. A different konfig.dt2 is refused rather than decoded with the wrong layout.

A full polysomnography with EEG very likely uses the same blocks with a different slot layout, and the self-check would show right away whether a candidate is right. If you have one, open a Recording not supported issue. Never attach the recording itself.

somnoscreen-dtx is an independent project, not affiliated with SOMNOmedics, and not a medical device. It reads files; it doesn't score or interpret them. My own analysis was a check next to the clinic's report, not a replacement for it.