A pot that becomes lighter has lost mass. That alone does not tell you whether water drained into a tray, evaporated, spilled elsewhere, or moved a cable that was pulling on the scale. Put the pot and its drainage collector on separate scales, then compare their changes over the same observed interval. The useful result is a conditional mass balance, with an explicit unknown state when either observation is missing.
This experiment uses two SparkFun SEN-13879 HX711 v11 boards, two HTC TAL220B 5 kg load cells sold as SparkFun SEN-14729, and an original RP2040 Raspberry Pi Pico running stock RPI_PICO MicroPython 1.26.0 at 125 megahertz (MHz). Each analog-to-digital converter (ADC) supplies a filtered 24-bit code. Twenty-four bits describe the output format, not 24 bits of weighing accuracy. Programmable input/output (PIO), a small hardware instruction engine in the RP2040, clocks each converter without relying on Python to end a short clock pulse. General-purpose input/output (GPIO) pins carry the data and clock signals. A state machine (SM) executes each PIO program; its receive first-in, first-out (FIFO) buffer holds the completed word. The input shift register (ISR) collects its individual bits.
The configured rate is 10 samples per second (SPS), with channel A and gain 128 on both converters. Universal Serial Bus (USB) supplies the Pico and both converter boards through one cable. No pump, valve or irrigation output is connected. All figures below are original circuit drawings or constructed examples. No target firmware build, Pico execution, physical weighing or water experiment is claimed.
Call the calibrated pot-platform mass P and the collector-platform mass C, both in grams (g). Compare two accepted pairs of observations. A 30 g pot decrease accompanied by a 30 g collector increase is compatible with transfer between the reservoirs. It does not prove that the transferred material was water, that none was lost, or that both scales were accurate. The residual is the sum of the two changes, not the pot change alone.
delta_P = P_now - P_previous delta_C = C_now - C_previous residual = delta_P + delta_C
A positive residual can indicate added water or another added load. A negative residual can indicate evaporation, an uncaught spill, a removed object or measurement error. This lesson deliberately labels those outcomes NET_GAIN_OR_ERROR and NET_LOSS_OR_ERROR. It does not relabel a negative residual as plant water use. Dry-back slope, soil volumetric water content and automatic watering decisions require other observations and a longer qualified procedure.
The supplied analyzer uses an assumed 10 g residual tolerance. That number is an application policy, not a manufacturer uncertainty bound. Independently establish whether your completed platforms, tare drift, reference masses, motion and load position can support it. If not, use a justified larger tolerance or leave the result unresolved. The two load cells need separate calibration even when they have the same part number.
The v11 carrier separates its main VCC supply from digital VDD. Its analog regulator is intended to produce approximately 4.3 volts (V) from a higher main rail. Feeding every terminal from 3.3 V would silently change that operating point. Here Pico VBUS, approximately 5 V from USB, supplies both VCC terminals; Pico 3V3_OUT supplies both VDD terminals. Every ground shares the same return. There is no independent positive supply and no 5 V pull-up on a Pico signal.
The circuit uses the boards' fitted regulator and bypass components. Do not replace them with undocumented module components. Keep each factory RATE jumper closed for 10 SPS. Channel B is unused. The HTC sheet identifies the exact four load-cell wires: red excitation positive, black excitation negative, green signal positive and white signal negative. Generic load-cell color charts are not interchangeable with this identity. The shield terminal is not connected (NC) for this four-wire cell.

