Automated hydroponic control system: design, programming and implementation

How a modular controller measures pH, TDS and climate, doses nutrients with a proportional loop, waits for the tank to mix, and protects the crop with safety blocks and alerts. With worked examples, sketches and Python code.

How does a greenhouse controller keep a nutrient solution at pH 6.0 and 1000 ppm for weeks without a person standing next to the tank? It measures the solution, compares it with the grower's profile, adds a small, calculated dose of fertilizer or acid, waits for the tank to mix, and measures again. That loop, plus climate control and a set of safety blocks, is an automated hydroponic control system. The design here follows the OverGrower specification and scales from one grow box up to a 0.5 ha greenhouse.

$$ \text{Profile} \;\rightarrow\; \underbrace{\text{Sensors}}_{\text{what is it now?}} \;\rightarrow\; \underbrace{\text{Controller}}_{\text{how much to add?}} \;\rightarrow\; \underbrace{\text{Pumps}}_{\text{dose}} \;\rightarrow\; \underbrace{\text{Pause}}_{\text{let it mix}} \;\rightarrow\; \text{Sensors again} $$

An analogy. Seasoning soup: you taste it (sensor), decide it needs a little salt (controller), add a pinch (pump), stir and wait (pause), then taste again. Nobody pours in the whole salt shaker after the first taste, and neither does the controller.

1. System architecture

Head unit, power unit, expansion bus with socket, climate, fertigation and sensor modules

The system is modular: one head unit runs the logic and talks to expansion modules over a bus, so a small setup and a large greenhouse use the same parts in different numbers.

Unit Role Key facts
Head unit Touch screen, growing profiles, control logic, data logging Hosts the web interface and the cloud connection
SOCKET-02OG Switches growing devices (lights, fans, heaters) Up to 1300 W per socket
CLIMATE-01OG Measures the air Temperature, humidity, illumination, CO₂
Fertigation unit Doses fertilizer and pH regulators Pumps A, B, C, pH up, pH down
Sensor array Measures the solution pH, TDS/EC, solution temperature, tank level
Power unit Supplies everything UPS backup rides through short outages

The system can be operated three ways: on the touch screen of the head unit, through a web interface from a computer or phone on the same network, and through a cloud service for logs, alerts and access from anywhere.

Operating modes

State diagram: WAITING, GROWING, SERVICE and SAFE modes with their transitions

Mode What runs Use it for
WAITING Nothing is dosed Setup, calibration, between crops
GROWING The growing profile runs automatically Normal operation
SERVICE Manual control; automation paused Cleaning, testing pumps, calibration
SAFE Affected pumps locked after a critical error Waiting for a person to fix the cause

Automatic growing only starts after the pre-start check-list passes: growth phase and day, fertilizer reserves, tank configuration, water volume and mixing settings.

2. Sensors: what the system measures

Parameter Range Resolution Placement
Acidity (pH) 3.00 – 8.00 0.01 In the working tank; calibrate weekly
TDS salinity (ppm) 0 – 3000 1 At least 1 cm from the tank walls
Solution temperature (°C) 0 – 40 0.1 Immersed in the solution
Air temperature (°C) 0 – 40 0.1 In the growing area
Relative humidity (%) 10 – 90 1 In the growing area
Illumination (lux) 0 – 50 000 1 At canopy level
CO₂ (ppm) 0 – 5000 1 In the growing area
Tank level Empty / Low / Medium / Full – Non-contact, fixed outside the tank

pH: a voltage that follows the Nernst equation

A glass pH electrode produces a small voltage that falls by a fixed amount for each pH unit. In the ideal case the slope is

$$ S = \frac{2.303\,R\,T}{F} \approx 0.1984\,(T + 273.15)\ \text{mV per pH}, \qquad S_{25\,^\circ\text{C}} = 59.16\ \text{mV/pH} $$

and the output is about 0 mV at pH 7. Real electrodes age: the slope shrinks and the zero point drifts, which is why pH is calibrated every week and the electrode is replaced about once a year. Store it under its cap in 3 M KCl; a dry electrode responds slowly and drifts.

TDS: conductivity with temperature compensation

A TDS sensor actually measures electrical conductivity (EC), which rises about 2 % per °C. The reading is first referred to 25 °C, then converted to ppm:

