Watching a Dog Sleep With an Accelerometer

Watching a Dog Sleep With an Accelerometer

A dog cannot tell you it slept badly. It just does, and then it is cranky, or slow on the morning walk, or off its food, and you are left guessing why. Sleep quality turns out to be one of the earlier signals that something is wrong with a dog: pain, anxiety, and age-related decline all show up in how an animal rests before they show up anywhere a vet can point to. Our collar already streams IMU data to the hub for step counting, so the obvious question was whether the same data could tell us when the dog is asleep.

The honest answer is that an accelerometer cannot see sleep. It can see stillness. But there is a well-worn trick from human sleep research called actigraphy: strap an accelerometer to a wrist, bucket the signal into fixed epochs, score each epoch as rest or activity from a movement feature, and infer sleep from sustained runs of rest. Wrist actigraphs have been used in sleep clinics since the 1980s, and the method holds up surprisingly well considering how little it asks of the sensor. We ported the idea to a dog's neck. This post covers the rule-based version we now have running live on the hub, what one of its events looks like, and what a real nap looks like through its eyes. Evaluation against ground truth comes later, and we will be upfront about that gap.

Jerk, Not Acceleration

The first decision is what "movement" means numerically. Raw acceleration magnitude is a poor choice on a collar because gravity is always in the frame and the orientation of the collar drifts as the dog shifts position. A dog that rolls from its side to its back produces a large, slow change in the measured vector while being, for our purposes, asleep the whole time.

So we differentiate. Jerk, the time derivative of acceleration, throws away the static gravity component and keeps only how fast the acceleration is changing. A sleeping dog produces almost none of it. We bucket the incoming samples into fixed 60-second epochs and compute a small set of features per epoch:

amag = np.sqrt(ax**2 + ay**2 + az**2)
j3d  = np.sqrt(np.gradient(ax, ts)**2 +
               np.gradient(ay, ts)**2 +
               np.gradient(az, ts)**2)

feats = {
    "jerk_mean": j3d.mean(),          # the classifier input
    "jerk_max":  j3d.max(),
    "still_pct": (amag < 1.10).mean() * 100,
    "gyro_mean": gyro_magnitude.mean(),
}

The rule itself is one line: an epoch with mean 3D jerk below 0.20 g/s is rest, otherwise it is active. An epoch with fewer than 100 samples is no_data rather than a guess, because a classifier that silently fills coverage gaps with a state is a classifier you can never debug. The threshold came from staring at our bark and motion pipeline data, where the same jerk feature separates a resting dog from everything else with a margin wide enough that we did not have to be clever about it. On the nap below, sleeping epochs sit near 0.07 g/s and waking epochs jump past 5. The two classes are not near each other.

Ten Still Minutes Make a Sleep

Per-epoch labels are not sleep. A dog pausing between zoomies is at rest; nobody would call it asleep. The actigraphy convention is to require persistence, so we upgrade a run of rest epochs to sleep_like only when the run lasts at least 10 minutes. Shorter rest stays rest.

Real sleep is also not perfectly still. Dogs twitch, reposition, and kick their legs mid-dream, and a single noisy minute should not split one nap into two. So a sleep bout tolerates one interrupting active epoch, which gets relabeled restless rather than breaking the run. The restless count per bout is a number we care about on its own: a dog that sleeps six hours with twenty restless minutes is having a different night from one that sleeps six hours undisturbed.

Epoch strip before and after the bout upgrade rule
The per-epoch rule only knows rest and active. The bout pass upgrades a sustained rest run to sleep_like and absorbs a lone active epoch as restless instead of splitting the nap.

One consequence of this design is that classification is retroactive. The eleventh minute of stillness changes the label on the first ten. The worker handles this by keeping the day's epoch features and reclassifying the whole day from scratch on every flush, which sounds wasteful until you remember a day is at most 1,440 epochs of five numbers each. Correctness is cheap at this scale.

From an Offline Script to a Live Worker

The method started life as an offline analysis script we ran against exported data. Turning it into something deployed meant answering the usual streaming questions: where do samples come from, what happens on restart, and when do results leave the device.

Six stage pipeline from frames table to sleep_monitor event
sleep_worker runs as a sidecar on the CM4 hub, next to the step counter, reading the same frames stream.