| Pico physical pin | Exact destination | Purpose |
|---|---|---|
| 40 VBUS | Both carriers J1 pin 2 VCC | Main analog supply, approximately 5 V |
| 36 3V3_OUT | Both J1 pin 1 VDD | 3.3 V digital domain |
| 38 GND | Both J1 pin 5 GND | Common return |
| 4 GP2 / 5 GP3 | Pot J1 pin 3 DAT / pin 4 SCK | Input / sole clock owner |
| 6 GP4 / 7 GP5 | Collector J1 pin 3 DAT / pin 4 SCK | Input / sole clock owner |
| No Pico connection | Each JP4: 5 E+ red; 4 E− black; 2 A+ green; 3 A− white | Independent bridge excitation and signal pair |
Use a dry tabletop and keep liquid below the electronics. Disconnect USB before rewiring. Verify terminal continuity against the v11 drawing, including headers viewed from the correct side. With power applied and no capture running, measure VBUS, VDD and each E+ relative to its E−. The HX711 operating supply range is 2.6–5.5 V. Check the analog input common-mode range, AGND + 1.2 V through AVDD − 1.3 V. A balanced bridge near half of a 4.3 V excitation is nominally inside that range; a broken or miswired bridge need not be.
With a nominal 4.3 V excitation and the cell's minimum stated 990 ohm (Ω) input resistance, one bridge draws about 4.34 milliamperes (mA). Two bridges need about 8.69 mA, before converter and board current. This is a nominal supply calculation, not a guarantee for every USB source. The nominal 1 millivolt per volt (mV/V) full-scale cell output is about 4.3 mV at 5 kg. The channel-A gain-128 input range is approximately ±0.5 × AVDD / 128, or ±16.8 mV at 4.3 V. Electrical range does not establish a useful gram resolution.
Mount each bar as its manufacturer intends: one end fixed, the other carrying the platform, with clearance for its small deflection. Include the platform and permanent fixtures in the tare. The 5 kg capacity includes every supported object, water and platform part; the analyzer's 4.5 kg application gate does not certify mechanical overload protection. Check off-center loading, wire tension and repeatability with known masses before placing a plant on either scale.
Water must leave the pot and cross an air gap before entering the collector. A hose resting on both platforms can transmit force. A cable rubbing on a pot, a wet bridge touching the tray, or a collector touching the bench creates another load path. Secure cables to the fixed structure with slack near the moving end. Do not tare while a hand supports either platform. Wait for obvious oscillation to stop; an electronic filter can hide motion without removing its bias.
The HTC sheet includes creep of up to ±0.1% of full scale over three minutes under its conditions. On a 5 kg cell that scale is 5 g, before platform effects or two-channel combination. This alone is a reason to test your selected tolerance and observation duration. A short successful transfer trial is not a validated all-day evaporation instrument.
HX711 DOUT becomes LOW when a result is ready. The program reads 24 data bits and sends a 25th clock pulse to select channel A, gain 128 for the next conversion. The data sheet requires a clock HIGH between 0.2 and 50 microseconds (µs); a HIGH longer than 60 µs invokes power-down. Python pauses while a clock is HIGH can therefore change converter state. The PIO program keeps the 24 data-clock HIGH intervals at three cycles and its final gain-clock HIGH at two cycles, at a requested 1 MHz state-machine clock. Both clock pins are LOW before a blocking FIFO push.
The program first deliberately holds both clocks HIGH for 100 µs and returns them LOW, resetting to A/128. It then waits a chosen 500 milliseconds (ms), beyond the listed 400 ms settling time at 10 SPS. This allowance has not been physically qualified. At every observation it consumes and discards each held result, then waits for the next conversion. The exported raw frames therefore follow a known read that initiated another conversion. Their internal filter history still extends into earlier physical conditions.
The two converters have separate internal clocks. Their state machines are armed sequentially, and their results are not simultaneous. Each exported frame includes the host arming bracket and read-completion time. Those are transaction observations, not exact conversion timestamps. The code bounds an observed pair window and records each channel separately so that analysis can omit uncertain intervals instead of inventing synchronized mass.
The program file must be loaded once after a Pico reset. Both state machines reuse one cached PIO program object; repeated calls in that context reuse its instruction allocation. Re-executing the entire file creates another context, so reset before loading it again. No other application may own state machines 0/1, these four GPIO pins or either converter. A stop attempts both state machines independently and remuxes both clocks to ordinary GPIO outputs LOW. An exception in one cleanup path does not suppress the other attempts; a failed cleanup prevents a successful returned report.
"""Two SparkFun HX711 v11 boards, channel A/gain128/10SPS.
Original RP2040 Pico, stock MicroPython1.26.0 at125MHz. No autostart.
Load once per reset: cached PIO program objects are reused by both SMs.
"""
_CACHE = None
def signed24(word):
if type(word) is not int or not 0 <= word <= 0xFFFFFF:
raise ValueError('WORD')
raw=word & 0xFFFFFF
if raw in (0x800000,0x7FFFFF):
raise ValueError('ADC_SATURATION')
return raw-(1<<24) if raw & (1<<23) else raw
def program(rp2):
global _CACHE
if _CACHE is not None and _CACHE[0] is rp2:
return _CACHE[1]
@rp2.asm_pio(set_init=rp2.PIO.OUT_LOW,in_shiftdir=rp2.PIO.SHIFT_LEFT)
def read_a128():
set(pins,0)
mov(isr,null)
wait(0,pin,0)
set(x,23)
label('bits')
set(pins,1)[1]
in_(pins,1)
set(pins,0)[1]
jmp(x_dec,'bits')
set(pins,1)[1]
set(pins,0)[1]
push(block)
label('park')
jmp('park')
_CACHE=(rp2,read_a128)
return read_a128
class Clock:
def __init__(self,ticks,diff):
self.ticks,self.diff=ticks,diff
self.raw=ticks();self.total=0;self.bad=False
if type(self.raw)is not int:raise ValueError('CLOCK')
def now(self):
if self.bad:raise ValueError('CLOCK')
raw=self.ticks();delta=self.diff(raw,self.raw)
if type(raw)is not int or type(delta)is not int or not 0<=delta<=1000:
self.bad=True;raise ValueError('CLOCK')
self.raw,self.total=raw,self.total+delta
return self.total
def wait(clock,sleep_ms,duration):
start=clock.now()
for _ in range(duration//25+4):
age=clock.now()-start
if age>=duration:return
sleep_ms(min(25,duration-age));clock.now()
raise ValueError('FROZEN_CLOCK')
class Hardware:
def __init__(self):
self.clocks=[];self.data=[];self.sms=[]
def open(self):
import machine,rp2
if machine.freq()!=125_000_000:raise ValueError('SYSTEM_CLOCK_125MHZ_REQUIRED')
self.machine,self.rp2=machine,rp2
for d,c in ((2,3),(4,5)):
# Retain handles before init, including partial-construction cleanup.
dp=machine.Pin(d);self.data.append(dp);dp.init(machine.Pin.IN,pull=None)
cp=machine.Pin(c);self.clocks.append(cp);cp.init(machine.Pin.OUT,value=0)
self.pio_program=program(rp2)
for n in (0,1):self.sms.append(rp2.StateMachine(n))
def reset(self):
from time import sleep_us
try:
for p in self.clocks:p.value(1)
sleep_us(100) # intentional >60us power-down/reset, not a data pulse
finally:
for p in self.clocks:p.value(0)
def frames(self,clock,sleep_ms):
starts=[]
for i,sm in enumerate(self.sms):
sm.active(0)
for _ in range(4):
if not sm.rx_fifo():break
sm.get()
if sm.rx_fifo():raise ValueError('FIFO_NOT_EMPTY')
sm.init(self.pio_program,freq=1_000_000,set_base=self.clocks[i],in_base=self.data[i])
start=clock.now()
for sm in self.sms:
before=clock.now();sm.active(1);after=clock.now()
starts.append((before,after))
results=[None,None]
for _ in range(505):
now=clock.now()
if now-start>500:raise ValueError('READY_TIMEOUT')
for i,sm in enumerate(self.sms):
if results[i] is None:
n=sm.rx_fifo()
if n>1:raise ValueError('FIFO_MULTIPLE')
if n:
raw=signed24(sm.get());done=clock.now()
results[i]={'raw':raw,'armed_before_ms':starts[i][0],
'armed_after_ms':starts[i][1],'read_ms':done}
if all(r is not None for r in results):
if clock.now()-start>500:raise ValueError('RETURNED_LATE')
return results
sleep_ms(1)
raise ValueError('FROZEN_CLOCK')
def close(self):
errors=[]
for sm in self.sms:
try:sm.active(0)
except BaseException as e:errors.append(e)
for p in self.clocks:
try:p.init(self.machine.Pin.OUT,value=0)
except BaseException as e:errors.append(e)
for p in self.data:
try:p.init(self.machine.Pin.IN,pull=None)
except BaseException as e:errors.append(e)
if errors:raise errors[0]
def run(io=None,ticks=None,diff=None,sleep_ms=None,count=30,period_ms=2000):
if type(count)is not int or not 1<=count<=120:raise ValueError('COUNT')
if type(period_ms)is not int or not 200<=period_ms<=10000:raise ValueError('PERIOD')
if ticks is None:
from time import ticks_ms as ticks,ticks_diff as diff,sleep_ms
io=Hardware()if io is None else io;rows=[]
try:
clock=Clock(ticks,diff);io.open();io.reset();clock.now()
wait(clock,sleep_ms,500) # chosen allowance beyond400ms listed10SPS settling
for index in range(count):
if index:wait(clock,sleep_ms,period_ms)
# Consume the held result, initiating a new conversion on each scale.
flush_before=clock.now();io.frames(clock,sleep_ms);flush_after=clock.now()
fresh=io.frames(clock,sleep_ms);end=clock.now()
valid=0<=flush_after-flush_before<=500 and 0<end-flush_before<=1000
for f in fresh:
if not flush_after<=f['armed_before_ms']<=f['armed_after_ms']<=f['read_ms']<=end:
raise ValueError('FRAME_TIME_ORDER')
rows.append({'index':index,'before_ms':flush_before,'after_ms':end,
'valid':valid,'reason':None if valid else'PAIR_WINDOW',
'pot':fresh[0],'collector':fresh[1]})
finally:
io.close() # all owned clocks get independent LOW/remux attempts
return {'state':'CLOSED_RAW_HISTORY','channel':'A','gain':128,'requested_rate_SPS':10,
'rows':rows,'measurement_timestamp':None,'current_mass_g':None,
'hardware_qualified':False,'scope':'Filtered raw codes and host transaction windows, not simultaneous mass samples'}
def main(count=30,period_ms=2000):
import json
result=run(count=count,period_ms=period_ms)
print(json.dumps(result))
return result
Save this exact file on the Pico as pico_dual_hx711_capture.py. In the MicroPython read-evaluate-print loop (REPL), execute the following block once. It does not install an automatic startup file. The finite default run has 30 pairs and 29 requested two-second pauses, plus converter waits and setup. It usually spans roughly a minute under the assumed rate; it has no hardware deadline.
scope = {"__name__": "experiment"}
exec(open("pico_dual_hx711_capture.py").read(), scope)
scope["main"]()
Copy the single returned JavaScript Object Notation (JSON) line to capture.json on your computer. The capture program does not write flash. Saturated signed endpoint codes, readiness timeout, FIFO anomalies, returned-late operations and invalid clock observations raise instead of becoming a zero mass. A blocked hardware call or suspended interpreter can delay cleanup. The maximum observed clock gap cannot detect an entirely unobserved whole tick wrap; run continuously in one short session, and do not treat the software limits as physical power supervision.
Begin with both empty platforms, including permanent pot supports but no removable pot or collector. Run a five-pair capture with scope["main"](count=5, period_ms=500). For each scale, average its five exported raw values only after checking that the platform was stationary and that the codes were repeatable. Record that integer mean as zero_raw. Never average a timeout or substitute an omitted row.
Place a traceable or otherwise honestly characterized 500 g reference mass on one platform, repeat the short capture and enter that scale's mean as span_raw, with known_g set to its actual reference value. Remove it and check return to zero. Repeat independently for the other platform. The blank calibration file contains nulls and intentionally cannot produce masses. The analyzer accepts reference masses from 100 to 2000 g; that is this lesson's bounded calibration policy. Check an intermediate mass and another load position before trusting it.
counts_per_g = (span_raw - zero_raw) / known_g mass_g = (raw - zero_raw) / counts_per_g
A negative counts-per-gram slope is allowed when the bridge orientation gives decreasing codes under added load. Do not hide a zero span, an unknown reference or nonfinite arithmetic by changing the sign manually. Calibrate in the final mechanical arrangement and repeat after changing power, mounting or a load cell. The supplied result retains the calibration values for review.
"""Offline two-reservoir mass-change analysis; no default physical calibration."""
import argparse,json,math
def finite(v):
if isinstance(v,bool)or not isinstance(v,(int,float))or not math.isfinite(v):raise ValueError('FINITE')
return v
def calibration(entry):
if set(entry)!={'zero_raw','span_raw','known_g'}:raise ValueError('CAL_FIELDS')
z,s=entry['zero_raw'],entry['span_raw'];g=finite(entry['known_g'])
if any(type(v)is not int or not -8388607<=v<=8388606 for v in (z,s)):raise ValueError('CAL_RAW')
if not 100<=g<=2000 or s==z:raise ValueError('CAL_SPAN')
return z,(s-z)/g
def analyse(capture,cal,tolerance_g=10):
tolerance_g=finite(tolerance_g)
if not 0<tolerance_g<=100:raise ValueError('TOLERANCE')
if capture.get('state')!='CLOSED_RAW_HISTORY' or capture.get('channel')!='A' or capture.get('gain')!=128:raise ValueError('CAPTURE_STATE')
if set(cal)!={'pot','collector'}:raise ValueError('CAL_SCALES')
scale={k:calibration(cal[k])for k in cal};rows=capture.get('rows')
if not isinstance(rows,list)or not rows:raise ValueError('ROWS')
result=[];previous=None;last_index=-1;last_time=-1
for r in rows:
index,before,after=r['index'],r['before_ms'],r['after_ms']
if any(type(v)is not int for v in(index,before,after))or index<=last_index or not last_time<before<=after:raise ValueError('ROW_ORDER')
if type(r['valid'])is not bool:raise ValueError('VALID_FLAG')
last_index,last_time=index,after
out={'index':index,'host_window_ms':[before,after],'pot_g':None,'collector_g':None,'delta_pot_g':None,'delta_collector_g':None,'residual_g':None,'assessment':'OMITTED'}
if not r['valid'] or after-before>1000:
result.append(out);previous=None;continue
masses={}
for k in scale:
f=r[k];raw=f['raw'];a,b,c=f['armed_before_ms'],f['armed_after_ms'],f['read_ms']
if any(type(v)is not int for v in(raw,a,b,c))or not -8388607<=raw<=8388606 or not before<=a<=b<=c<=after:raise ValueError('RAW_FRAME')
z,slope=scale[k];m=(raw-z)/slope
if not -100<=m<=4500:raise ValueError('APPLICATION_MASS_RANGE')
masses[k]=m
out['pot_g'],out['collector_g']=masses['pot'],masses['collector']
out['assessment']='BASELINE'
if previous and index==previous['index']+1 and before-previous['after_ms']<=12000:
dp=masses['pot']-previous['pot'];dc=masses['collector']-previous['collector'];residual=dp+dc
if abs(residual)<=tolerance_g and dp< -tolerance_g and dc>tolerance_g:label='COMPATIBLE_TRANSFER_ASSUMED'
elif residual>tolerance_g:label='NET_GAIN_OR_ERROR'
elif residual< -tolerance_g:label='NET_LOSS_OR_ERROR'
else:label='SMALL_OR_UNRESOLVED'
out.update(delta_pot_g=dp,delta_collector_g=dc,residual_g=residual,assessment=label)
result.append(out);previous={'index':index,'after_ms':after,**masses}
return {'state':'HISTORICAL_ANALYSIS','rows':result,'assumed_balance_tolerance_g':tolerance_g,
'calibration':cal,'whole_pot_moisture_percent':None,'current_drainage_rate':None,
'physical_cause_proven':False,'scope':'Conditional mass differences; creep, coupling, lag, spills and unobserved intervals remain limitations'}
def main():
parser=argparse.ArgumentParser();parser.add_argument('capture');parser.add_argument('calibration');parser.add_argument('--tolerance-g',type=float,default=10)
a=parser.parse_args()
with open(a.capture)as f:c=json.load(f)
with open(a.calibration)as f:k=json.load(f)
print(json.dumps(analyse(c,k,a.tolerance_g)))
if __name__=='__main__':main()
On a computer with Python 3, run python drainage_balance.py capture.json calibration.json --tolerance-g 10. The first accepted pair establishes BASELINE. Missing indices, an omitted pair or an interval beyond 12 seconds require a new baseline. No mass change crosses that gap. Otherwise the result includes each platform change, the signed residual and a conditional assessment. The analyzer retains past accepted rows as history; it does not claim the last mass remains current after the run.

The example's transfer phase keeps the sum nearly constant. Its added-mass and loss phases show why one falling curve is insufficient. The gray gap has no inferred slope or residual. The next accepted pair starts another segment. The chart was constructed from declared values, not measured from a plant, and its labels are examples of the analyzer's policy.
The host checks exercise signed parsing, saturation, discarded versus exported frames, timing order, clock faults, cleanup, calibration polarity, transfer/loss labels and randomized conservation identities. The exact pinned MicroPython assembler encodes the delivered PIO program; a separate opcode model checks its clock count and shift behavior. These are host checks. They do not compile a Pico firmware image or validate analog accuracy, mechanical loading or actual pulse widths.
The pot loses 20 g while the collector gains 12 g. The assumed residual tolerance is 10 g. May you report that exactly 8 g evaporated?
No. The residual is −8 g, inside the assumed balance tolerance. Both opposite changes exceed 10 g, so the implementation calls this compatible transfer under the stated assumptions. That conditional label never identifies an 8 g evaporation loss. Tare creep, timing offset, an uncaught spill or calibration error could produce that residual. Establish independent uncertainty and other observations before naming a physical cause.
Download the compressed ZIP archive with the complete experiment and host checks, Pico capture program, computer analyzer, blank calibration file, blank observations worksheet, and editable circuit SVG / balance SVG. Scalable Vector Graphics (SVG) files remain editable; Portable Network Graphics (PNG) images above open at their original size.
Review the ADC lesson and the electronics course for the measurement background.