File arithmetic.hpp¶
File List > espos_flow > include > espos_flow > transforms > arithmetic.hpp
Go to the documentation of this file
// SPDX-FileCopyrightText: 2026 Dirk Wahrheit
// SPDX-License-Identifier: Apache-2.0
//
// espos::flow — the arithmetic transforms: scale, integrate, round, convert,
// clamp, rate.
//
// These are the nodes that sit directly after a sensor and turn a raw reading
// into the SI quantity Signal K wants. `Linear` is the one every installation
// uses, because no sender is exactly what its datasheet says and the two
// numbers that fix that are exactly the two a user must be able to type in.
#pragma once
#include <cmath>
#include <cstdint>
#include "espos_flow/node.hpp"
#include "espos_flow/transforms/param.hpp"
namespace espos::flow {
// ────────────────────────────────────────────────────────────────── Linear
//
// out = in * multiplier + offset
//
// SensESP's Linear, and the most-used transform in that library by a wide
// margin. Both parameters are live: `register_config()` puts them on a config
// page as `f_<id>/mul` and `f_<id>/off`, they are validated and stored in NVS,
// and a change takes effect on the next reading with no reboot.
//
// The calibration workflow this exists for: wire the sender, watch the raw
// value, measure the real quantity two ways, type the two numbers, done. That
// is a five-minute job on the dock instead of an evening with a toolchain.
template <typename T = float>
class Linear : public Symmetric<T> {
public:
Linear(const char* id, T multiplier = T{1}, T offset = T{0})
: Symmetric<T>(id), mul_(multiplier), off_(offset) {}
void set(const T& v) override {
ESPOS_FLOW_ASSERT_TASK(this->id());
this->emit(static_cast<T>(v * mul_ + off_));
}
T multiplier() const { return mul_; }
T offset() const { return off_; }
void set_multiplier(T m) { mul_ = m; }
void set_offset(T b) { off_ = b; }
// Put both parameters on a config page and adopt any stored values. Call
// after espos_config_init(); a node that never calls this keeps the values
// it was constructed with, which is what the host test relies on.
esp_err_t register_config(const char* title = "Calibration") {
params_.add_float("mul", "Multiplier", "", static_cast<float>(mul_));
params_.add_float("off", "Offset", "", static_cast<float>(off_));
esp_err_t err = params_.register_ns(this->id(), title, "Linear transform");
if (err != ESP_OK) return err;
float m = static_cast<float>(mul_);
float b = static_cast<float>(off_);
params_.load_float("mul", &m);
params_.load_float("off", &b);
mul_ = static_cast<T>(m);
off_ = static_cast<T>(b);
return ESP_OK;
}
private:
T mul_;
T off_;
ParamSet<2> params_;
};
// ───────────────────────────────────────────────────────────────── Convert
//
// Apply a pure function to every value: the unit conversions in
// espos_formulas/units.hpp, wired as a node.
//
// auto& kn = g.make<Convert<float, float, decltype(&units::ms_to_kn)>>(
// "kn", units::ms_to_kn);
//
// It is a Lambda with no parameters and a name that says what it is for. The
// distinction is worth a type: a reader seeing `Convert` knows the arithmetic
// is a unit change and not a calibration, which is the difference between
// "this is correct by definition" and "this was measured".
template <typename In, typename Out, typename Fn>
class Convert : public Transform<In, Out> {
public:
Convert(const char* id, Fn fn) : Transform<In, Out>(id), fn_(std::move(fn)) {}
void set(const In& v) override {
ESPOS_FLOW_ASSERT_TASK(this->id());
this->emit(static_cast<Out>(fn_(v)));
}
private:
Fn fn_;
};
// Deduction so `Convert{"kn", units::ms_to_kn}` needs no type list.
template <typename Fn>
Convert(const char*, Fn) -> Convert<
std::remove_cvref_t<decltype(std::declval<Fn&>()(std::declval<float>()))>,
std::remove_cvref_t<decltype(std::declval<Fn&>()(std::declval<float>()))>,
Fn>;
// ────────────────────────────────────────────────────────────────── Cast
//
// Change the value type without changing the value.
//
// Mostly unnecessary — the edge trampoline already converts implicitly, so
// float into a Consumer<int32_t> works with no node at all. It exists for the
// case where the conversion must be VISIBLE: a chain that deliberately
// truncates a reading to an integer count reads better with a node saying so
// than with a silent narrowing three files away.
template <typename In, typename Out>
class Cast : public Transform<In, Out> {
public:
explicit Cast(const char* id) : Transform<In, Out>(id) {}
void set(const In& v) override {
ESPOS_FLOW_ASSERT_TASK(this->id());
this->emit(static_cast<Out>(v));
}
};
// ────────────────────────────────────────────────────────────────── Round
//
// Round to `decimals` places. A display concern, not a data one: publishing a
// depth as 4.2 instead of 4.19999981 makes a log readable and costs nothing,
// but rounding BEFORE an average or an integral throws away information you
// paid for. Put this last in a chain, never in the middle.
class Round : public Symmetric<float> {
public:
Round(const char* id, int decimals = 0) : Symmetric<float>(id) {
set_decimals(decimals);
}
void set(const float& v) override {
ESPOS_FLOW_ASSERT_TASK(this->id());
this->emit(std::round(v * scale_) / scale_);
}
void set_decimals(int d) {
if (d < 0) d = 0;
if (d > 6) d = 6; // beyond float's ~7 significant digits it is noise
decimals_ = d;
scale_ = 1.0f;
for (int i = 0; i < d; i++) scale_ *= 10.0f;
}
int decimals() const { return decimals_; }
private:
int decimals_ = 0;
float scale_ = 1.0f;
};
// ────────────────────────────────────────────────────────────────── Clamp
//
// Limit a value to [min, max]. Not in SensESP, and it should have been: a
// sensor glitch that publishes a depth of 6000 m does more damage than a
// missing reading, because it survives into averages and rescales every graph
// that touches it.
//
// Clamp is the blunt tool — it produces a wrong-but-plausible number at the
// limit. `Threshold` (filters.hpp) is the sharp one: it drops the reading
// entirely. Prefer Threshold for data and Clamp for something that must always
// have a value, like a gauge needle or a PWM duty cycle.
template <typename T = float>
class Clamp : public Symmetric<T> {
public:
Clamp(const char* id, T min, T max)
: Symmetric<T>(id), min_(min), max_(max) {}
void set(const T& v) override {
ESPOS_FLOW_ASSERT_TASK(this->id());
this->emit(v < min_ ? min_ : (v > max_ ? max_ : v));
}
void set_limits(T min, T max) {
min_ = min;
max_ = max;
}
T min() const { return min_; }
T max() const { return max_; }
private:
T min_;
T max_;
};
// ───────────────────────────────────────────────────────────── Integrator
//
// A running sum: out += in * multiplier, emitted on every input.
//
// SensESP's Integrator. Two things it is genuinely for on a boat:
//
// * a fuel or water totaliser, where the input is a flow rate and the
// multiplier is the interval, so the sum is a volume;
// * an amp-hour counter, where the input is current and the sum is charge.
//
// It is NOT the way to integrate a rate against real elapsed time — for that
// the multiplier would have to change per sample. `RateOfChange` is the
// inverse operation and does look at the clock; an integrator that did would
// need to know that its input arrives at a fixed rate, which the graph cannot
// promise. Feed it from a `Poll` with a known period and the multiplier IS the
// period.
//
// The total persists across a reboot when `register_config()` is used: an
// engine-hours or fuel-used figure that resets when the batteries are switched
// off is worse than none, because it looks plausible.
template <typename T = float>
class Integrator : public Symmetric<T> {
public:
Integrator(const char* id, T multiplier = T{1}, T initial = T{0})
: Symmetric<T>(id), mul_(multiplier), total_(initial) {}
void set(const T& v) override {
ESPOS_FLOW_ASSERT_TASK(this->id());
total_ = static_cast<T>(total_ + v * mul_);
this->emit(total_);
}
T total() const { return total_; }
// Reset to a known figure: what a user does after filling the tank or
// changing the engine. Emits, so whatever is downstream sees it at once.
void reset(T to = T{0}) {
total_ = to;
this->emit(total_);
}
void set_multiplier(T m) { mul_ = m; }
esp_err_t register_config(const char* title = "Total",
const char* unit = "") {
params_.add_float("mul", "Multiplier", "", static_cast<float>(mul_));
params_.add_float("total", "Total", unit, static_cast<float>(total_));
esp_err_t err = params_.register_ns(this->id(), title, "Integrator");
if (err != ESP_OK) return err;
float m = static_cast<float>(mul_);
float t = static_cast<float>(total_);
params_.load_float("mul", &m);
params_.load_float("total", &t);
mul_ = static_cast<T>(m);
total_ = static_cast<T>(t);
return ESP_OK;
}
// Write the running total back to NVS. NOT called on every input: NVS is
// flash with a finite erase budget, and a 1 Hz integrator would burn through
// it in months. Call it on a slow Ticker (every few minutes), and once more
// from a shutdown path if the hardware has one.
esp_err_t persist() {
return params_.store_float("total", static_cast<float>(total_));
}
private:
T mul_;
T total_;
ParamSet<2> params_;
};
// ─────────────────────────────────────────────────────────── RateOfChange
//
// The derivative: how fast the input is changing, per SECOND.
//
// Not in SensESP, and the missing half of Integrator. What it is for:
//
// * rate of turn, from a heading (with the wrap handled before it — feed it
// an unwrapped angle or the 359-to-1 step reads as 358 deg/s);
// * charge and discharge rate, from a battery's state of charge;
// * the rate a bilge is filling, from a level sender, which is the
// difference between "the pump ran" and "call for help".
//
// Emits nothing on the first value: a rate needs two samples and there is no
// honest number to produce from one. Emits nothing when two values arrive in
// the same millisecond either — the divisor would be zero, and the flow
// clock's resolution is the limit of what can be said.
template <typename T = float>
class RateOfChange : public Symmetric<T> {
public:
explicit RateOfChange(const char* id) : Symmetric<T>(id) {}
void set(const T& v) override {
ESPOS_FLOW_ASSERT_TASK(this->id());
const uint32_t now = espos_flow_now_ms();
if (!have_prev_) {
prev_ = v;
prev_ms_ = now;
have_prev_ = true;
return;
}
// Modular subtraction: the flow clock is 32-bit milliseconds and wraps
// every 49.7 days. Subtracting is correct across the wrap; comparing is
// not.
const uint32_t dt_ms = now - prev_ms_;
if (dt_ms == 0) return;
const T rate =
static_cast<T>((v - prev_) * 1000.0f / static_cast<float>(dt_ms));
prev_ = v;
prev_ms_ = now;
this->emit(rate);
}
void reset() { have_prev_ = false; }
private:
T prev_{};
uint32_t prev_ms_ = 0;
bool have_prev_ = false;
};
// ──────────────────────────────────────────────────────────────── Counter
//
// Count inputs and emit the count. The input's value is ignored entirely —
// what is being counted is EVENTS.
//
// Bilge pump cycles, anchor-windlass turns, tacks, MOB button presses. Pair it
// with `Filter` or `ChangeFilter` upstream to count only the events that
// matter (a rising edge rather than every sample of a level that is still
// high).
template <typename In = bool>
class Counter : public Transform<In, int32_t> {
public:
explicit Counter(const char* id, int32_t initial = 0)
: Transform<In, int32_t>(id), count_(initial) {}
void set(const In&) override {
ESPOS_FLOW_ASSERT_TASK(this->id());
this->emit(++count_);
}
int32_t count() const { return count_; }
void reset(int32_t to = 0) {
count_ = to;
this->emit(count_);
}
private:
int32_t count_;
};
} // namespace espos::flow