Continuous Weight Sensing: Taming Load-Cell Drift

Continuous Weight Sensing: Taming Load-Cell Drift

A dog's weight is arguably the single most predictive number in preventive care. A slow upward creep is the earliest signal of obesity; a slow decline can be the first whisper of kidney disease, dental pain, or a dozen other conditions a vet would love to catch months sooner. So when we built a smart feeding station that weighs the bowl continuously, the goal was bigger than "show grams on a screen" — it was to track a pet's weight precisely enough, over weeks, to surface trends a human would miss. That's where it gets hard. A load cell that quietly reads a few grams heavier every hour will manufacture a fake weight-gain trend out of thin air. This post is about how we measure weight on the bowl, and how we fight the drift that would otherwise make those measurements lie.

The Problem: The Sensor Won't Hold Still

A load cell is a metal flexure with strain gauges bonded to it. Press on it and it bends microscopically, changing the gauges' resistance by a vanishingly small amount. We read three of them under each bowl, digitize that tiny signal with a 24-bit analog-to-digital converter, and end up with a raw integer count proportional to force.

Turning counts into grams is straightforward in principle: subtract the empty-bowl reading (the tare offset), then multiply by a calibration factor measured against known weights. That's the whole formula in our firmware:

1378	    // Effective tare = tare_offset + drift_offset
1379	    int32_t effective_tare = calib_data.tare_offset[bowl_idx][ch_idx] +
1380	                             calib_data.drift_offset[bowl_idx][ch_idx];
1381	
1382	    result.weight = (result.raw_value - effective_tare) * scale->calib_factor;

The trouble is the word "empty-bowl reading," as if it were a constant. It isn't. The raw zero point of a load cell drifts — it wanders slowly with temperature, with mechanical creep in the flexure, with humidity, with the structure settling over hours. A bowl tared at 9 a.m. can read tens of grams off by afternoon with nothing on it at all. Over a day, that drift dwarfs the real signal we care about, which for a steadily-fed pet might be only a handful of grams.

The Approach: Notice the Drift, Don't Just Fight the Noise

It's tempting to reach for heavier filtering — a longer moving average, a low-pass, a Kalman filter. But drift isn't noise; it's a slow, real shift in the sensor's zero, and averaging won't remove a bias that's genuinely there. We need to estimate the zero offset as it changes and subtract it back out.

The key insight is that the bowl spends most of its day empty and undisturbed. If we watch a quiet stretch and the reading slowly slides while nothing is physically happening, that slide is the drift — and we can measure it directly. The catch is telling "quiet drift" apart from "a dog just ate 30 grams of kibble." Subtract the wrong thing and you erase the very meals you're trying to measure.

So our approach is a running drift-compensation window: collect every reading for five minutes, decide whether that window was genuinely quiet, and only then learn a new zero correction from it. Three thresholds govern the decision:

32	// Drift compensation configuration
33	#define DRIFT_WINDOW_DURATION_SEC 300   // 5 minutes
34	#define DRIFT_MAX_SAMPLES 6000          // 5min @ 20 samples/sec
35	#define DRIFT_STABILITY_THRESHOLD 10.0f // Max weight variation (g)
36	#define DRIFT_EMPTY_BOWL_THRESHOLD                                             \
37	  20.0f                                // Max avg weight to consider "empty" (g)
38	#define DRIFT_ACTIVITY_THRESHOLD 50.0f // Sudden change threshold (g)

A window is only "trustworthy drift" if the weight barely moved across the whole five minutes (stability), and any sudden jump means a pet showed up and the window is thrown away (activity). Everything below is built on those two ideas.

The Process: A Five-Minute Window That Knows When to Trust Itself

Every reading flows into a per-channel window buffer. When five minutes have elapsed, we analyze the whole batch: compute the min, max, and average, and derive the peak-to-peak spread, delta. If delta is small, nothing physical happened during those five minutes — so any change in the raw value is pure drift, and we fold it into the running drift_offset:

