Whole-House Energy Monitoring with CT Clamps and an ADS1115
Hardware used in this build
- Raspberry Pi 4
Used in the running shed build; works reliably. - ADS1115 16-bit I2C ADC breakout
HiLetgo ADS1115 16-bit 4-channel I2C ADC. Fine for relative load trends; 860 SPS is ~14 samples/cycle at 60 Hz, so harmonics alias. - Split-core CT clamp (x2)
JANSANE SCT-013-000 100 A split-core CT clamp. Works, but a split core that is not fully closed reads low silently. Calibration constant is empirically fitted. - DS18B20 (x8)
Waterproof DS18B20 probe with stainless steel sleeve, includes 4.7kΩ resistor. Buy the 2-pack. - DHT11
Fine for 20-80 %RH yes/no questions; out of spec below 20 %RH. No standalone product page yet.
The problem I started with: I wanted whole-house current on the two 120 V legs of my panel without buying a commercial energy monitor, and I already had an ADS1115 breakout and a Pi collector running my DS18B20 probes and a DHT11 on the same asyncio collector loop. What I did not appreciate at the time is that an ADS1115 is the wrong shape of ADC for mains current, and that everything downstream is an exercise in managing that mismatch honestly rather than pretending it away.
This post is about that mismatch, and about what fifteen days of my own logged data actually look like coming out the other end.
The sampling ceiling, from the datasheet
The ADS1115 is a 16-bit delta-sigma ADC with a programmable data rate topping out at 860 SPS (TI datasheet SBAS444). That is the conversion rate for a single channel in continuous mode. On 60 Hz mains, 860 SPS is roughly 14 samples per cycle. At 50 Hz, about 17.
Fourteen samples per cycle is above Nyquist for the fundamental, so you can in principle recover a 60 Hz sine. What you cannot recover is anything above the 7th harmonic — and switching-mode supplies, LED drivers, and variable-speed motors put real energy up there. Those harmonics do not vanish; they alias down into your passband and add an error to the RMS estimate whose sign and size depend on the load. That is the crux: this is not random noise you can average away, it is load-dependent bias.
There is one mitigating detail. The ADS1115 is an oversampling converter with an internal sinc filter, so each “sample” is an average over the conversion window rather than a true instantaneous sample-and-hold. That buys some anti-alias help for free and simultaneously smears fast edges. Neither effect comes with a clean number you can drop into an error budget.
My working position: this rig produces a relative trend that is very good at showing when the heat pump kicks in, and an absolute watt figure whose accuracy I have never verified against a reference meter. I will come back to why that matters more than usual here.
Two clamps, two legs, single-ended
US residential service is split-phase: two 120 V legs. One clamp on one leg gives you half the house and lies about the other half. So there are two CTs, on ADS1115 channels A2 and A3, read single-ended, and the two wattages are summed.
| CT type | Output | Burden resistor | Consequence |
|---|---|---|---|
| Current-output (e.g. SCT-013-000) | Current proportional to primary | You supply one | You pick full-scale; never leave the secondary open |
| Voltage-output (e.g. SCT-013-030) | 1 V at rated amps | Built in | Simpler, full-scale fixed by the vendor |
If you have a current-output CT, the secondary must never be open-circuit with current in the primary — the core drives the winding voltage up until something breaks down. The burden resistor is a safety element, not just a scaling choice. Mine lives at the terminal block, not at the far end of a run.
Sizing comes straight from the turns ratio: for ratio N and peak primary current I_peak, the secondary peak is I_peak/N, and burden R sets the peak voltage at R·I_peak/N. That peak has to sit inside the chosen PGA range with headroom for motor inrush.
Because the CT output is bipolar and the ADS1115 inputs must not swing below GND (absolute limit GND − 0.3 V, or you forward-bias the ESD diodes), the burden midpoint sits at VDD/2 through a divider with a decoupling cap. The waveform then rides on that bias, and the bias is removed in software by subtracting the mean of each sample burst. Single-ended plus mean-subtraction is what I actually run; a differential read across the burden would reject common-mode noise better, but it would not remove the need for the DC bias.
The collector code
This is the sampling core as it runs, with the constants my config actually uses:
def rms_watts(samples, amps_per_volt=20, calibration=25, volts=120):
"""RMS current from raw ADS1115 counts -> watts for one 120 V leg."""
if not samples:
return 0.0
mid = sum(samples) / len(samples)
rms = math.sqrt(sum((x - mid) ** 2 for x in samples) / len(samples)) \
* 0.0001875 * amps_per_volt
return rms * calibration * volts
def condition(inst_w, noise_gate=12.0, spike_limit=15000, spike_sub=300.0):
if inst_w > spike_limit:
return spike_sub # obvious garbage -> substitute
if inst_w < noise_gate:
return 0.0 # below the noise floor -> call it zero
return inst_w
and the window loop, which runs in a worker thread so the event loop never sees per-sample I2C latency:
def _measure_window(self):
a2, a3 = self._channels() # AnalogIn(ads, 2), AnalogIn(ads, 3)
istart = time.monotonic()
w_sum, t_sum, l_samp = 0.0, 0.0, time.monotonic()
while time.monotonic() - istart < cfg["window_s"]:
inst = rms_watts(self._burst(a2), ...) + rms_watts(self._burst(a3), ...)
inst = condition(inst, cfg["noise_gate_w"])
now = time.monotonic()
dt = now - l_samp
w_sum += inst * dt # time-weighted, not count-weighted
t_sum += dt
l_samp = now
avg_w = w_sum / t_sum
return avg_w, (avg_w * t_sum) / 3600.0
_burst() grabs 400 raw counts per channel, and the outer loop repeats that for a ~50 s window inside each 60 s cycle. The averaging is time-weighted by dt, not a plain mean over bursts, because the per-burst duration is not constant — I2C contention with the 1-Wire and DHT reads stretches some bursts. Weighting by count instead of time would quietly overweight whatever the bus happened to make fast.
The calibration constant is hiding a scale error
Here is the thing I found writing this up, and it is the most useful sentence in the post. The code multiplies raw counts by 0.0001875 V/LSB. That is the LSB for the ±6.144 V range (gain 2/3). The ADC is configured with gain = 1, which is the ±4.096 V range, whose LSB is 0.125 mV. The volts-per-count figure in the math is 1.5× larger than the configured range implies.
The system still reads plausibly, because calibration = 25 is an empirical fudge factor fitted after the fact — it absorbed the 1.5× along with the CT turns ratio and the burden value. That is exactly how a wrong constant survives for years: fit an arbitrary scalar at the end and any consistent upstream error disappears into it, as long as it is linear.
So the numbers below are internally consistent and useful as trends. They are not traceable, and I am not going to claim a percentage accuracy I have not measured against a clamp meter or the utility meter. If you take one thing from this post: an empirically fitted calibration constant does not validate the chain above it.
What fifteen days of real data look like
Pulled from the SQLite (WAL) timeseries DB on the collector, source='power', 60-second cadence:
| Statistic | Value |
|---|---|
| Span logged | 15.6 days, 22,491 watt samples |
| Minimum | 160 W |
| 5th percentile | 249 W |
| Median | 531 W |
| 75th percentile | 827 W |
| 95th percentile | 1409 W |
| 99th percentile | 2062 W |
| Maximum | 3594 W |
| Mean | 650 W |
| 3 a.m. hourly mean (same 15.6 d span, 955 samples) | 441 W |
| Samples above 3000 W | 13 |
A few observations that only fall out of having the distribution rather than a dashboard screenshot:
The 3 a.m. mean of 441 W against a median of 531 W tells me the standby floor is most of my consumption. The interesting number in a house is not the peak, it is the baseline you pay for 24 hours a day.
The noise gate has never fired: zero samples in the whole dataset came out at exactly 0.0 W. Neither has the 15 kW spike substitution — no sample was replaced with the 300 W stand-in. Both guards are legacy behaviour I preserved during the migration, and the data says they are currently dead code. I am leaving them in, because a failing CT connection is exactly the kind of thing that produces a nonsense reading at 3 a.m. on a Tuesday.
Cadence, over the last 7 days: 10,091 rows against an expected 10,080. The inter-sample deltas are dominated by 60 s as designed, with 833 at 61 s and 145 at 120 s (one skipped cycle). Nineteen gaps exceeded 300 s and the worst was 721 s. And 410 deltas were zero — duplicate timestamps.
What went wrong
Duplicate rows in mirror mode. Those 410 zero-second deltas are the parallel-run bridge double-fetching. The new collector currently runs in mirror mode: the legacy power-monitor service owns the ADC, and my collector polls new rows out of the legacy DuckDB using WHERE timestamp > last_seen. When a row lands with a timestamp equal to last_seen, or the legacy writer rewrites a row, it comes through twice. It does not corrupt the trend, but any naive SUM() over that table double-counts those intervals. The schema has no unique constraint on (ts, source, metric) on the raw table — the 5-minute and hourly rollup tables do, which is why the rollups are clean and the raw table is not. Fixing the raw table is on my list.
I2C is not a cable bus. I originally put the ADS1115 near the panel and ran I2C back to the Pi. The I2C spec is written around a total bus-capacitance budget, not a distance, and I got hangs that wedged the whole collector. The structural fix is to keep the ADC within centimetres of the Pi, or to put an ESP32-C3 at the panel and send only the reduced scalar over the network — my HC-12 433 MHz link is far too slow for a sample stream, but it carries one number a minute without complaint.
Amps is not watts, and I am multiplying by 120 anyway. There is no voltage waveform in this rig. rms_watts() multiplies current by a nominal 120 V, which gives volt-amps, and labels it watts. On resistive load that is close; on inductive and switching load, power factor is below 1 and this overstates real power. Getting true power needs a simultaneous voltage channel, and on an ADS1115 a second channel means multiplexing, which means the samples are not simultaneous, which reintroduces a phase error you then have to correct. I have not built that, and the 650 W mean above should be read as a VA figure.
A split core that is not fully closed reads low, silently. A gap in the magnetic path drops the coupling and the reading just comes out small. Re-seat the clamp before concluding your calibration constant drifted.
What I would tell someone starting
If you want a trend — is the heat pump running, what is the 3 a.m. floor, did the shop circuit spike — two CT clamps into an ADS1115 are cheap and honest, provided you log the raw distribution and not just a rolling average, and provided you are clear with yourself that the calibration constant is a fitted scalar and not a measurement.
If you want billing-grade watt-hours, you want a metering front-end with simultaneous voltage and current channels and a real sample rate, and no amount of Python on top of an 860 SPS delta-sigma will get you there.
Most write-ups on this hardware present a calibration constant and a screenshot and stop. The calibration constant is the easy part. Finding out it was hiding a 1.5× unit error is the article.
Related reading
- Parallel-Run: How to Replace a Monitoring System Without Trusting It Yet
How I ran my old RRDtool logger and its SQLite replacement in parallel for months — why you tee the reads instead of double-polling, and how to set a real cutover test before a monitoring-system migration.
- DHT11 After 18 Months of Continuous Logging: An Honest Accuracy Review
18 months and 26,318 rollup rows from one DHT11 on a Pi: 38% of them below the datasheet's 20 %RH floor, what that actually looks like, and where the part stops being usable.
- An asyncio Collector Pattern for Mixed-Protocol Sensor Fleets
Why asyncio does not make 1-Wire faster, and the collector pattern I actually run: per-plugin loops, to_thread for blocking buses, and one batched SQLite writer.