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 showing 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.

// Effective tare = tare_offset + drift_offset
int32_t effective_tare = calib_data.tare_offset[bowl_idx][ch_idx] +
                         calib_data.drift_offset[bowl_idx][ch_idx];

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:

// Drift compensation configuration
#define DRIFT_WINDOW_DURATION_SEC 300   // 5 minutes
#define DRIFT_MAX_SAMPLES 6000          // 5min @ 20 samples/sec
#define DRIFT_STABILITY_THRESHOLD 10.0f // Max weight variation (g)
#define DRIFT_EMPTY_BOWL_THRESHOLD                                             \
  20.0f                                // Max avg weight to consider "empty" (g)
#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).

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:

// Check stability: delta < 10g means no eating/drinking
if (delta < 10.0f) {
  // STABLE: Calculate drift as change from first to last sample
  int32_t first_raw = w->raw_samples[0];
  int32_t last_raw = w->raw_samples[num_samples - 1];
  int32_t drift_delta = last_raw - first_raw;

  // Add this delta to existing drift_offset
  int32_t old_drift = calib_data.drift_offset[bowl_id][ch_idx];
  int32_t new_drift = old_drift + drift_delta;

  calib_data.drift_offset[bowl_id][ch_idx] = new_drift;
}

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 gets 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:

// Invalidate window if significant sudden weight change (activity detected)
if (w->sample_count > 0 &&
    fabs(new_weight - w->weight_samples[w->sample_count - 1]) > 50.0f) {
    w->sample_count = 0;
    w->window_start = now;
    return false;
}

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 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

Weight is one of the highest-value signals our 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.

Key takeaways

Weight is a slow, small-signal metric, 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, using a peak-to-peak stability gate over a fixed window to separate true drift from eating and drinking. Reset on activity, so a real meal never gets mistaken for drift and silently averaged away. And accumulate and persist, folding each quiet window's slide into a running, saved offset so the device gets more accurate the longer it runs.

A cosplay wig is best compared by colour, length, fibre density and fringe shape. Fringe placement can change the overall impression of the character. When comparing colour and fringe shape, Marin Kitagawa character wig(喜多川海夢 キャラクターウィッグ) keeps the choice tied to the intended character. This reference remains specific to wigs and does not use costume sizing language. Gentle detangling and suitable storage help maintain the wig between uses. Final styling is easiest after the costume neckline and accessories are considered.