$$ EC_{25} = \frac{EC_T}{1 + \alpha\,(T - 25)}, \quad \alpha \approx 0.02, \qquad \text{TDS} = f \cdot EC_{25} $$

Worked example. At 20 °C the probe reads 1.80 mS/cm. Then \(EC_{25} = 1.80 / (1 - 0.10) = 2.00\) mS/cm. On the common 500 scale \((f = 500)\) that is 1000 ppm; on the 700 scale it would be 1400 ppm. Always check which factor your meter and your fertilizer chart use.

Two-point calibration

Electrode output versus pH with the LOW and HIGH calibration points and the fitted line

Both pH and TDS are calibrated by measuring two known solutions and fitting a straight line through them:

$$ \text{pH}(E) = \text{pH}_H + (E - E_H)\,\frac{\text{pH}_L - \text{pH}_H}{E_L - E_H} $$

Worked example. The probe reads 171 mV in pH 4.00 buffer and −3 mV in pH 7.00 buffer.

Quantity Calculation Value
Slope \((171 - (-3)) / (7 - 4)\) 58.0 mV/pH
Efficiency \(58.0 / 59.16\) 98 % (good)
Unknown sample at 50 mV \(7 + (50 + 3) \cdot \tfrac{4 - 7}{171 + 3}\) pH 6.09

A third buffer (pH 6.0) checks the result: if it reads more than about 0.05 off, repeat the calibration.

Step pH sensor (weekly) TDS sensor (monthly)
Mode WAITING or SERVICE WAITING or SERVICE
Clean Rinse in distilled water, soak 15 min in 3 M KCl, rinse Rinse in deionised water, wipe with 70 % isopropyl alcohol, dry
LOW point pH 4.00 buffer, wait 30–60 s, fix 500 ppm solution, wait 30 s, fix
HIGH point pH 7.00 buffer, wait, fix 2000 ppm solution, wait, fix
Store Coefficients to non-volatile memory Coefficients to non-volatile memory
Verify pH 6.0 buffer 1000 ppm solution (optional)

3. Regulation: dosing pH and TDS

Control loop: profile, summing junction, controller, pumps, tank, pause regulator, sensors

Fertigation has two loops. TDS is raised by proportional pumping of a multi-component fertilizer (pumps A, B and C in a fixed ratio). pH is moved by acid or alkali dosing. Both run in the same rhythm: dose, pause, read.

Proportional dosing

Let \(e = \text{TDS}_\text{target} - \text{TDS}_\text{measured}\) and let \(k\) be how many ppm one millilitre of fertilizer mix adds to this tank (measure it once: dose 20 mL into 100 L, see +40 ppm, so \(k = 2\) ppm/mL; \(k\) scales with \(1/V\)). The controller doses

$$ v = K_p\,\frac{e}{k}\ \text{mL}, \qquad t_\text{pump} = \frac{v_\text{component}}{q_\text{pump}} $$

with a gain \(K_p < 1\). Each cycle removes the fraction \(K_p\) of the remaining error, so after \(n\) cycles

$$ e_n = e_0\,(1 - K_p)^n, \qquad n \ge \frac{\ln(\text{tol}/e_0)}{\ln(1 - K_p)} $$

Why not \(K_p = 1\)? Because fertilizer can only be added, never taken out. If \(k\) is a little wrong, a full-size dose overshoots and the only fix is diluting the whole tank. A gain of about 0.8 approaches the target from below.

Worked example. 100 L tank at 800 ppm, target 1000 ± 5 ppm, \(k = 2\) ppm/mL, ratio A : B : C = 2 : 2 : 1, every pump calibrated to 1.2 mL/s.

Cycle Reading Error Dose \(0.8\,e/k\) Pump times A / B / C
1 800.0 200 80.0 mL 26.7 / 26.7 / 13.3 s
2 960.0 40 16.0 mL 5.3 / 5.3 / 2.7 s
3 992.0 8 3.2 mL 1.1 / 1.1 / 0.5 s
4 998.4 1.6 – within tolerance, stop

The formula agrees: \(n \ge \ln(5/200)/\ln(0.2) = 2.3\), so three doses.

TDS rising in three steps towards 1000 ppm, with pump pulses and pause periods

pH regulation

pH works the same way with two pumps: below target → alkali, above target → acid. The response is not linear, because nutrient solutions are buffered: the first millilitres of acid move pH little, later ones much more. Keep doses small (the controller can learn "pH per mL" from previous cycles) and always regulate pH after TDS, since fertilizer itself shifts pH.

