Skip to content

File marine.hpp

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

Go to the documentation of this file

// SPDX-FileCopyrightText: 2026 Dirk Wahrheit
// SPDX-License-Identifier: Apache-2.0
//
// espos::flow — the marine transforms: the espos_formulas arithmetic, wired as
// nodes with live parameters.
//
// The maths lives in `components/espos_formulas` (pure, host-tested against
// published reference values); this file is the thin layer that gives it an
// id, a place in a chain and a config page. Keeping the two apart is what
// makes the arithmetic testable without a device and the nodes trivial enough
// to read at a glance.
#pragma once

#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <optional>
#include <string>

#include "espos_flow/node.hpp"
#include "espos_flow/transforms/param.hpp"
#include "espos_formulas/curve.hpp"
#include "espos_formulas/marine.hpp"
#include "espos_formulas/units.hpp"

namespace espos::flow {

// espos::units is a sibling of espos::flow, not nested in it; naming it once
// here keeps the nodes below reading as `units::rad_to_deg` rather than
// `::espos::units::rad_to_deg`.
namespace units = ::espos::units;

// ────────────────────────────────────────────────────────────────── Curve
//
// Piecewise-linear calibration over a table the user edits IN THE WEB UI.
// SensESP's CurveInterpolator, and the feature that most often turns a
// half-finished installation into a finished one.
//
// The table is a `format: "table"` string config key (docs/config.md): a JSON
// array of {"in": x, "out": y} rows that the UI renders as a row editor, NVS
// stores as readable text, and an export round-trips diffably. So calibrating
// a tank is: fill in ten litres, read the raw value off the device page, type
// the pair, repeat. No rebuild, no cable, no laptop.
//
// Endpoints CLAMP rather than extrapolate — see espos_formulas/curve.hpp for
// why (SensESP #1005 published a NaN below the lowest sample).
//
// `MaxSamples` is a template parameter because the table lives inside the
// node. 32 covers a tank curve with room to spare; the config key's 3999-byte
// budget is the other limit, at roughly 250 rows.
template <std::size_t MaxSamples = 32>
class Curve : public Symmetric<float> {
 public:
  explicit Curve(const char* id) : Symmetric<float>(id) {}

  // Build with a compiled-in table: a thermistor preset from
  // espos_formulas/marine.hpp, or a manufacturer's curve. A user's stored
  // table replaces it once register_config() loads one.
  Curve(const char* id, const formulas::CurveSample* samples, std::size_t count)
      : Symmetric<float>(id) {
    set_samples(samples, count);
  }

  void set(const float& v) override {
    ESPOS_FLOW_ASSERT_TASK(this->id());
    std::optional<float> out = formulas::curve_eval(samples_, count_, v);
    // An empty table emits nothing rather than zero: a tank that has never
    // been calibrated should read as "no data", not as "empty".
    if (out.has_value()) this->emit(*out);
  }

  void set_samples(const formulas::CurveSample* samples, std::size_t count) {
    count_ = count < MaxSamples ? count : MaxSamples;
    for (std::size_t i = 0; i < count_; i++) samples_[i] = samples[i];
    formulas::curve_sort(samples_, count_);
  }

  std::size_t sample_count() const { return count_; }
  const formulas::CurveSample* samples() const { return samples_; }

  // Parse the table config key's JSON: [{"in":0,"out":0},{"in":10,"out":0.5}].
  //
  // A hand-rolled scan rather than a JSON parser, for two reasons: the shape
  // is fixed and trivial, and pulling cJSON into espos_flow would give the
  // graph runtime a dependency on a parser that a firmware doing no
  // interpolation would still link. It reads NUMBERS in order, two per row —
  // so it accepts the object form above and the shorter [[0,0],[10,0.5]] one
  // equally, which is what a user typing into a text box will produce anyway.
  //
  // Returns the number of samples parsed. A malformed table yields whatever
  // complete pairs it found, which degrades to "fewer points" rather than to
  // "no calibration at all".
  std::size_t set_table_json(const char* json) {
    count_ = 0;
    if (json == nullptr) return 0;
    const char* p = json;
    float pending = 0.0f;
    bool have_first = false;
    while (*p != '\0' && count_ < MaxSamples) {
      // Skip to the next number, ignoring keys, punctuation and whitespace.
      if (!((*p >= '0' && *p <= '9') || *p == '-' || *p == '+' ||
            (*p == '.' && p[1] >= '0' && p[1] <= '9'))) {
        p++;
        continue;
      }
      char* end = nullptr;
      const float v = std::strtof(p, &end);
      if (end == p) {
        p++;
        continue;
      }
      p = end;
      if (!have_first) {
        pending = v;
        have_first = true;
      } else {
        samples_[count_].input = pending;
        samples_[count_].output = v;
        count_++;
        have_first = false;
      }
    }
    formulas::curve_sort(samples_, count_);
    return count_;
  }