248	    // Check stability: delta < 10g means no eating/drinking
249	    if (delta < 10.0f) {
250	      // STABLE: Calculate drift as change from first to last sample
251	      int32_t first_raw = w->raw_samples[0];
252	      int32_t last_raw = w->raw_samples[num_samples - 1];
253	      int32_t drift_delta = last_raw - first_raw;
254	
255	      // Add this delta to existing drift_offset
256	      int32_t old_drift = calib_data.drift_offset[bowl_id][ch_idx];
257	      int32_t new_drift = old_drift + drift_delta;
258	
259	      calib_data.drift_offset[bowl_id][ch_idx] = new_drift;
260	
261	      printf("[DRIFT] Bowl%d Ch%d: COMPENSATED - drift_offset %+d → %+d (Δ%+d "
262	             "over 5min, %d samples)\n",
263	             bowl_id + 1, ch_idx + 1, old_drift, new_drift, drift_delta,
264	             num_samples);

Notice it's accumulative — each quiet window adds its small slide to the existing offset, so the correction tracks the sensor's slow wander all day long. And it's persisted: the new offset is written to the calibration file immediately after, so a reboot doesn't throw away hours of learned correction.

The opposite case is just as important. If at any point a reading jumps by more than the activity threshold versus the previous sample, a pet has almost certainly arrived — so we discard the current window and start fresh rather than mistake a meal for drift:

284	  // Invalidate window if significant sudden weight change (activity detected)
285	  if (w->sample_count > 0 &&
286	      fabs(new_weight - w->weight_samples[w->sample_count - 1]) > 50.0f) {
287	    printf("[DRIFT] Bowl%d Ch%d: Activity detected (%.1fg change), resetting "
288	           "window\n",
289	           bowl_id + 1, ch_idx + 1,
290	           fabs(new_weight - w->weight_samples[w->sample_count - 1]));
291	    w->sample_count = 0;
292	    w->window_start = now;
293	    return false;
294	  }

This guard is what keeps the system honest. Drift is only ever learned from stillness; the moment there's real activity, learning pauses. We also require a minimum sample count before trusting a window at all, so a brief quiet gap can't trigger a spurious correction.

The Results

The payoff is a weight stream that stays anchored to reality over the long haul. Instead of a slowly tilting baseline that fakes a gain or loss, the reported empty-bowl weight hovers near zero across temperature swings and all-day operation, because the compensation quietly chases the sensor's wander every quiet five minutes.

Because the correction is per-channel — three load cells under each bowl, each drifting slightly differently — we can also average across channels for a stable bowl total while still catching a single corner that's misbehaving. And because the whole thing is a few comparisons and subtractions, it costs almost nothing: no heavy filter, no model, just a window buffer and three thresholds. The learned offsets live in a small calibration file and survive restarts, so the device gets more accurate the longer it runs, not less.

The honest limitation, which the thresholds make explicit, is that drift is only learned when the bowl is left alone — a station that's in constant use simply learns more slowly. For a feeding bowl, that's exactly the right tradeoff: there's always a quiet night.

Why It Matters at Hoomanely

Hoomanely is reinventing healthcare for pets — replacing reactive, imprecise care with continuous, clinical-grade monitoring that catches problems early. Our devices form a Physical Intelligence ecosystem: sensors fused at the edge, feeding the Biosense AI Engine that turns raw signals into personalized, preventive insights.

Weight is one of the highest-value signals that ecosystem can capture, precisely because the meaningful changes are slow and small. A vet visit catches weight twice a year; a smart bowl catches it every single day, and a trustworthy multi-week trend is what makes early intervention possible. None of that works if the underlying sensor is allowed to drift — a forged trend is worse than no trend, because it erodes trust in the whole system.

Our guiding principle is that every signal matters and every detail counts. Taming load-cell drift is a small, unglamorous piece of measurement engineering — but it's the difference between a number that looks precise and one a clinician can actually act on.

Key Takeaways

  • Weight is a slow, small-signal metric. The health-relevant changes are tiny, so sensor drift can easily exceed the real signal and invent false trends.
  • Drift is bias, not noise. Filtering won't remove a wandering zero; you have to estimate the offset and subtract it back out.
  • Learn the zero only from stillness. A peak-to-peak stability gate over a fixed window separates true drift from eating and drinking.
  • Reset on activity. A sudden-change guard ensures a real meal never gets mistaken for drift and silently averaged away.
  • Accumulate and persist. Folding each quiet window's slide into a running, saved offset lets the device get more accurate the longer it runs.

Author's Note

This drift-compensation logic runs on the Linux compute module inside Hoomanely's smart feeding station, one of the physical-intelligence devices in our ecosystem. The bowl weighs continuously, corrects its own zero through the quiet hours, and streams a trustworthy weight to the Biosense AI Engine. It's a reminder that "AI-powered pet health" starts long before any model runs — it starts with caring enough about a few grams to make the sensor tell the truth.

Read more