Pause and stability

After a dose the solution needs time to mix. The pause regulator (30–600 s, user configurable) blocks new readings until then, and the reading is only trusted once it is stable: the standard deviation of the last few samples is below a threshold.

$$ \sigma = \sqrt{\frac{1}{N}\sum_{i=1}^{N} (x_i - \bar{x})^2} < \sigma_\text{max} $$

Worked example. Six pH samples, 10 s apart: 6.02, 5.98, 6.01, 6.00, 5.99, 6.00. Mean 6.000, squared deviations sum to 0.0010, so \(\sigma = \sqrt{0.0010/6} = 0.013\). That is below 0.05: stable, dosing may continue. Typical thresholds are 0.05 pH or 10 ppm over 60 s. Mixing itself is a duty cycle on the aerator or circulation pump, for example 15 min on, 5 min off.

Controller error

If the controller doses but the reading does not move beyond a timeout, something outside the software is wrong. The system raises a pH or TDS controller error, locks the pumps and waits for a manual restart. Typical causes:

Cause Check
Empty fertilizer or regulator reservoir Refill, re-prime the tubing
Kinked or air-locked tubing Straighten, re-prime with a syringe
Dead zones, too little mixing Increase the mixing duty cycle
Sensor too close to the dosing point Move it away from the inlet
Air bubbles or biofilm on the probe Clean and tap the probe
Temperature stratification Mix, check the solution heater

Pump calibration

Dosing is only as good as the flow rate \(q\). Run each pump (pH up, pH down, A, B, C) into a graduated cylinder for a fixed time, three times.

Worked example. 30 s runs give 36.2, 35.8 and 36.5 mL. Mean 36.17 mL, so \(q = 1.206\) mL/s; the largest deviation is 0.9 %, well inside ±5 %. A 10 mL dose then takes \(10 / 1.206 = 8.3\) s. Recalibrate after replacing tubing; if runs disagree by more than 5 %, look for air leaks, kinks or worn peristaltic rollers.

4. Tanks, irrigation and climate

One working tank versus a preparation tank plus working tank, with sensor and level placement

Aspect One working tank Two-tank system
Sensors All sensors in the working tank pH/TDS/temperature in the working tank, level in both
Level states Full, Medium, Low, Empty Working: Full/Low; preparation: Full/Medium/Low/Empty
Water supply Fill the working tank when Low Fill the preparation tank, transfer when needed
Solution preparation Mix directly in the working tank Pre-mix, transfer once stable (solution transfer function)
Trade-off Simpler, fewer pumps More hardware, no concentration swings at the roots

Irrigation modes

Mode Behaviour Typical use
Constant Solution flows non-stop NFT, deep water culture
Periodic On/off schedule, e.g. 15 min on / 45 min off Ebb and flow, drip, aeroponics
Blocking Stops the pump on tank Empty/Low or when pH/TDS leave the allowed range Protects pump and roots

Climate: light, humidity, power

The daily regime sets the photoperiod, e.g. 18/6 (18 h light, 6 h dark). A lux sensor does not measure photosynthetic light directly, but for a rough daily light integral (DLI) a conversion factor works:

$$ \text{DLI} = \text{PPFD} \cdot \frac{3600 \cdot h}{10^6}\ \ \text{mol·m}^{-2}\text{·d}^{-1}, \qquad \text{PPFD} \approx c \cdot \text{lux} $$

Example: 20 000 lux of sunlight-like light with \(c \approx 0.0185\) gives about 370 µmol·m⁻²·s⁻¹; over 18 h that is \(370 \cdot 64\,800 / 10^6 \approx 24\) mol·m⁻²·d⁻¹. The factor \(c\) depends on the light source, so calibrate it against a PAR meter for LEDs.

Humidity is easier to control as vapour pressure deficit (VPD), which is what drives transpiration:

$$ e_s(T) = 0.6108\,\exp\!\Big(\frac{17.27\,T}{T + 237.3}\Big)\ \text{kPa}, \qquad \text{VPD} = e_s(T)\,\Big(1 - \frac{RH}{100}\Big) $$

Example: 25 °C and 60 % RH: \(e_s = 3.17\) kPa, so VPD = 1.27 kPa, a comfortable value for most crops in vegetative growth.