  // Serialise the table back to the config key's JSON form.
  // `buf` needs about 32 bytes per sample.
  std::size_t table_json(char* buf, std::size_t n) const {
    if (buf == nullptr || n == 0) return 0;
    std::size_t w = 0;
    auto put = [&](const char* s) {
      while (*s && w + 1 < n) buf[w++] = *s++;
    };
    put("[");
    for (std::size_t i = 0; i < count_; i++) {
      char row[64];
      std::snprintf(row, sizeof(row), "%s{\"in\":%g,\"out\":%g}", i ? "," : "",
                    static_cast<double>(samples_[i].input),
                    static_cast<double>(samples_[i].output));
      put(row);
    }
    put("]");
    buf[w] = '\0';
    return w;
  }

  // Put the table on a config page and adopt a stored one.
  esp_err_t register_config(const char* title = "Calibration curve",
                            const char* in_label = "input",
                            const char* out_label = "output") {
    columns_[0] = in_label;
    columns_[1] = out_label;
    params_.add_table("curve", "Samples", columns_, 2);
    esp_err_t err = params_.register_ns(this->id(), title, "Curve");
    if (err != ESP_OK) return err;
    // A stored table replaces the compiled-in one; an absent key reads the
    // "[]" default, which would wipe a preset, so an empty parse is ignored.
    char json[512] = {};
    params_.load_str("curve", json, sizeof(json));
    if (json[0] != '\0') {
      const std::size_t before = count_;
      if (set_table_json(json) == 0 && before > 0) {
        // Nothing parsed but we had a preset: keep it rather than emitting
        // nothing forever because a config key was empty.
        return ESP_OK;
      }
    }
    return ESP_OK;
  }

 private:
  formulas::CurveSample samples_[MaxSamples] = {};
  std::size_t count_ = 0;
  const char* columns_[2] = {"input", "output"};
  ParamSet<1> params_;
};

// ─────────────────────────────────────────────────────────────── DewPoint
//
// Temperature and humidity in, dew point out. Arden Buck, in kelvin
// throughout (Signal K's unit).
//
// Two inputs, so it is a node with a second consumer slot rather than a
// Symmetric: `temp` is the primary input and drives the output, `humidity()`
// is the auxiliary. That asymmetry is deliberate — a BME280 reads both in one
// transaction and publishes temperature at the sensor's rate, so recomputing
// on every humidity update as well would double the output rate for no new
// information.
//
// Emits nothing until both have arrived, and nothing when RH is zero (there is
// no dew point for perfectly dry air; SensESP returned -inf).
class DewPoint : public Transform<float, float> {
 public:
  explicit DewPoint(const char* id) : Transform<float, float>(id) {}

  void set(const float& temp_k) override {
    ESPOS_FLOW_ASSERT_TASK(this->id());
    temp_k_ = temp_k;
    have_temp_ = true;
    compute();
  }

  class Humidity : public Consumer<float> {
   public:
    using consumes_type = float;
    void set(const float& rh) override {
      if (!owner) return;
      owner->rh_ = rh;
      owner->have_rh_ = true;
    }
    DewPoint* owner = nullptr;
  };

  // Wire the humidity producer here: `bme.humidity >> dew.humidity()`.
  Humidity& humidity() {
    rh_in_.owner = this;
    return rh_in_;
  }

 private:
  void compute() {
    if (!have_temp_ || !have_rh_) return;
    std::optional<float> dp = formulas::dew_point_k(temp_k_, rh_);
    if (dp.has_value()) this->emit(*dp);
  }

