A pot's sensor voltage may fall during dry-back, jump when you water it, and keep changing while water drains and redistributes. One straight line through that whole file can describe none of those processes. This experiment records a fixed Vegetronix VH400-2M probe, marks the changes in experimental conditions, and estimates a voltage trend only inside a qualified interval.
The target is an original Arduino UNO Rev3 with an ATmega328P in its 28-pin plastic dual-inline package (PDIP), a 16 MHz ceramic resonator and Arduino AVR Boards 1.8.6. AVR names the microcontroller family. A finite board program records raw analog-to-digital converter (ADC) readings; a separate Python 3 program analyzes the saved file on a computer. Neither program turns on a pump or interprets an arbitrary number as a universal soil-moisture percentage.
This package contains document checks, host execution of the actual sketch through adapters, and actual Python behavioral tests. It has not been built with the AVR target compiler, run on a UNO, installed in a pot or physically calibrated here. Constructed graph values are not measurements.
A slope is a change divided by elapsed time. A decreasing voltage trend can be useful for comparing this pot under recorded conditions, but it is not automatically the plant's water-use rate. Evaporation, root uptake, drainage, temperature, probe contact and the voltage reference may all affect the record. Keep independent pot-mass and watering notes alongside the voltage.
The lesson's units are volts (V), millivolts (mV), milliseconds (ms), seconds (s), minutes, hours (h), ohms (Ω), kilohms (kΩ), nanofarads (nF), milliamperes (mA) and grams (g). The fit reports ADC counts per hour and a millivolt-per-hour scale under one assumed constant reference. It does not report litres per hour, a moisture percentage or an irrigation threshold.
Use the VH400-2M, the two-metre cable version. The manufacturer's wiring is important: red is POWER, bare is ground, and black is the analog OUT wire. Black is not ground in this cable. The current product table specifies a 3.5–20 V supply, output related to moisture from 0–3 V, 10 kΩ output impedance, less than 13 mA consumption and 400 ms from power-on to stable output. This circuit uses the UNO's measured 5 V rail, not its 3.3 V pin.