The worker tails the hub's SQLite frames table by row id, the same stream the pedometer reads. Every 60-second tick it decodes new IMU batches, assigns samples to epochs, and closes any epoch whose window has fully passed plus a grace period. Timestamps needed one careful decision: the firmware's sample clock is boot-relative, so it is useless as wall time. Each batch instead gets anchored to the hub's received_at for that frame, with the firmware timestamps supplying only the spacing between samples inside the batch. Anchoring the last sample of a batch to its arrival time keeps consecutive batches from drifting apart, at the cost of small overlaps we deduplicate before computing features.

State lives in a JSON file on disk: the read cursor, the day's closed epoch features, and the flush bookkeeping. If the worker crashes or the hub reboots, it picks up from the cursor and loses at most the partially accumulated current minute. We considered persisting the raw sample buffers too and decided against it; one minute of bounded loss is a fair trade for never writing raw IMU to disk twice.

Results leave the device as a single sleep_monitor event, posted on three triggers: once an hour as a heartbeat, immediately when a sleep bout closes so the app can show a finished nap without waiting for the top of the hour, and at local midnight to seal the day before the epoch store resets. Each event carries the epoch timeline for the window since the last flush plus a full day-so-far summary, so a consumer can rebuild the day from the latest event alone.

What an Event Looks Like

Here is the actual event the collar posted on July 7th, trimmed. The flush reason tells you a bout had just closed:

{
  "kind": "sleep_hourly",
  "algo": "actigraphy-jerk-v1",
  "epoch_s": 60,
  "day_local": "2026-07-07",
  "flush_reason": "bout_closed",
  "window_epochs": [
    {"t": 1783416240000, "state": "sleep_like", "n": 1190,
     "jerk_mean": 0.07, "still_pct": 100.0, "gyro_mean": 0.29},
    ...
  ],
  "day_summary": {
    "imu_coverage_hours": 1.15,
    "sleep_like_hours": 0.63,
    "active_hours": 0.52,
    "sleep_bouts": [
      {"start": "2026-07-07T09:24:00Z", "end": "2026-07-07T10:02:00Z",
       "duration_min": 38, "restless_min": 0, "open": false}
    ],
    "longest_bout_min": 38,
    "restlessness_per_sleep_hour": 0.0
  }
}

One Real Nap

We do not have much data yet. The collar is on one dog, and the sample size for this post is a single afternoon nap. But it is a clean one, and it shows the classifier doing exactly what it was designed to do.

Log-scale jerk per epoch across a nap and wake-up, with state strip below
Mean jerk per 60 s epoch on July 7th (log scale). Thirty-eight sleeping minutes sit near 0.07 g/s, well under the 0.20 threshold. The wake-up epoch hits 60 g/s, nearly three orders of magnitude above the sleeping baseline.

The dog settled at 14:54 IST and the jerk trace flatlined. A couple of epochs poke up toward 0.15 g/s, which we read as repositioning, but nothing crosses the threshold, so the bout runs unbroken: 38 minutes, zero restless. Then the dog wakes at 15:32 and the signal does not creep up, it explodes, from 0.06 to 60 g/s inside two minutes. The gray gap right after is the collar being taken off before a play session, and it is labeled no_data rather than anything more confident, which is the behavior we want when the sensor leaves the dog.

Sleep dashboard showing the July 7 nap: 38 minute bout, zero restlessness, activity timeline
The same nap in our internal dashboard: one bout, 38 minutes, restlessness 0.0 per sleep hour, with the day's activity timeline above the session table.

What We Have Not Done Yet

The state is called sleep_like and not sleep, and that is deliberate. Actigraphy infers sleep from stillness, and a dog in a sphinx pose watching the door can look identical to a dozing one for ten minutes at a stretch. Human actigraphy has decades of validation against polysomnography behind its thresholds. We have one dog, one threshold we picked by inspection, and a few afternoons of data.

So the near-term work is evaluation rather than features. We want owner-annotated ground truth and some camera-checked sessions to measure how often sleep_like is actually sleep, full overnight recordings rather than daytime naps, and a sensitivity sweep on the 0.20 g/s threshold and the 10-minute rule across dogs of different sizes, since a terrier's twitch is not a mastiff's. The restless label needs the same treatment, because right now it counts movement without knowing whether the movement woke the dog. Further out, the per-epoch features we already store, jerk, stillness percentage, gyro magnitude, are exactly the input a small learned classifier would want, and combining them with the collar's audio events could separate "asleep" from "lying still and whining at the door." The rule-based version ships first because we can explain every label it produces, and when it is wrong, we can see why. That is a property worth keeping until the data says otherwise.

Read more