Skip to content

File smoothing.hpp

File List > espos_flow > include > espos_flow > transforms > smoothing.hpp

Go to the documentation of this file

// SPDX-FileCopyrightText: 2026 Dirk Wahrheit
// SPDX-License-Identifier: Apache-2.0
//
// espos::flow — smoothing: moving average, median, exponential average,
// min/max hold.
//
// Every sensor on a boat is noisy, and the noise has two different characters
// that want two different tools:
//
//   * GAUSSIAN noise — thermal noise in an ADC, ripple on a supply. A moving
//     average is optimal for it and cheap.
//   * OUTLIERS — an ultrasonic depth sounder that occasionally sees a fish, a
//     GPS that occasionally sees a building, an ADC sample taken while the
//     windlass drew 200 A. A mean is the WRONG tool: one 6000 m depth reading
//     in a window of ten drags the average by 600 m. A median ignores it
//     entirely.
//
// Choosing between them is the difference between a depth display that is
// usable in a swell and one that spikes, so the two are separate nodes with
// this note between them rather than one node with a flag.
#pragma once

#include <cstddef>
#include <cstdint>

#include "espos_flow/node.hpp"
#include "espos_flow/transforms/param.hpp"

namespace espos::flow {

// ─────────────────────────────────────────────────────── MovingAverage
//
// The mean of the last N values. SensESP's MovingAverage.
//
// A ring buffer with a running sum, so the cost per sample is one add, one
// subtract and one divide regardless of N — not O(N) like the naive form.
//
// N is a template parameter because the buffer is inside the node: no
// allocation, and the size of the graph is decided at build time like every
// other espOS table. A run-time N would mean a heap allocation in a
// constructor that may run before the heap is interesting.
//
// Before the window has filled, it averages what it has rather than emitting
// nothing. That is deliberate: a depth display that shows nothing for the
// first ten seconds after boot looks broken, and the average of three samples
// is a perfectly good depth. `full()` says whether the window is complete for
// a caller that cares.
template <std::size_t N, typename T = float>
class MovingAverage : public Symmetric<T> {
  static_assert(N >= 1, "a moving average needs at least one sample");

 public:
  explicit MovingAverage(const char* id) : Symmetric<T>(id) {}

  void set(const T& v) override {
    ESPOS_FLOW_ASSERT_TASK(this->id());
    if (filled_ == N) {
      // Recomputing the sum from the ring every time would be O(N); keeping a
      // running sum means float error accumulates instead. Over a long run
      // that drift is real, so the sum is rebuilt whenever the ring wraps —
      // once per N samples, which is O(1) amortised and bounds the error to
      // one window's worth.
      sum_ -= ring_[head_];
    } else {
      filled_++;
    }
    ring_[head_] = v;
    sum_ += v;
    head_ = (head_ + 1) % N;
    if (head_ == 0) rebuild_sum();
    this->emit(static_cast<T>(sum_ / static_cast<double>(filled_)));
  }

  bool full() const { return filled_ == N; }
  std::size_t samples() const { return filled_; }
  void reset() {
    filled_ = 0;
    head_ = 0;
    sum_ = 0.0;
  }

  static constexpr std::size_t window = N;

 private:
  void rebuild_sum() {
    double s = 0.0;
    for (std::size_t i = 0; i < filled_; i++) s += ring_[i];
    sum_ = s;
  }

  T ring_[N] = {};
  // The accumulator is double even when T is float: summing a hundred floats
  // in float loses the low bits of every one of them, and this is the one
  // place in the graph where the extra width is worth its cost.
  double sum_ = 0.0;
  std::size_t head_ = 0;
  std::size_t filled_ = 0;
};

// ─────────────────────────────────────────────────────────────── Median
//
// The median of the last N values. SensESP's Median.
//
// This is the outlier-resistant one — see the note at the top of the file.
// Half the window can be garbage and the output does not move.
//
// N should be ODD. With an even N the median is the mean of the two middle
// values, which reintroduces exactly the averaging-with-an-outlier problem the
// median was chosen to avoid (one wild value pulls one of the two middles).
// The static_assert says so rather than letting it be a surprise.
//
// Cost is a partial selection sort of a copy, O(N * N/2) worst case. At the
// N of 5 to 9 that a depth sounder wants that is nothing; at N of 50 it is
// noticeable per sample and MovingAverage or Ema is the better answer.
template <std::size_t N, typename T = float>
class Median : public Symmetric<T> {
  static_assert(N >= 1, "a median needs at least one sample");
  static_assert(N % 2 == 1,
                "use an odd window: an even one averages the two middle "
                "values, which is the outlier sensitivity a median exists to "
                "avoid");

 public:
  explicit Median(const char* id) : Symmetric<T>(id) {}

  void set(const T& v) override {
    ESPOS_FLOW_ASSERT_TASK(this->id());
    ring_[head_] = v;
    head_ = (head_ + 1) % N;
    if (filled_ < N) filled_++;

    // Copy, then selection-sort only as far as the middle. Sorting the ring in
    // place would destroy the arrival order the ring depends on.
    T buf[N];
    for (std::size_t i = 0; i < filled_; i++) buf[i] = ring_[i];
    const std::size_t mid = filled_ / 2;
    for (std::size_t i = 0; i <= mid; i++) {
      std::size_t m = i;
      for (std::size_t j = i + 1; j < filled_; j++) {
        if (buf[j] < buf[m]) m = j;
      }
      if (m != i) {
        T t = buf[i];
        buf[i] = buf[m];
        buf[m] = t;
      }
    }
    this->emit(buf[mid]);
  }

  bool full() const { return filled_ == N; }
  std::size_t samples() const { return filled_; }
  void reset() {
    filled_ = 0;
    head_ = 0;
  }