| Connection | Purpose |
|---|---|
| UNO 5V header → VH400 red POWER | Continuous probe supply from the board's Universal Serial Bus (USB) power input. |
| UNO GND → VH400 bare wire | Common return for supply and output measurement. |
| VH400 black OUT → R1 1 kΩ → A0 | Series sense path. A0 is PC0 / ADC0, pin 23 of the stated PDIP chip. |
| C1 100 nF: A0 → GND | Local reservoir/filter at the ADC input. |
| C2 100 nF: board-side red-power junction → GND | Local bypass at the accessible cable junction; it is not a claim about hidden probe electronics. |
| AREF header: externally unconnected | DEFAULT selects the board's analog supply, AVCC, as the reference. Do not attach another reference source. |
R1 is Vishay MRS25000C1001FCT00, 1 kΩ ±1%. C1/C2 are nonpolar X7R ceramic capacitors rated at least 10 V; X7R identifies the dielectric class. Power through USB alone, and qualify the loaded rail in the stated 16 MHz operating setup. The analysis accepts a chosen 4.5–5.25 V reference-scale range, not a guarantee that every USB source meets it. Keep the board and connections away from runoff. No external 12/24 V supply or output is connected to A0.
The 10 kΩ sensor output plus R1 gives an approximately 11 kΩ nominal source path. With C1, its simple resistance-capacitance (RC) time constant is approximately 11 kΩ × 100 nF = 1.1 ms. That slightly exceeds the ADC's usual approximately 10 kΩ source guidance. The reservoir, fixed A0 channel, extra conversion discard and 10 ms wait help acquisition; they do not establish absolute settling accuracy for the cable, sensor and board.
The sketch leaves the sensor continuously powered, waits one second at startup, and then performs an extra discarded conversion and filter wait. Each recorded block contains eight readings with minimum, maximum and sum preserved. Averaging reduces some varying noise but does not convert a 10-bit ADC into a more accurate instrument. The first conversion after ADC enable differs from subsequent conversions; an unqualified reference or source cannot be fixed by taking more samples.
For a displayed voltage, the host uses the ideal code-bin scale mean_code × reference_mV / 1024. The default 5000 mV is explicitly assumed. Record the actual reference under load if substituting a measured value. The VH400 output is not a promise of ratiometric tracking with AVCC: reference drift can imitate a sensor trend. End codes 0 or 1023 are retained but end rate qualification because they are ambiguous at the converter limits; they do not diagnose a dry pot or a broken wire.
The current non-automotive ATmega328/P errata were checked. The listed 32 kHz oscillator, two-wire-interface and comparator issues concern functions this sketch does not use; that is not a claim that every silicon revision is identical. Leave the original board's fuses and clock configuration unchanged, record the actual hardware identity, and verify target compilation and memory use before upload. No fabricated flash or memory-size result is supplied.
The stream starts in HOLD. In the serial console at 115200 baud, send one lowercase command: w before watering, d for drainage/redistribution, s to start a new operator-qualified stable interval, h to hold, or x to finish. Carriage return, newline and spaces are ignored. An unknown command or excessive command burst ends the run with a fault record.
A “stable” interval means the experimental conditions are being held suitable for a trend comparison; it does not mean the voltage must be flat. Decide when drainage and placement are acceptable from the actual pot and independent observations. Do not replace that decision with an invented universal waiting time. Mark before a pour: a serial command is timestamped when processed, not when water physically reaches the roots.
There are 120 acquisition attempts, one every nominal 60 seconds, so the finite run spans about 119 minutes from the first to the last attempt. It does not take 120 samples inside one minute. A late loop takes one current block, without compressing missed measurements into a burst. A gap above 90 seconds returns to HOLD. A command processed during an ADC block suppresses that block and returns to HOLD as well; send a fresh s when ready.
Place uno-soil-dryback-intervals.ino in an identically named sketch folder, select the original UNO and the stated core version, then build and inspect the actual compiler/memory report. Opening the serial console can reset a UNO. Begin capture at its BOOT1 record; a reset starts a separate session rather than continuing the old slope.
// VH400-2M: red->UNO5V, bare->GND, BLACK OUT->1k->A0.
// A0 has100nF toGND; DEFAULT AVCC reference; external AREF unconnected.
// Original UNO Rev3 / ATmega328P, Arduino AVR Boards1.8.6. No actuator.
#include <Arduino.h>
#if F_CPU != 16000000UL
#error This experiment requires the stated 16 MHz UNO configuration
#endif
const uint32_t INTERVAL_MS = 60000UL;
const uint16_t MAX_ATTEMPTS = 120;
enum Mode { HOLD, WATER, DRAIN, STABLE };
Mode mode = HOLD;
uint32_t sequence = 0, segment = 0, last_sample = 0;
uint16_t attempts = 0;
bool finished = false;
// Explicit declarations avoid depending on sketch auto-prototypes for Mode.
const char *mode_name();
void emit(const char *kind, uint32_t tick, uint16_t sum = 0,
uint16_t minimum = 0, uint16_t maximum = 0, uint32_t span = 0);
void mark(Mode next, const char *kind = "MARK");
bool commands();
const char *mode_name() {
switch (mode) {
case WATER: return "WATER";
case DRAIN: return "DRAIN";
case STABLE: return "STABLE";
default: return "HOLD";
}
}
// Nine CSV fields; sequence counts every emitted record, not only samples.
void emit(const char *kind, uint32_t tick, uint16_t sum,
uint16_t minimum, uint16_t maximum, uint32_t span) {
Serial.print(kind); Serial.print(',');
Serial.print(sequence++); Serial.print(',');
Serial.print(tick); Serial.print(',');
Serial.print(segment); Serial.print(',');
Serial.print(mode_name()); Serial.print(',');
Serial.print(sum); Serial.print(','); Serial.print(minimum); Serial.print(',');
Serial.print(maximum); Serial.print(','); Serial.println(span);
}
void mark(Mode next, const char *kind) {
++segment; mode = next; emit(kind, millis());
}
// Commands are processing-time observations. Send w BEFORE pouring water.
bool commands() {
bool changed = false;
for (uint8_t n = 0; n < 32 && Serial.available(); ++n) {
const char c = char(Serial.read());
if (c == '\r' || c == '\n' || c == ' ') continue;
changed = true;
if (c == 'w') mark(WATER);
else if (c == 'd') mark(DRAIN);
else if (c == 's') mark(STABLE);
else if (c == 'h') mark(HOLD);
else if (c == 'x') { emit("DONE", millis()); finished = true; return true; }
else { mark(HOLD, "FAULT"); finished = true; return true; }
}
if (Serial.available()) { mark(HOLD, "FAULT"); finished = true; changed = true; }
return changed;
}
void setup() {
Serial.begin(115200);
pinMode(A0, INPUT); digitalWrite(A0, LOW); // no pull-up
DIDR0 |= _BV(ADC0D); // disable A0 digital receiver
analogReference(DEFAULT);
delay(1000); // sensor continuously powered
(void)analogRead(A0); delay(10); // discard and allow external RC wait
emit("BOOT1", millis());
last_sample = millis() - INTERVAL_MS; // first attempt immediately, HOLD
}
void loop() {
if (finished) return;
commands();
if (finished) return;
const uint32_t now = millis();
const uint32_t elapsed = uint32_t(now - last_sample);
if (elapsed < INTERVAL_MS) return;
if (elapsed > 90000UL) mark(HOLD, "GAP");
last_sample = now; // no catch-up burst
++attempts;
const uint32_t begin = millis();
(void)analogRead(A0); delay(10);
uint16_t sum = 0, minimum = 1023, maximum = 0;
bool bad = false;
for (uint8_t n = 0; n < 8; ++n) {
const int value = analogRead(A0);
if (value < 0 || value > 1023) bad = true;
else {
sum += uint16_t(value);
if (value < minimum) minimum = uint16_t(value);
if (value > maximum) maximum = uint16_t(value);
}
delay(2);
}
const uint32_t end = millis();
if (bad) { mark(HOLD, "FAULT"); finished = true; return; }
const bool changed = commands();
if (finished) return;
if (changed || uint32_t(end - begin) > 1000UL) {
mark(HOLD, "GAP"); emit("DROP", millis());
} else emit("SAMPLE", begin, sum, minimum, maximum, uint32_t(end - begin));
if (attempts >= MAX_ATTEMPTS) { emit("DONE", millis()); finished = true; }
}
Each comma-separated-values (CSV) line contains these nine fields:
kind, sequence, tick_ms, segment, state, sum8, minimum, maximum, span_ms
sequence counts all emitted records. A sample's tick_ms is the start of its acquisition block; span_ms is the observed block duration. sum8/8 preserves a fractional mean without pretending it is an extra hardware bit. Other record kinds carry zero sample payload. There is no hidden temperature or sensor timestamp, no real-time clock and no Coordinated Universal Time (UTC) value.
millis() is a board-uptime counter based on Timer0 interrupts. The analysis uses the actual reported differences, not sample number times 60 seconds. Its oscillator is not calibrated here. An entirely unobserved 32-bit wrap can alias the elapsed count, and blocked interrupts can distort it. The approximately two-hour finite procedure and timely servicing remain assumptions; analogRead() can itself wait indefinitely, so the sample limit is not a hardware watchdog.
Download soil-dryback-analysis.py beside the saved capture on your computer.
Save the unedited American Standard Code for Information Interchange (ASCII) records as capture.csv. Do not add the explanatory field-name line or serial-console prompts. Run python3 soil-dryback-analysis.py capture.csv --reference-mv 5000 > report.json. Replace 5000 only with a documented constant reference assumption or measurement appropriate to that record. The analyzer reads the input and writes its report to standard output; it does not overwrite the capture.
"""Host Python3 analysis of the exact nine-field UNO BOOT1 protocol.
No moisture percentage, UTC, sensor calibration or irrigation decision.
"""
import argparse
import csv
import json
import math
import re
import sys
STATES = {"HOLD", "WATER", "DRAIN", "STABLE"}
KINDS = {"BOOT1", "MARK", "GAP", "SAMPLE", "DROP", "DONE", "FAULT"}
def finite(value, name, low, high):
if type(value) not in (int, float):
raise ValueError(name)
try:
value = float(value)
except (ValueError, OverflowError):
raise ValueError(name)
if not math.isfinite(value) or not low <= value <= high:
raise ValueError(name)
return value
def integer(s):
if not re.fullmatch(r"[0-9]{1,10}", s):
raise ValueError("Nondecimal or oversized protocol integer")
n = int(s)
if n > 0xFFFFFFFF:
raise ValueError("Protocol integer exceeds uint32")
return n
def row_decode(line):
fields = next(csv.reader([line], strict=True))
if len(fields) != 9:
raise ValueError("Exactly nine fields are required")
kind, seq, tick, seg, state, total, low, high, span = fields
if kind not in KINDS or state not in STATES:
raise ValueError("Unknown record kind/state")
seq, tick, seg, total, low, high, span = map(integer, (seq,tick,seg,total,low,high,span))
if kind == "SAMPLE":
if not (0 <= low <= high <= 1023 and 8*low <= total <= 8*high and span <= 1000):
raise ValueError("Invalid eight-reading ADC block")
elif any((total, low, high, span)):
raise ValueError("Nonsample payload must be zero")
return dict(kind=kind, seq=seq, tick=tick, segment=seg, state=state,
sum8=total, minimum=low, maximum=high, span_ms=span)
def slope(points, reference_mv):
if len(points) < 3 or points[-1]["elapsed_ms"] - points[0]["elapsed_ms"] < 120000:
return None
times = [(p["elapsed_ms"] - points[0]["elapsed_ms"]) / 3600000 for p in points]
values = [p["mean_code"] for p in points]
mt, my = sum(times)/len(times), sum(values)/len(values)
denominator = sum((t-mt)**2 for t in times)
if denominator <= 0:
return None
rate = sum((t-mt)*(v-my) for t,v in zip(times,values)) / denominator
return dict(counts_per_hour=rate, millivolts_per_hour=rate*reference_mv/1024,
duration_ms=points[-1]["elapsed_ms"]-points[0]["elapsed_ms"],
samples=len(points), reference_scale="One assumed constant reference; not pot calibration")
def analyze(lines, reference_mv=5000, step_counts=20):
reference_mv=finite(reference_mv,"reference_mv",4500,5250)
step_counts=finite(step_counts,"step_counts",2,512)
segments, events, samples, raw_lines = [], [], [], []
session=-1; active=False; expected=0; last_tick=None; elapsed=0
known_seg=0; state="HOLD"; qualified=False; current=[]
def close(reason, allow_fit=True):
nonlocal current
if current:
segments.append(dict(session=session,segment=known_seg,ended_by=reason,
points=current,fit=slope(current,reference_mv) if allow_fit else None))
current=[]
for index,line in enumerate(lines):
if index >= 5000:
raise ValueError("Input exceeds bounded 5000-record analysis")
line=line.rstrip("\r\n")
if not line:
continue
r=row_decode(line);raw_lines.append(line)
kind=r["kind"]
if kind=="BOOT1":
if r["seq"]!=0 or r["segment"]!=0 or r["state"]!="HOLD":
raise ValueError("Invalid BOOT1")
close("RESET")
session+=1;active=True;expected=1;last_tick=r["tick"];elapsed=0
known_seg=0;state="HOLD";qualified=False
events.append(dict(session=session,elapsed_ms=0,reason="BOOT1"))
continue
if not active:
raise ValueError("A complete new BOOT1 is required")
if r["seq"] < expected:
raise ValueError("Duplicate/backward record sequence")
if r["seq"] != expected:
close("RECORD_GAP");qualified=False
events.append(dict(session=session,elapsed_ms=elapsed,reason="RECORD_GAP"))
expected=r["seq"]+1
delta=(r["tick"]-last_tick)&0xFFFFFFFF
if delta >= 0x80000000:
raise ValueError("Backward/ambiguous clock without BOOT1")
elapsed+=delta;last_tick=r["tick"]
if delta>90000:
close("TIME_GAP");qualified=False
events.append(dict(session=session,elapsed_ms=elapsed,reason="TIME_GAP"))
if kind in ("MARK","GAP"):
if r["segment"] <= known_seg or (kind=="GAP" and r["state"]!="HOLD"):
raise ValueError("Invalid segment boundary")
close(kind+"_"+r["state"])
known_seg=r["segment"];state=r["state"]
qualified=kind=="MARK" and state=="STABLE"
events.append(dict(session=session,elapsed_ms=elapsed,reason=kind+"_"+state))
elif kind=="SAMPLE":
if r["segment"]!=known_seg or r["state"]!=state:
close("UNOBSERVED_STATE_CHANGE");qualified=False
known_seg=r["segment"];state=r["state"]
mean=r["sum8"]/8
point=dict(session=session,segment=known_seg,elapsed_ms=elapsed,
mean_code=mean,voltage_mv=mean*reference_mv/1024,
minimum=r["minimum"],maximum=r["maximum"],span_ms=r["span_ms"])
if r["minimum"]==0 or r["maximum"]==1023:
close("RAIL_READING");qualified=False
events.append(dict(session=session,elapsed_ms=elapsed,reason="RAIL_READING"))
if qualified and current:
if elapsed-current[-1]["elapsed_ms"]>90000:
close("SAMPLE_GAP");qualified=False
elif elapsed<=current[-1]["elapsed_ms"]:
raise ValueError("Non-increasing sample time")
elif abs(mean-current[-1]["mean_code"])>=step_counts:
close("OBSERVED_STEP");qualified=False
events.append(dict(session=session,elapsed_ms=elapsed,reason="OBSERVED_STEP"))
point["qualified_for_interval"]=qualified
samples.append(point)
if qualified:current.append(point.copy())
elif kind=="DROP":
close("DROPPED_BLOCK");qualified=False
events.append(dict(session=session,elapsed_ms=elapsed,reason="DROPPED_BLOCK"))
elif kind in ("DONE","FAULT"):
close(kind);qualified=False;active=False
events.append(dict(session=session,elapsed_ms=elapsed,reason=kind))
close("END_WITHOUT_DONE",False)
return dict(scope="Historical voltage trend within operator-qualified intervals; not moisture percentage or irrigation control",
reference_mv=reference_mv,reference_scope="Assumed constant scale; replace only with documented reference measurements",
observed_step_policy_counts=step_counts,sessions=session+1,
qualified_fits=sum(s["fit"] is not None for s in segments),
stream_ended_with_done=bool(raw_lines and row_decode(raw_lines[-1])["kind"]=="DONE"),
segments=segments,events=events,samples=samples,raw_lines=raw_lines)
def main(argv=None):
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument("capture")
parser.add_argument("--reference-mv",type=float,default=5000)
parser.add_argument("--step-counts",type=float,default=20)
args=parser.parse_args(argv)
try:
with open(args.capture,encoding="ascii") as f:
result=analyze(f,args.reference_mv,args.step_counts)
except (OSError,ValueError,csv.Error,UnicodeError) as e:
print("Analysis rejected: "+str(e),file=sys.stderr)
return 2
print(json.dumps(result,indent=2,allow_nan=False))
return 0
if __name__=="__main__":
raise SystemExit(main())
Ordinary least squares (OLS) fits a line to points inside one qualified interval. For sample times in hours, the slope in counts/hour is:
b = Σ[(t − mean(t)) × (code − mean(code))] / Σ[(t − mean(t))²]
slope_mV_per_hour = b × reference_mV / 1024
At least three samples and at least 120 seconds of actual reported span are required. Watering/drainage/hold marks, resets, missing records, excessive time or sample gaps, dropped blocks and rail readings separate intervals. An observed change of 20 counts or more also ends qualification until a fresh stable mark. At an assumed 5 V scale that policy is about 97.7 mV; it is a visible analysis choice, not a universal watering detector. Small unmarked refills or a transient between samples can still escape detection.
A truncated active tail does not receive a fitted rate. Completed historical intervals before a later reset or fault can remain in the report, with their ending reason. Duplicate/backward records and malformed data reject analysis instead of silently becoming zeros. Raw lines, block ranges and the voltage scale stay alongside every result. A negative fitted voltage slope is still only a qualified voltage trend.

The first constructed interval decreases two counts per minute: −2 × 60 = −120 counts/hour, or approximately −585.9 mV/hour under the assumed 5000 mV reference. The other qualified intervals give −60 counts/hour. Including the refill and unqualified points instead produces a positive whole-file slope. None of these synthetic rates states how many grams of water a real plant lost.
The blank observation sheet contains headings only. The host-test bundle includes the actual sketch adapters, Python regressions, both programs and exact run instructions. Host compilation of those adapters is not an AVR firmware build. No physical measurements populate the bench log.
Two records have the same raw voltage change. One spans five minutes and the other twenty. Can they share a rate simply because they contain the same number of samples?
No. A rate depends on elapsed time. The five-minute interval has four times the magnitude if all other assumptions match. Neither record may be joined across a refill or reset merely to obtain more points, and reference drift can still imitate a voltage change.
Study the electronics course and ADC lesson before interpreting small rate differences. Independent mass observations, the actual soil and the reference remain part of the experiment.