  float temp_k_ = 0.0f;
  float rh_ = 0.0f;
  bool have_temp_ = false;
  bool have_rh_ = false;
  Humidity rh_in_;
};

// ────────────────────────────────────────────────────────────── HeatIndex
//
// The same two inputs, NOAA's regression, "what it feels like" in kelvin.
class HeatIndex : public Transform<float, float> {
 public:
  // `effect` true emits the DIFFERENCE (how much hotter it feels) rather than
  // the absolute index — SensESP's HeatIndexEffect, which is usually the more
  // useful channel because it is a fact about the space rather than about the
  // weather.
  explicit HeatIndex(const char* id, bool effect = false)
      : Transform<float, float>(id), effect_(effect) {}

  void set(const float& temp_k) override {
    ESPOS_FLOW_ASSERT_TASK(this->id());
    temp_k_ = temp_k;
    have_temp_ = true;
    if (!have_rh_) return;
    this->emit(effect_ ? formulas::heat_index_effect_k(temp_k_, rh_)
                       : formulas::heat_index_k(temp_k_, rh_));
  }

  class Humidity : public Consumer<float> {
   public:
    using consumes_type = float;
    void set(const float& rh) override {
      if (!owner) return;
      owner->rh_ = rh;
      owner->have_rh_ = true;
    }
    HeatIndex* owner = nullptr;
  };

  Humidity& humidity() {
    rh_in_.owner = this;
    return rh_in_;
  }

 private:
  bool effect_;
  float temp_k_ = 0.0f;
  float rh_ = 0.0f;
  bool have_temp_ = false;
  bool have_rh_ = false;
  Humidity rh_in_;
};

// ───────────────────────────────────────────────────────────── AirDensity
//
// Temperature, pressure and humidity to kg/m3. Three inputs: temperature is
// the primary, pressure and humidity are auxiliaries.
class AirDensity : public Transform<float, float> {
 public:
  explicit AirDensity(const char* id) : Transform<float, float>(id) {}

  void set(const float& temp_k) override {
    ESPOS_FLOW_ASSERT_TASK(this->id());
    temp_k_ = temp_k;
    if (!(pressure_pa_ > 0.0f) || !(temp_k_ > 0.0f)) return;
    this->emit(formulas::air_density(temp_k_, pressure_pa_, rh_));
  }

  class Pressure : public Consumer<float> {
   public:
    using consumes_type = float;
    void set(const float& pa) override {
      if (owner) owner->pressure_pa_ = pa;
    }
    AirDensity* owner = nullptr;
  };
  class Humidity : public Consumer<float> {
   public:
    using consumes_type = float;
    void set(const float& rh) override {
      if (owner) owner->rh_ = rh;
    }
    AirDensity* owner = nullptr;
  };

  Pressure& pressure() {
    p_in_.owner = this;
    return p_in_;
  }
  Humidity& humidity() {
    rh_in_.owner = this;
    return rh_in_;
  }

 private:
  float temp_k_ = 0.0f;
  // Sea-level standard, so a device with no barometer still produces a usable
  // density rather than nothing. Documented as an assumption, not hidden:
  // 1013.25 hPa is right within a percent for most weather.
  float pressure_pa_ = 101325.0f;
  float rh_ = 0.0f;
  Pressure p_in_;
  Humidity rh_in_;
};

// ─────────────────────────────────────────────────── DividerR1 / R2 / Scale
//
// A measured voltage back to what the sender is doing. See
// espos_formulas/marine.hpp for the circuit and for which form to pick;
// briefly, R2 is the one tank and temperature senders need (they are the
// low-side element with one terminal on the hull).
//
// All three take the ADC voltage as their input and their circuit constants as
// live parameters, because the resistor you soldered is 1% at best and the
// supply rail is not exactly 3.3 V. Both are things a user measures once and
// types in.
class DividerR2 : public Symmetric<float> {
 public:
  DividerR2(const char* id, float v_in, float r1_ohm)
      : Symmetric<float>(id), v_in_(v_in), r1_(r1_ohm) {}

  void set(const float& v_out) override {
    ESPOS_FLOW_ASSERT_TASK(this->id());
    std::optional<float> r = formulas::divider_r2(v_out, v_in_, r1_);
    // An open sender emits nothing rather than infinity: a disconnected wire
    // must not read as a full tank.
    if (r.has_value()) this->emit(*r);
  }

  esp_err_t register_config(const char* title = "Divider") {
    params_.add_float("vin", "Supply voltage", "V", v_in_);
    params_.add_float("r1", "Fixed resistor", "ohm", r1_);
    esp_err_t err = params_.register_ns(this->id(), title, "Resistive divider");
    if (err != ESP_OK) return err;
    params_.load_float("vin", &v_in_);
    params_.load_float("r1", &r1_);
    return ESP_OK;
  }