  static constexpr std::size_t window = N;

 private:
  T ring_[N] = {};
  std::size_t head_ = 0;
  std::size_t filled_ = 0;
};

// ──────────────────────────────────────────────────────────────────── Ema
//
// Exponential moving average: out = alpha * in + (1 - alpha) * out.
//
// Not in SensESP, and it should be the default choice for most smoothing. One
// multiply-add and ONE stored float — no ring buffer, no window — against
// MovingAverage's N floats. For a device with fifteen smoothed channels that
// is the difference between 600 bytes of RAM and 60.
//
// Alpha is the responsiveness, 0..1: 1 is no smoothing at all, 0.1 is heavy.
// The rule of thumb is that alpha ≈ 2 / (N + 1) behaves like an N-sample
// moving average, so alpha 0.2 is roughly a 9-sample window.
//
// The first value is adopted as-is rather than blended toward from zero.
// Starting at zero means a temperature channel ramps up from 0 K over the
// first minute, which looks exactly like a failing sensor.
template <typename T = float>
class Ema : public Symmetric<T> {
 public:
  Ema(const char* id, float alpha = 0.2f) : Symmetric<T>(id) {
    set_alpha(alpha);
  }

  void set(const T& v) override {
    ESPOS_FLOW_ASSERT_TASK(this->id());
    if (!have_) {
      acc_ = static_cast<double>(v);
      have_ = true;
    } else {
      acc_ = alpha_ * static_cast<double>(v) + (1.0 - alpha_) * acc_;
    }
    this->emit(static_cast<T>(acc_));
  }

  void set_alpha(float a) {
    if (a <= 0.0f) a = 0.0001f;  // 0 would freeze the output forever
    if (a > 1.0f) a = 1.0f;
    alpha_ = a;
  }
  float alpha() const { return static_cast<float>(alpha_); }
  void reset() { have_ = false; }

  esp_err_t register_config(const char* title = "Smoothing") {
    params_.add_float("alpha", "Responsiveness", "",
                      static_cast<float>(alpha_));
    esp_err_t err =
        params_.register_ns(this->id(), title, "Exponential average");
    if (err != ESP_OK) return err;
    float a = static_cast<float>(alpha_);
    params_.load_float("alpha", &a);
    set_alpha(a);
    return ESP_OK;
  }

 private:
  double alpha_ = 0.2;
  double acc_ = 0.0;
  bool have_ = false;
  ParamSet<1> params_;
};

// ─────────────────────────────────────────────────────────── MinMaxHold
//
// Remember the extremes over a rolling window, and emit them.
//
// Not in SensESP. What it is for: max wind gust over the last ten minutes,
// which is a number every sailor wants and no instrument gives without it;
// minimum depth over the last hour, which is the one that tells you whether
// you touched; maximum engine temperature since start.
//
// The window is TIME, not a sample count — a gust figure over "the last 60
// readings" means something different at anchor than under way. The
// implementation keeps a small ring of (value, time) pairs and expires them,
// which is O(Slots) per sample; Slots is small on purpose, because a gust
// window needs resolution, not history.
//
// The output is the max; `min()` and `max()` read both. A caller that wants
// both published wires two of these, or reads the accessors from a Sink.
template <std::size_t Slots = 16, typename T = float>
class MinMaxHold : public Symmetric<T> {
  static_assert(Slots >= 2, "a hold window needs at least two slots");

 public:
  MinMaxHold(const char* id, uint32_t window_ms)
      : Symmetric<T>(id), window_ms_(window_ms) {}

  void set(const T& v) override {
    ESPOS_FLOW_ASSERT_TASK(this->id());
    const uint32_t now = espos_flow_now_ms();

    // The ring is a coarse histogram of the window, not every sample: each
    // slot covers window_ms/Slots and holds that bucket's own min and max.
    // That bounds the memory at 16 slots regardless of sample rate, which is
    // what lets a 10-minute gust window sit next to a 20 Hz anemometer.
    const uint32_t bucket_ms = window_ms_ / Slots ? window_ms_ / Slots : 1;
    const uint32_t bucket = now / bucket_ms;

    if (!have_) {
      for (std::size_t i = 0; i < Slots; i++) slots_[i].bucket = bucket - Slots;
      have_ = true;
    }
    Slot& s = slots_[bucket % Slots];
    if (s.bucket != bucket) {
      // This slot is from a previous lap of the ring: it has expired, so it
      // starts over with this sample rather than keeping a value from a
      // window ago.
      s.bucket = bucket;
      s.min = v;
      s.max = v;
    } else {
      if (v < s.min) s.min = v;
      if (v > s.max) s.max = v;
    }

    // Fold the live slots. A slot whose bucket is more than Slots behind is
    // outside the window and is skipped, which is how a value ages out.
    bool any = false;
    T lo{}, hi{};
    for (std::size_t i = 0; i < Slots; i++) {
      if (bucket - slots_[i].bucket >= Slots) continue;
      if (!any) {
        lo = slots_[i].min;
        hi = slots_[i].max;
        any = true;
      } else {
        if (slots_[i].min < lo) lo = slots_[i].min;
        if (slots_[i].max > hi) hi = slots_[i].max;
      }
    }
    min_ = lo;
    max_ = hi;
    this->emit(hi);
  }

  T min() const { return min_; }
  T max() const { return max_; }
  void reset() { have_ = false; }

 private:
  struct Slot {
    uint32_t bucket = 0;
    T min{};
    T max{};
  };

  Slot slots_[Slots] = {};
  uint32_t window_ms_;
  T min_{};
  T max_{};
  bool have_ = false;
};

}  // namespace espos::flow