Socket power. Each SOCKET-02OG socket carries at most 1300 W (≈ 5.7 A at 230 V). Two 600 W LED fixtures (1200 W) fit; three (1800 W) do not, so the socket drives the coil of an external contactor that switches the lights. Leave margin for LED and motor inrush.

Adding a module

  1. Connect the module to the head unit's expansion port.
  2. In Device Manager, add the new SOCKET-02OG.
  3. Assign it to a climate zone.
  4. Assign socket 1 → Lighting, socket 2 → Exhaust.
  5. Check every load is ≤ 1300 W; otherwise add a contactor.
  6. Test each function from the interface before starting growth.

Safety and alerts

Category Examples Severity Action
Controller errors pH, TDS or CO₂ controller error CRITICAL Pumps locked, SAFE mode, manual restart
Tank status Working tank empty, level sensor error CRITICAL Irrigation blocked, refill now
Sensor maintenance Cleaning due, calibration drift WARNING Schedule maintenance
Fertilizer levels Component A low, pH regulator empty WARNING Refill, dosing may fail
System status Power on UPS, network lost, phase complete INFO Log only

Critical errors show a red icon, sound the (configurable) alarm and send an e-mail with timestamp, device ID, description, current readings and recommended action. E-mail needs an internet connection and SMTP settings (host, port 465 for SSL or 587 for STARTTLS, credentials, sender and recipients); send a test message and whitelist the sender before relying on it.

5. Code: sensor calibration and temperature compensation

Python
from dataclasses import dataclass

@dataclass
class TwoPointCal:
    """Straight line through two reference points: raw -> value."""
    raw_low: float
    ref_low: float
    raw_high: float
    ref_high: float

    @property
    def slope(self):
        return (self.ref_high - self.ref_low) / (self.raw_high - self.raw_low)

    def __call__(self, raw):
        return self.ref_low + (raw - self.raw_low) * self.slope


def ph_efficiency(cal, temp_c=25.0):
    """Electrode slope as a fraction of the ideal Nernst slope."""
    ideal = 0.1984 * (temp_c + 273.15)          # mV per pH
    return abs(1 / cal.slope) / ideal


def tds_ppm(ec_ms_cm, temp_c, alpha=0.02, factor=500):
    """Refer EC to 25 °C, then convert to ppm (500 or 700 scale)."""
    ec25 = ec_ms_cm / (1 + alpha * (temp_c - 25))
    return factor * ec25


ph = TwoPointCal(raw_low=171, ref_low=4.00, raw_high=-3, ref_high=7.00)
print(f"pH at 50 mV: {ph(50):.2f}, efficiency {ph_efficiency(ph):.0%}")  # 6.09, 98%
print(f"TDS: {tds_ppm(1.80, 20):.0f} ppm")                               # 1000 ppm

6. Code: proportional dosing with a controller-error check

Python
from dataclasses import dataclass, field

@dataclass
class TDSController:
    target: float
    tolerance: float = 5.0
    gain: float = 0.8
    ppm_per_ml: float = 2.0                     # k, measured for this tank
    ratio: dict = field(default_factory=lambda: {"A": 2, "B": 2, "C": 1})
    min_change: float = 3.0                     # a dose must move the reading this much
    max_dry_doses: int = 3
    dry_doses: int = 0
    blocked: bool = False

    def step(self, reading, last_reading=None, last_dose_ml=0.0):
        """Return {component: mL} to dose now; empty dict means do nothing."""
        if self.blocked:
            return {}
        if last_dose_ml > 0 and last_reading is not None:
            if abs(reading - last_reading) < self.min_change:
                self.dry_doses += 1
                if self.dry_doses >= self.max_dry_doses:
                    self.blocked = True         # controller error: manual restart
                    return {}
            else:
                self.dry_doses = 0
        error = self.target - reading
        if error <= self.tolerance:             # fertilizer can only raise TDS
            return {}
        total = self.gain * error / self.ppm_per_ml
        parts = sum(self.ratio.values())
        return {k: total * v / parts for k, v in self.ratio.items()}


# Simulated 100 L tank, pumps at 1.2 mL/s
tds, ctl, flow = 800.0, TDSController(target=1000), 1.2
last, dosed = None, 0.0
while dose := ctl.step(tds, last, dosed):
    dosed = sum(dose.values())
    print(f"TDS {tds:6.1f} -> {dosed:5.1f} mL, A {dose['A'] / flow:4.1f} s")
    last, tds = tds, tds + dosed * ctl.ppm_per_ml   # on hardware: run pumps, then pause