 private:
  float v_in_;
  float r1_;
  ParamSet<2> params_;
};

class DividerR1 : public Symmetric<float> {
 public:
  DividerR1(const char* id, float v_in, float r2_ohm)
      : Symmetric<float>(id), v_in_(v_in), r2_(r2_ohm) {}

  void set(const float& v_out) override {
    ESPOS_FLOW_ASSERT_TASK(this->id());
    std::optional<float> r = formulas::divider_r1(v_out, v_in_, r2_);
    if (r.has_value()) this->emit(*r);
  }

  esp_err_t register_config(const char* title = "Divider") {
    params_.add_float("vin", "Supply voltage", "V", v_in_);
    params_.add_float("r2", "Fixed resistor", "ohm", r2_);
    esp_err_t err = params_.register_ns(this->id(), title, "Resistive divider");
    if (err != ESP_OK) return err;
    params_.load_float("vin", &v_in_);
    params_.load_float("r2", &r2_);
    return ESP_OK;
  }

 private:
  float v_in_;
  float r2_;
  ParamSet<2> params_;
};

// Undo a known attenuation: what a 12 V bus monitor needs, where the divider
// exists only to bring 14 V inside the ADC's 3.3 V range.
class DividerScale : public Symmetric<float> {
 public:
  DividerScale(const char* id, float r1_ohm, float r2_ohm)
      : Symmetric<float>(id), r1_(r1_ohm), r2_(r2_ohm) {}

  void set(const float& v_out) override {
    ESPOS_FLOW_ASSERT_TASK(this->id());
    this->emit(formulas::divider_scale(v_out, r1_, r2_));
  }

  esp_err_t register_config(const char* title = "Divider") {
    params_.add_float("r1", "Upper resistor", "ohm", r1_);
    params_.add_float("r2", "Lower resistor", "ohm", r2_);
    esp_err_t err = params_.register_ns(this->id(), title, "Voltage divider");
    if (err != ESP_OK) return err;
    params_.load_float("r1", &r1_);
    params_.load_float("r2", &r2_);
    return ESP_OK;
  }

 private:
  float r1_;
  float r2_;
  ParamSet<2> params_;
};

// ──────────────────────────────────────────────────────────── Frequency
//
// Pulse counts to a rate. The input is a COUNT for the elapsed period, which
// is what a pulse-counter peripheral or an ISR-fed Mailbox produces; the node
// divides by the period and by the pulses-per-unit multiplier.
//
// `pulses_per_unit` of 6 turns an alternator's W terminal into engine
// revolutions per second — see espos_formulas/marine.hpp. Signal K wants Hz,
// not RPM: units::hz_to_rpm exists for a display, not for a publish.
class Frequency : public Transform<int32_t, float> {
 public:
  Frequency(const char* id, uint32_t period_ms, float pulses_per_unit = 1.0f)
      : Transform<int32_t, float>(id),
        period_s_(static_cast<float>(period_ms) / 1000.0f),
        ppu_(pulses_per_unit) {}

  void set(const int32_t& count) override {
    ESPOS_FLOW_ASSERT_TASK(this->id());
    std::optional<float> hz = formulas::frequency_hz(
        static_cast<uint32_t>(count < 0 ? 0 : count), period_s_, ppu_);
    if (hz.has_value()) this->emit(*hz);
  }

  esp_err_t register_config(const char* title = "Frequency") {
    params_.add_float("ppu", "Pulses per revolution", "", ppu_);
    esp_err_t err = params_.register_ns(this->id(), title, "Frequency");
    if (err != ESP_OK) return err;
    params_.load_float("ppu", &ppu_);
    return ESP_OK;
  }

 private:
  float period_s_;
  float ppu_;
  ParamSet<1> params_;
};

// ──────────────────────────────────────────────────────── AngleOffset
//
// Add a calibration offset to an angle and wrap the result.
//
// SensESP's AngleCorrection, plus the interval choice it lacked: `min` of 0
// gives [0, 2pi) for a heading, and -pi gives [-pi, pi) for a relative angle
// like apparent wind or rudder, where the sign is the information.
//
// The offset is stored in RADIANS (SI) and shown in DEGREES: the config key
// carries `displayMultiplier` 180/pi, so a user types "the sensor is mounted
// 12 degrees off the bow" and NVS holds 0.2094.
class AngleOffset : public Symmetric<float> {
 public:
  AngleOffset(const char* id, float offset_rad = 0.0f, float min_rad = 0.0f)
      : Symmetric<float>(id), offset_(offset_rad), min_(min_rad) {}

