Code for DFRobot EC Sensor with integrated Temperature probe

Glad you got it sorted. Sorry mate - was mentally taxing to troubleshoot without being able to test it for you!

Salinity Monitoring — Summary of Development and Findings

I designed and built a custom inline conductivity-based salinity monitoring system for a ~2,000L recirculating marine reef aquarium. The goal was to use continuous salinity measurement as the primary control input for an automated RODI top-off (ATO) system.

Hardware

  • Laboratory-grade two-electrode conductivity probe
  • PT1000 temperature sensor read via ADS1115 ADC on an ESP32 microcontroller
  • Filtering capacitors added after oscilloscope analysis to eliminate electrical noise
  • Single-point calibration system with slope correction stored in flash, with timestamp logging
  • ESPHome firmware with Modbus output to Home Assistant via Node-RED

Signal processing

  • Median filtering on raw ADC readings
  • Non-linear polynomial conversion from conductivity to salinity (PSS-78 approximation)
  • Temperature compensation referenced at 27°C (deliberately offset from standard 15°C to minimise compensation magnitude at operating point)
  • Rolling linear regression in Node-RED to auto-tune the temperature coefficient from live data
  • EMA smoothing on corrected salinity output
  • Multiple temperature probe averaging to reduce thermal noise

Findings
The hardware performed well — ADC readings were clean and the calibration system was robust. The fundamental problem proved to be the conversion from conductivity to salinity, not the measurement itself.

At 53,000µS/cm, the temperature sensitivity of conductivity is approximately 2% per °C. A temperature measurement error of 0.1°C introduces ~100µS error, which translates to a salinity error sufficient to displace several litres of RODI on a 2,000L system. This error margin exceeds the control tolerance required for reliable closed-loop ATO operation regardless of compensation method or filter sophistication.

The rolling regression approach — designed to auto-tune the temperature coefficient from live data — failed in practice because the system is thermally too stable. With 2,000L of thermal mass, temperature variation was insufficient to allow meaningful regression, and the EMA smoothing then dominated over the true measurement signal. The result was a stable but inaccurate reading, with true salinity drifting up to 2ppt from target while the sensor reported stability.

Conclusion
Conductivity-based salinity measurement is fundamentally unsuitable as a closed-loop ATO control input at reef salinity and high thermal stability. The limitation is physical, not implementation-based. Optical refractometry — specifically measuring refractive index — was identified as the correct approach due to its significantly lower temperature sensitivity at the operating point.

Fable 5 took a look:

What I changed and why:

  1. Removed the double temperature compensation — this was the big one. Your EC value was already compensated to the reference temperature, but the salinity lambda then subtracted another 1.0 × (temp − 27) ppt on top. That injected roughly 1 ppt of error per degree the tank sits away from 27°C.
  2. Replaced the linear × 0.00066 conversion with the PSS-78 practical salinity equation — the oceanographic standard for computing salinity from conductivity. The linear factor is only correct at exactly 35 ppt and drifts ~0.5 ppt at the edges of the reef range; PSS-78 is accurate everywhere. Sanity check: 53,000 µS/cm at 25°C now reads 34.96 ppt, which is genuinely correct (standard 35 ppt seawater is 53,087 µS/cm at 25°C).
  3. Replaced the linear 1.9%/°C temperature compensation with PSS-78's own seawater rt(T) function, normalising to 25°C instead of 27°C. This matters because your 53,000 µS/cm calibration target is defined at 25°C — compensating to 27°C while calibrating against a 25°C value baked in a small systematic error. The actual seawater coefficient near 25°C is ~1.97%/°C, and rt(T) tracks its curvature exactly. The calibration button uses the same function, so everything stays self-consistent.
  4. Heavier filtering — raw ADC medians widened from 3 to 7 samples, plus a sliding-window average on EC (12 samples, ~1 minute of smoothing) and on temperature. Salinity changes slowly in a tank, so there's no downside to smoothing hard, and it takes real noise off the reading.

