SensorRig

Whole-House Energy Monitoring with CT Clamps and an ADS1115

9 August 2026 · 9 min read

Hardware used in this build

  • Raspberry Pi 4 — 1-Wire master + villa collector
    Used in the running shed build; works reliably.
  • ADS1115 16-bit I2C ADC breakout — Reads the two CT clamps (channels A2/A3) for whole-house current
    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) — Current sensing on both 120 V legs of the panel
    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) — 1-Wire temp probes (house)
    Waterproof DS18B20 probe with stainless steel sleeve, includes 4.7kΩ resistor. Buy the 2-pack.
  • DHT11 — Indoor temp + humidity (long-run logging)
    Fine for 20-80 %RH yes/no questions; out of spec below 20 %RH. No standalone product page yet.

Full parts list & affiliate disclosure →

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 typeOutputBurden resistorConsequence
Current-output (e.g. SCT-013-000)Current proportional to primaryYou supply oneYou pick full-scale; never leave the secondary open
Voltage-output (e.g. SCT-013-030)1 V at rated ampsBuilt inSimpler, 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:

StatisticValue
Span logged15.6 days, 22,491 watt samples
Minimum160 W
5th percentile249 W
Median531 W
75th percentile827 W
95th percentile1409 W
99th percentile2062 W
Maximum3594 W
Mean650 W
3 a.m. hourly mean (same 15.6 d span, 955 samples)441 W
Samples above 3000 W13

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