  void set(const float& rad) override {
    ESPOS_FLOW_ASSERT_TASK(this->id());
    this->emit(formulas::angle_offset(rad, offset_, min_));
  }

  void set_offset(float rad) { offset_ = rad; }
  float offset() const { return offset_; }

  esp_err_t register_config(const char* title = "Angle offset") {
    params_.add_float("off", "Offset", "rad", offset_, "",
                      units::rad_to_deg(1.0f));
    esp_err_t err = params_.register_ns(this->id(), title, "Angle offset");
    if (err != ESP_OK) return err;
    params_.load_float("off", &offset_);
    return ESP_OK;
  }

 private:
  float offset_;
  float min_;
  ParamSet<1> params_;
};

// Wrap without an offset: normalise an angle that arrived outside its
// interval. Useful after arithmetic — a heading plus a rate-of-turn
// integration will drift out of [0, 2pi) and needs folding back.
class WrapAngle : public Symmetric<float> {
 public:
  explicit WrapAngle(const char* id, float min_rad = 0.0f)
      : Symmetric<float>(id), min_(min_rad) {}

  void set(const float& rad) override {
    ESPOS_FLOW_ASSERT_TASK(this->id());
    this->emit(formulas::wrap_angle(rad, min_));
  }

 private:
  float min_;
};

// ─────────────────────────────────────────────────────────── TankLevel
//
// A sender reading to a Signal K ratio 0..1, linear and clamped.
//
// `empty` and `full` are the sender's readings at those states and may be in
// either order — a US resistive sender is 240 ohm empty and 33 ohm full, so
// `full < empty` is the common case.
//
// For a tank that is not a box (and most are not — they follow the hull),
// `Curve` is the right node instead: the relationship between sender angle and
// litres is whatever shape the hull is. TankLevel is the two-point
// approximation for a tank that really is prismatic, and the starting point
// before someone does the bucket work.
class TankLevel : public Symmetric<float> {
 public:
  TankLevel(const char* id, float empty, float full)
      : Symmetric<float>(id), empty_(empty), full_(full) {}

  void set(const float& reading) override {
    ESPOS_FLOW_ASSERT_TASK(this->id());
    this->emit(formulas::tank_level(reading, empty_, full_));
  }

  esp_err_t register_config(const char* title = "Tank calibration",
                            const char* unit = "ohm") {
    params_.add_float("empty", "Reading when empty", unit, empty_);
    params_.add_float("full", "Reading when full", unit, full_);
    esp_err_t err = params_.register_ns(this->id(), title, "Tank level");
    if (err != ESP_OK) return err;
    params_.load_float("empty", &empty_);
    params_.load_float("full", &full_);
    return ESP_OK;
  }

 private:
  float empty_;
  float full_;
  ParamSet<2> params_;
};

// ──────────────────────────────────────────────────────────── BatterySoc
//
// Resting terminal voltage to an estimated state of charge, 0..1.
//
// Read the caveat in espos_formulas/marine.hpp before wiring this: voltage SoC
// is only meaningful at rest, and for LiFePO4 it is marginal even then,
// because the discharge curve is deliberately flat. Publish it as an estimate,
// alarm on the ends where the curve is steep, and use a shunt if the number
// has to be trusted.
//
// It is here because "roughly how full is the bank" while the boat sits on a
// mooring is genuinely useful and a voltage divider can already answer it.
class BatterySoc : public Symmetric<float> {
 public:
  BatterySoc(
      const char* id,
      formulas::BatteryChemistry chem = formulas::BatteryChemistry::kLifepo4,
      float nominal_v = 12.0f)
      : Symmetric<float>(id), chem_(chem), nominal_v_(nominal_v) {}

  void set(const float& volts) override {
    ESPOS_FLOW_ASSERT_TASK(this->id());
    this->emit(formulas::battery_soc(volts, chem_, nominal_v_));
  }

  void set_chemistry(formulas::BatteryChemistry c) { chem_ = c; }

 private:
  formulas::BatteryChemistry chem_;
  float nominal_v_;
};

}  // namespace espos::flow