Two things to do after flashing:

  • Recalibrate. Your stored slope persists across OTA updates (restore_value: true), and it was solved against the old formula. Put the probe in the 53.0 mS/cm / 35 ppt standard, wait a good minute for the filters to settle, then press the calibrate button.
  • Check your temperature calibration against a trusted thermometer. Temperature error flows straight into salinity through the compensation — 1°C off ≈ 0.7 ppt off. Your PT1000 fit (slope −7.65) is unusual enough that verifying it at tank temperature is worth five minutes.

One thing to be aware of: if that k × (temp − 27) term was added to force agreement with your refractometer, readings will shift once it's removed. If a small constant discrepancy remains after recalibrating, trim it with the existing EC Calibration Offset entity rather than re-adding a temperature term.

Beyond the config, the remaining accuracy levers are hardware: keep the probe electrodes clean (algae film reads low), keep it away from bubbles and pump outlets, and if readings jump when pumps or heaters switch, that's electrical interference/ground-loop noise rather than a calibration problem.

A few thoughts on the findings themselves:

Your error budget checks out. 0.1°C × ~2%/°C at 53,000 µS/cm ≈ 100 µS ≈ 0.066 ppt, and holding 35 ppt on 2,000 L means that error commands roughly 3.8 L of RODI. Best-case absolute accuracy for a calibrated PT1000 chain is on the order of ±0.1°C, so the noise floor genuinely sits above your control tolerance. The rolling-regression failure is a textbook identifiability problem, well diagnosed: you can't estimate a temperature coefficient without temperature excitation, and a 2,000 L thermal mass is specifically designed not to provide any.

One caveat worth carrying into the refractometer build: the temperature advantage of refractive index is real but more modest than it's often assumed to be. For seawater near 25°C, dn/dT ≈ −1.0×10⁻⁴/°C against dn/dS ≈ +1.9×10⁻⁴/ppt — so a temperature error still shows up at roughly 0.5 ppt/°C equivalent, versus ~0.7 ppt/°C for conductivity. That's maybe a 30% improvement, not an order of magnitude. Where optical genuinely wins is elsewhere: no electrode polarization or fouling drift, no cell-constant drift, and in a critical-angle design the sample film is in tight thermal equilibrium with the prism, so the temperature sensor can be co-located almost perfectly with the measurement — which is the thing the conductivity setup could never quite achieve. Tight, co-located temperature measurement will still be the make-or-break of the optical build.

One structural observation, take or leave: since evaporation removes only water, holding volume constant holds salinity constant — which is why most closed-loop ATO designs control on water level (optical/pressure sensor in the sump) and demote salinity to a slow supervisory alarm for drift from salt creep and water changes. If the refractometer proves harder than expected, that decomposition sidesteps the precision problem entirely rather than solving it.

time:
  - platform: homeassistant
    id: homeassistant_time

i2c:
  sda: GPIO41
  scl: GPIO42
  scan: true
  frequency: 400kHz

# ADS1115 configuration
ads1115:
  - address: 0x48
    id: ads1115_hub

# Persistent storage for calibration parameters - MUST be defined before sensors
number:
  - platform: template
    name: "EC Calibration Slope"
    id: ec_slope
    entity_category: config
    min_value: 0
    max_value: 30
    initial_value: 24.5772
    step: 0.0001
    optimistic: true
    restore_value: true

  - platform: template
    name: "EC Calibration Offset"
    id: ec_offset
    entity_category: config
    min_value: -30.0
    max_value: 30.0
    initial_value: 0.0
    step: 0.1
    optimistic: true
    restore_value: true

  - platform: template
    name: "Last Calibration Timestamp"
    id: last_calibration_timestamp
    entity_category: config
    min_value: 0
    max_value: 2147483647
    initial_value: 1758376812
    step: 1
    optimistic: true
    restore_value: true
    internal: true

  - platform: template
    name: "Temperature Calibration Offset"
    id: temp_offset
    entity_category: config
    min_value: -20.0
    max_value: 20.0
    initial_value: 4.6
    step: 0.01
    optimistic: true
    restore_value: true

  - platform: template
    name: "Temperature Calibration Slope"
    id: temp_slope
    entity_category: config
    min_value: -10
    max_value: 10
    initial_value: -7.65
    step: 0.0001
    optimistic: true
    restore_value: true

