The limits of edge classification
Our collar prototype classified activity on the device. The hardware is a Seeed XIAO nRF52840 Sense with a 6-axis IMU. The legacy firmware computed the variance of the accelerometer magnitude over a sliding window and mapped it to sleep, rest, walk, run or play with fixed thresholds. It also kept daily accumulators of sleep, rest and active seconds.
Moving the brain to the backend
On 1 March we stripped the on-device classification entirely. I fixed the IMU initialisation to 26 Hz at ±4g with the gyroscope off, and removed the variance-based classification and the daily accumulators. The device now sends raw per-minute aggregates with the activity type set to 255, which stands for UNCLASSIFIED. The magnitude variance is packed into the reserved bytes of the record.
The backend now does the classifying. A new classifyActivity() function uses intensity, steps and peak thresholds. A new migration added a magnitude_variance column to the activity records. The server recomputes daily statistics from the classified records when the device does not send them. The mobile app parses the variance from the reserved bytes and passes it to the backend during sync. Old firmware that still sends activity types 0 to 4 keeps working unchanged.
Building the FilterNet pipeline
With classification on our servers, we built a proper machine learning pipeline the next day. FilterNet combines a CNN and an LSTM, with roughly 200,000 parameters. We added pet-type conditioning: a pet-type embedding concatenated into the LSTM. The ONNX export takes two inputs, the sensor window and the pet type.
We added a loader for the Dunford cat dataset from Dryad (9 cats, 40 Hz collar data) and a physics-informed synthetic data generator that uses allometric scaling. We then expanded the model from five to seven classes, adding eating and drinking, with synthetic generators for pet-type-aware chewing and lapping vibrations and real Mendeley eating and drinking labels.
The training run reported an eating F1 of 0.914, a drinking F1 of 0.991 and a macro F1 of 0.899. The ML server accepts the pet type in the classify-batch request, and our Go client sends it.
External dependencies will break
The same day, two of our external dataset sources broke. The Mendeley and Dryad APIs changed, and our downloads failed.
We disabled the broken datasets, posture_dogs and dunford_cats, and trained with mendeley_dogs plus synthetic data only. The seven-class training commit landed after that change.
What is still open
By mid-April, real eating recall was limited by a domain gap: the model misses quiet eating, where low head movement looks like sleep. It needs real Fudini collar data to fine-tune. Seven classes also turned out to be too many. In April the model went down to four classes (sleep, walk, play, eat). Separate rest and run classes collapsed F1, because at the collar sensor rest overlaps sleep and run overlaps walk.
If you are building something similar
- Classify on the backend if you can afford to send raw data. A model on the server can be retrained and redeployed without a firmware update.
- Condition your models on the animal, and check that the pet type actually reaches the model. At one point our backend was not sending it, and everything was classified as a medium dog.
- Assume external data APIs will change without notice. Keep local copies of your training datasets.