print(f"TDS {tds:6.1f} -> done")

The full simulation, with a tank model, is in dosing_sim.py.

To simulate an empty reservoir, replace dosed * ctl.ppm_per_ml with 0 in the last line of the loop: after three doses that change nothing the loop stops with ctl.blocked == True, the controller error.

7. Code: stability check and e-mail alerts

Python
import smtplib
import statistics
from email.message import EmailMessage

def is_stable(samples, max_std):
    """True when the last samples vary less than max_std (population std dev)."""
    return len(samples) >= 3 and statistics.pstdev(samples) < max_std

print(is_stable([6.02, 5.98, 6.01, 6.00, 5.99, 6.00], 0.05))   # True (σ = 0.013)

def send_alert(cfg, severity, text, readings):
    msg = EmailMessage()
    msg["Subject"] = f"[{severity}] {cfg['device_id']}: {text}"
    msg["From"], msg["To"] = cfg["sender"], ", ".join(cfg["recipients"])
    body = [text, ""] + [f"{k}: {v}" for k, v in readings.items()]
    msg.set_content("\n".join(body))
    with smtplib.SMTP(cfg["host"], 587, timeout=10) as s:   # 587 = STARTTLS
        s.starttls()
        s.login(cfg["user"], cfg["password"])
        s.send_message(msg)

# send_alert(cfg, "CRITICAL", "TDS controller error: pumps locked",
#            {"TDS": "812 ppm", "pH": 6.1, "Tank": "Low"})

Common problems and fixes

Problem Fix
pH drifts between calibrations Clean the probe, store it wet in 3 M KCl, check efficiency; replace below ~85 %
TDS overshoots the target Lower the gain, re-measure \(k\), lengthen the pause
Readings jump after every dose Move sensors away from the dosing inlet, increase mixing
Controller error with full reservoirs Re-prime tubing, check for kinks and worn pump rollers
Wrong ppm compared with another meter Check the conversion factor (500 vs 700) and temperature compensation
Lights trip the socket Load above 1300 W or high inrush: use a contactor
No alert e-mails Test SMTP settings, whitelist the sender, check the internet connection

Glossary

Term Meaning
Access point mode The controller creates its own Wi-Fi network for direct connection
Check-list Pre-start verification of all critical settings
Climate zone An area with its own growing profile and assigned devices
Cyclical injection Scheduled dosing of biostimulants or additives at set volumes and intervals
Daily regime Photoperiod in the profile, e.g. 18/6
Device Manager Menu for adding, removing and configuring modules
Fertigation unit Subsystem that mixes fertilizer and regulates pH
Growing controls Main menu for solution and climate zones and the growing process
Growing profile Block program of phase-specific targets (pH, TDS, fertilizers, daily regime)
Service mode Manual override that pauses automation
Smooth change Gradual transition of targets between growth phases to reduce plant stress
Solution zone Settings for the solution, tank system and fertigation

Quick review

  1. What scale does the system cover? From a single setup up to a 0.5 ha greenhouse.
  2. What must a growing profile define? Solution zone: pH, TDS and fertilizers. Climate zone: the daily regime.
  3. When is solution transfer available? Only in the two-tank configuration.
  4. What triggers a controller error? Dosing happened but the reading did not change within the timeout; pumps stay locked until a manual restart.
  5. How is the pH electrode stored? Under its cap in 3 M KCl, never dry; replace it about yearly.
  6. What are the three ways to operate the system? Touch screen, local web interface, cloud service.

Further reading

Try it yourself

  1. Calibrate a pH probe with two buffers, compute its slope and efficiency, and check it with a third buffer.
  2. Run the simulation in section 6 with gains 0.5, 0.8 and 1.2. How many cycles does each need, and which ones overshoot when the \(k\) you entered is 20 % smaller than the real one?
  3. Log pH every 10 s after a dose and find the shortest pause after which is_stable returns True.
  4. Compute VPD for your grow room at day and night temperatures and decide which humidity targets keep it between 0.8 and 1.2 kPa.

Basheer Al-Tawil is a PhD researcher in Robotics and AI at Otto von Guericke University. Found a mistake or have a question? Write to me.

Browse by topic