sensor:
  - platform: ads1115
    ads1115_id: ads1115_hub
    multiplexer: 'A0_GND'
    gain: 1.024
    name: "Temperature Voltage"
    id: pt1000_voltage
    update_interval: 2s
    accuracy_decimals: 4
    internal: true
    filters:
      - median:
          window_size: 7
          send_every: 1

  - platform: template
    id: pt1000_temperature
    name: "Water Temperature"
    unit_of_measurement: "°C"
    accuracy_decimals: 2
    update_interval: 5s
    filters:
      - sliding_window_moving_average:
          window_size: 6
          send_every: 1
      - lambda: |-
          return round(x * 100.0) / 100.0;
    lambda: |-
      float voltage = id(pt1000_voltage).state;
      if (isnan(voltage)) return {};
      float rpt1000 = (voltage / 16.67 + 0.223) / 0.000124 / 2.0;
      float temp_raw = (rpt1000 - 1000.0) / 3.85;
      float temp = (temp_raw * id(temp_slope).state) + id(temp_offset).state;
      return temp;

  - platform: ads1115
    ads1115_id: ads1115_hub
    multiplexer: 'A1_GND'
    gain: 4.096
    name: "EC Voltage"
    id: ec_voltage
    update_interval: 2s
    unit_of_measurement: V
    accuracy_decimals: 4
    internal: true
    filters:
      - median:
          window_size: 7
          send_every: 1

  - platform: template
    name: "Electrical Conductivity"
    id: ec
    unit_of_measurement: "µS/cm"
    device_class: "conductivity"
    state_class: "measurement"
    accuracy_decimals: 0
    update_interval: 5s
    filters:
      - sliding_window_moving_average:
          window_size: 12
          send_every: 3
    lambda: |-
      float measured = id(ec_voltage).state;         // V_adc (after divider)
      float temperature = id(pt1000_temperature).state;
      if (isnan(measured) || isnan(temperature)) return {};
      float voltage = (measured / 0.6f) * 1000.0f;   // true probe voltage in mV

      // Raw EC from probe voltage at the current water temperature
      float ec_raw = 100000.0f * voltage / 820.0f / 200.0f * id(ec_slope).state;

      // PSS-78 seawater temperature function rt(T): conductivity ratio of
      // standard seawater at T relative to 15 °C. Using the ratio
      // rt(25)/rt(T) normalises the reading to 25 °C — the temperature the
      // 53000 µS/cm (35 ppt) calibration standard is specified at — and is
      // accurate across the whole reef range, unlike a linear %/°C factor.
      auto rt = [](float t) -> float {
        return 0.6766097f + t * (2.00564e-2f + t * (1.104259e-4f
               + t * (-6.9698e-7f + t * 1.0031e-9f)));
      };
      float ec25 = ec_raw * rt(25.0f) / rt(temperature);
      ec25 += id(ec_offset).state;
      return ec25;

  - platform: template
    id: salinity
    name: "Salinity"
    unit_of_measurement: "ppt"
    accuracy_decimals: 2
    update_interval: 20s
    lambda: |-
      // PSS-78 practical salinity from conductivity at 25 °C.
      // id(ec) is already temperature-compensated to 25 °C, so no further
      // temperature correction belongs here.
      float ec25 = id(ec).state;
      if (isnan(ec25) || ec25 <= 100.0f) return {};

      // Conductivity ratio: C(35,15,0) = 42914 µS/cm, scaled to 25 °C by rt(25)
      auto rt = [](float t) -> float {
        return 0.6766097f + t * (2.00564e-2f + t * (1.104259e-4f
               + t * (-6.9698e-7f + t * 1.0031e-9f)));
      };
      float Rt = ec25 / (42914.0f * rt(25.0f));
      float sr = sqrtf(Rt);

      float S = 0.0080f
              + sr * -0.1692f
              + Rt * 25.3851f
              + Rt * sr * 14.0941f
              + Rt * Rt * -7.0261f
              + Rt * Rt * sr * 2.7081f;

      // PSS-78 temperature correction term, evaluated at the 25 °C reference
      const float T = 25.0f;
      float dS = ((T - 15.0f) / (1.0f + 0.0162f * (T - 15.0f)))
               * (0.0005f
                  + sr * -0.0056f
                  + Rt * -0.0066f
                  + Rt * sr * -0.0375f
                  + Rt * Rt * 0.0636f
                  + Rt * Rt * sr * -0.0144f);

      return S + dS;

  - platform: template
    name: "Days Since Last Calibration"
    id: days_since_calibration
    unit_of_measurement: "days"
    device_class: "duration"
    state_class: "measurement"
    entity_category: diagnostic
    accuracy_decimals: 1
    update_interval: 1h
    lambda: |-
      uint32_t last_cal_timestamp = (uint32_t)id(last_calibration_timestamp).state;

      // If never calibrated, return a large number to indicate this
      if (last_cal_timestamp == 0) {
        return 999.9;  // Indicates "never calibrated"
      }

      // Get current time from Home Assistant
      auto time = id(homeassistant_time).now();
      if (!time.is_valid()) {
        ESP_LOGW("calibration", "Time not available from Home Assistant");
        return {};  // Return empty optional if time not available
      }

      uint32_t current_timestamp = time.timestamp;

      // Calculate time difference in seconds
      uint32_t time_diff = current_timestamp - last_cal_timestamp;

      // Convert to days (86400 seconds per day)
      float days = time_diff / 86400.0;

      return days;

button:
  - platform: template
    name: "Calibrate EC to 53000 µS/cm"
    id: calibrate_ec_button
    entity_category: diagnostic
    on_press:
      - lambda: |-
          // Read divided ADC voltage (median-filtered). Let the probe sit in
          // the 35 ppt / 53.0 mS/cm standard for at least a minute before
          // pressing so the filter window is fully settled.
          float measured = id(ec_voltage).state;             // V_adc
          // Undo divider and convert to mV (true probe voltage)
          float current_voltage = (measured / 0.6) * 1000.0; // V_probe in mV

          float current_temp = id(pt1000_temperature).state;

          if (isnan(current_temp)) {
            current_temp = 25.0;
          }

          // Conductivity of the 35 ppt seawater standard at 25 °C
          const float target_ec = 53000.0;

          if (current_voltage > 0) {
            float current_offset = id(ec_offset).state;

            auto rt = [](float t) -> float {
              return 0.6766097f + t * (2.00564e-2f + t * (1.104259e-4f
                     + t * (-6.9698e-7f + t * 1.0031e-9f)));
            };

            // ec25 = voltage_factor * slope * rt(25)/rt(T) + offset
            // Solving for slope:
            float voltage_factor = 100000.0 * current_voltage / 820.0 / 200.0;
            float new_slope = (target_ec - current_offset) * rt(current_temp)
                              / (voltage_factor * rt(25.0f));

            if (new_slope > 0.1 && new_slope < 100.0) {
              auto call = id(ec_slope).make_call();
              call.set_value(new_slope);
              call.perform();

              // Record calibration timestamp
              auto time = id(homeassistant_time).now();
              if (time.is_valid()) {
                auto timestamp_call = id(last_calibration_timestamp).make_call();
                timestamp_call.set_value((float)time.timestamp);
                timestamp_call.perform();

                ESP_LOGI("calibration", "Calibration timestamp saved: %u", time.timestamp);
              } else {
                ESP_LOGW("calibration", "Could not get current time for calibration timestamp");
              }

              id(days_since_calibration).update();

              ESP_LOGI("calibration", "EC Calibration completed!");
              ESP_LOGI("calibration", "Raw Voltage: %.2f mV, Temperature: %.2f°C", current_voltage, current_temp);
              ESP_LOGI("calibration", "New cal_slope: %.3f (old: %.3f)", new_slope, id(ec_slope).state);
              ESP_LOGI("calibration", "Voltage factor: %.2f", voltage_factor);
              ESP_LOGI("calibration", "Target EC: %.0f µS/cm", target_ec);
            } else {
              ESP_LOGE("calibration", "Calculated slope out of bounds: %.3f", new_slope);
              ESP_LOGE("calibration", "Raw voltage: %.2f mV, check sensor readings", current_voltage);
            }
          } else {
            ESP_LOGE("calibration", "Invalid raw voltage reading: %.2f mV", current_voltage);
          }

  - platform: restart
    name: "Salinity Controller Restart"
    icon: "mdi:restart"
    entity_category: diagnostic