Add power meter.
25
README.md
|
@ -101,7 +101,7 @@ A means of rendering multiple larger fonts to the SSD1306 OLED display. See
|
||||||
[docs](./SSD1306/README.md).
|
[docs](./SSD1306/README.md).
|
||||||
|
|
||||||
# mutex
|
# mutex
|
||||||
A class providing mutal exclusion enabling interrupt handlers and the main program to access shared
|
A class providing mutual exclusion enabling interrupt handlers and the main program to access shared
|
||||||
data in a manner which ensures data integrity.
|
data in a manner which ensures data integrity.
|
||||||
|
|
||||||
# watchdog
|
# watchdog
|
||||||
|
@ -127,19 +127,20 @@ the same topic. Measures the round-trip delay. Adapt to suit your server address
|
||||||
QOS (quality of service, 0 and 1 are supported). After 100 messages reports maximum and
|
QOS (quality of service, 0 and 1 are supported). After 100 messages reports maximum and
|
||||||
minimum delays.
|
minimum delays.
|
||||||
|
|
||||||
conn.py Connect in station mode using saved connection details where possible
|
conn.py Connect in station mode using saved connection details where possible.
|
||||||
|
|
||||||
# Rotary Incremental Encoder
|
# Rotary Incremental Encoder
|
||||||
|
|
||||||
Classes for handling incremental rotary position encoders. Note that the Pyboard timers can
|
Classes for handling incremental rotary position encoders. Note that the Pyboard
|
||||||
do this in hardware. These samples cater for cases where that solution can't be used. The
|
timers can do this in hardware. These samples cater for cases where that
|
||||||
encoder_timed.py sample provides rate information by timing successive edges. In practice this
|
solution can't be used. The encoder_timed.py sample provides rate information by
|
||||||
is likely to need filtering to reduce jitter caused by imperfections in the encoder geometry.
|
timing successive edges. In practice this is likely to need filtering to reduce
|
||||||
|
jitter caused by imperfections in the encoder geometry.
|
||||||
|
|
||||||
There are other algorithms but this is the simplest and fastest I've encountered.
|
There are other algorithms but this is the simplest and fastest I've encountered.
|
||||||
|
|
||||||
These were written for encoders producing TTL outputs. For switches, adapt the pull definition
|
These were written for encoders producing TTL outputs. For switches, adapt the
|
||||||
to provide a pull up or pull down as required.
|
pull definition to provide a pull up or pull down as required.
|
||||||
|
|
||||||
The `encoder.portable.py` version should work on all MicroPython platforms.
|
The `encoder.portable.py` version should work on all MicroPython platforms.
|
||||||
Tested on ESP8266. Note that interrupt latency on the ESP8266 limits performance
|
Tested on ESP8266. Note that interrupt latency on the ESP8266 limits performance
|
||||||
|
@ -159,6 +160,14 @@ of numbers following initialisation will always be the same.
|
||||||
|
|
||||||
See the code for usage and timing documentation.
|
See the code for usage and timing documentation.
|
||||||
|
|
||||||
|
# A design for a hardware power meter
|
||||||
|
|
||||||
|
This uses a Pyboard to measure the power consumption of mains powered devices.
|
||||||
|
Unlike simple commercial devices it performs a true vector (phasor) measurement
|
||||||
|
enabling it to provide information on power factor and to work with devices
|
||||||
|
which generate as well as consume power. It uses the official LCD160CR display
|
||||||
|
as a touch GUI interface. It is documented [here](./power/README.md).
|
||||||
|
|
||||||
# License
|
# License
|
||||||
|
|
||||||
Any code placed here is released under the MIT License (MIT).
|
Any code placed here is released under the MIT License (MIT).
|
||||||
|
|
|
@ -0,0 +1,107 @@
|
||||||
|
# A phasor power meter
|
||||||
|
|
||||||
|
This measures the AC mains power consumed by a device. Unlike many cheap power
|
||||||
|
meters it performs a vector measurement and can display true power, VA and
|
||||||
|
phase. It can also plot snapshots of voltage and current waveforms. It can
|
||||||
|
calculate average power consumption of devices whose consumption varies with
|
||||||
|
time such as freezers and washing machines, and will work with devices capable
|
||||||
|
of sourcing power into the grid. It supports full scale ranges of 30W to 3KW.
|
||||||
|
|
||||||
|
[Images of device](./images/IMAGES.md)
|
||||||
|
|
||||||
|
## Warning
|
||||||
|
|
||||||
|
This project includes mains voltage wiring. Please don't attempt it unless you
|
||||||
|
have the necessary skills and experience to do this safely.
|
||||||
|
|
||||||
|
# Hardware Overview
|
||||||
|
|
||||||
|
The file `SignalConditioner.fzz` includes the schematic and PCB layout for the
|
||||||
|
device's input circuit. The Fritzing utility required to view and edit this is
|
||||||
|
available (free) from [here](http://fritzing.org/download/).
|
||||||
|
|
||||||
|
The unit includes a transformer with two 6VAC secondaries. One is used to power
|
||||||
|
the device, the other to measure the line voltage. Current is measured by means
|
||||||
|
of a current transformer SEN-11005 from SparkFun. The current output from this
|
||||||
|
is converted to a voltage by means of an op-amp configured as a transconductance
|
||||||
|
amplifier. This passes through a variable gain amplifier comprising two cascaded
|
||||||
|
MCP6S91 programmable gain amplifiers, then to a two pole Butterworth low pass
|
||||||
|
anti-aliasing filter. The resultant signal is presented to one of the Pyboard's
|
||||||
|
shielded ADC's. The transconductance amplifier also acts as a single pole low
|
||||||
|
pass filter.
|
||||||
|
|
||||||
|
The voltage signal is similarly filtered with three matched poles to ensure
|
||||||
|
that correct relative phase is maintained. The voltage channel has fixed gain.
|
||||||
|
|
||||||
|
## PCB
|
||||||
|
|
||||||
|
The PCB and schematic have an error in that the inputs of the half of opamp U4
|
||||||
|
which handles the current signal are transposed.
|
||||||
|
|
||||||
|
# Firmware Overview
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
1. The `uasyncio` library.
|
||||||
|
2. The official lcd160 driver `lcd160cr.py`.
|
||||||
|
|
||||||
|
Also from the [lcd160cr GUI library](https://github.com/peterhinch/micropython-lcd160cr-gui.git)
|
||||||
|
the following files:
|
||||||
|
|
||||||
|
1. `lcd160_gui.py`.
|
||||||
|
2. `font10.py`.
|
||||||
|
3. `lcd_local.py`
|
||||||
|
4. `constants.py`
|
||||||
|
5. `lplot.py`
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
In my build the above plus `mains.py` are implemented as frozen bytecode. There
|
||||||
|
is no SD card, the flash filesystem containing `main.py` and `mt.py`.
|
||||||
|
|
||||||
|
If `mt.py` is deleted from flash and located on an SD card the code will create
|
||||||
|
simulated sinewave samples for testing.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
The code has not been optimised for performance, which in my view is adequate
|
||||||
|
for the application.
|
||||||
|
|
||||||
|
The module `mains.py` contains two classes, `Preprocessor` and `Scaling` which
|
||||||
|
perform the data capture and analysis. The former acquires the data, normalises
|
||||||
|
it and calculates normalised values of RMS voltage and current along with power
|
||||||
|
and phase. `Scaling` controls the PGA according to the selected range and
|
||||||
|
modifies the Vrms, Irms and P values to be in conventional units.
|
||||||
|
|
||||||
|
The `Scaling` instance created in `mt.py` has a continuously running coroutine
|
||||||
|
(`._run()`) which reads a set of samples, processes them, and executes a
|
||||||
|
callback. Note that the callback function is changed at runtime by the GUI code
|
||||||
|
(by `mains_device.set_callback()`). The iteration rate of `._run()` is about
|
||||||
|
1Hz.
|
||||||
|
|
||||||
|
The code is intended to offer a degree of noise immunity, in particular in the
|
||||||
|
detection of voltage zero crossings. It operates by acquiring a set of 400
|
||||||
|
sample pairs (voltage and current) as fast as standard MicroPython can achieve.
|
||||||
|
On the Pyboard with 50Hz mains this captures two full cycles, so guaranteeing
|
||||||
|
two positive going voltage zero crossings. The code uses an averaging algorithm
|
||||||
|
to detect these (`Preprocessor.analyse()`) and populates four arrays of floats
|
||||||
|
with precisely one complete cycle of data. The arrays comprise two pairs of
|
||||||
|
current and voltage values, one scaled for plotting and the other scaled for
|
||||||
|
measurement.
|
||||||
|
|
||||||
|
Both pairs are scaled to a range of +-1.0 with any DC bias removed (owing to
|
||||||
|
the presence of transformers this can only arise due to offsets in the
|
||||||
|
circuitry and/or ADC's). DC removal facilitates long term integration.
|
||||||
|
|
||||||
|
Plot data is further normalised so that current values exactly fill the +-1.0
|
||||||
|
range. In other words plots are scaled so that the current waveform fills the
|
||||||
|
Y axis with the X axis containing one full cycle. The voltage plot is made 10%
|
||||||
|
smaller to avoid the visually confusing situation with a resistive load where
|
||||||
|
the two plots coincide exactly.
|
||||||
|
|
||||||
|
## Calibration
|
||||||
|
|
||||||
|
This is defined by `Scaling.vscale` and `Scaling.iscale`. These values were
|
||||||
|
initially calculated, then adjusted by comparing voltage and current readings
|
||||||
|
with measurements from a calibrated meter. Voltage calibration in particular
|
||||||
|
will probably need adjusting depending on the transformer characteristics.
|
|
@ -0,0 +1,23 @@
|
||||||
|
# Sample Images
|
||||||
|
|
||||||
|
#Puhbuttons
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
Interior construction.
|
||||||
|
|
||||||
|

|
||||||
|
Microwave oven.
|
||||||
|

|
||||||
|
Microwave waveforms.
|
||||||
|
|
||||||
|

|
||||||
|
Integration screen.
|
||||||
|
|
||||||
|

|
||||||
|
Soldering iron on 3KW range.
|
||||||
|
|
||||||
|

|
||||||
|
Same iron on 30W range
|
||||||
|
|
Po Szerokość: | Wysokość: | Rozmiar: 36 KiB |
Po Szerokość: | Wysokość: | Rozmiar: 36 KiB |
Po Szerokość: | Wysokość: | Rozmiar: 361 KiB |
Po Szerokość: | Wysokość: | Rozmiar: 37 KiB |
Po Szerokość: | Wysokość: | Rozmiar: 164 KiB |
Po Szerokość: | Wysokość: | Rozmiar: 34 KiB |
Po Szerokość: | Wysokość: | Rozmiar: 37 KiB |
|
@ -0,0 +1,326 @@
|
||||||
|
# mains.py Data collection and processing module for the power meter.
|
||||||
|
|
||||||
|
# The MIT License (MIT)
|
||||||
|
#
|
||||||
|
# Copyright (c) 2017 Peter Hinch
|
||||||
|
#
|
||||||
|
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
# of this software and associated documentation files (the "Software"), to deal
|
||||||
|
# in the Software without restriction, including without limitation the rights
|
||||||
|
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
# copies of the Software, and to permit persons to whom the Software is
|
||||||
|
# furnished to do so, subject to the following conditions:
|
||||||
|
#
|
||||||
|
# The above copyright notice and this permission notice shall be included in
|
||||||
|
# all copies or substantial portions of the Software.
|
||||||
|
#
|
||||||
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
# THE SOFTWARE.
|
||||||
|
|
||||||
|
from pyb import SPI, Pin, ADC
|
||||||
|
from array import array
|
||||||
|
from micropython import const
|
||||||
|
from math import sin, cos, asin, pi, radians, sqrt
|
||||||
|
import uasyncio as asyncio
|
||||||
|
import gc
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
# PINOUT
|
||||||
|
# vadc X19
|
||||||
|
# iadc X20
|
||||||
|
# MOSI X8
|
||||||
|
# MISO X7
|
||||||
|
# SCK X6
|
||||||
|
# CSN0 X5 First PGA
|
||||||
|
# CSN1 X4 Second PGA
|
||||||
|
|
||||||
|
# SAMPLING
|
||||||
|
NSAMPLES = const(400)
|
||||||
|
def arr_gen(n):
|
||||||
|
for _ in range(n):
|
||||||
|
yield 0
|
||||||
|
|
||||||
|
isamples = array('h', arr_gen(NSAMPLES))
|
||||||
|
vsamples = array('h', arr_gen(NSAMPLES))
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
# HARDWARE
|
||||||
|
vadc = ADC(Pin.board.X19)
|
||||||
|
iadc = ADC(Pin.board.X20)
|
||||||
|
|
||||||
|
spi = SPI(1)
|
||||||
|
spi.init(SPI.MASTER, polarity = 0, phase = 0)
|
||||||
|
|
||||||
|
# ************* Programmable Gain Amplifier *************
|
||||||
|
|
||||||
|
class MCP6S91():
|
||||||
|
CHANNEL_ADDR = 0x41
|
||||||
|
GAIN_ADDR = 0x40
|
||||||
|
GAINVALS = (1, 2, 4, 5, 8, 10, 16, 32)
|
||||||
|
PINS = ('X5', 'X4')
|
||||||
|
def __init__(self, devno):
|
||||||
|
try:
|
||||||
|
self.csn = Pin(MCP6S91.PINS[devno], mode = Pin.OUT_PP)
|
||||||
|
except IndexError:
|
||||||
|
raise ValueError('MCP6S91 device no. must be 0 or 1.')
|
||||||
|
self.csn.value(1)
|
||||||
|
self.csn.value(0)
|
||||||
|
self.csn.value(1) # Set state machine to known state
|
||||||
|
self.gain(1)
|
||||||
|
|
||||||
|
def gain(self, value):
|
||||||
|
try:
|
||||||
|
gainval = MCP6S91.GAINVALS.index(value)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError('MCP6S91 invalid gain {}'.format(value))
|
||||||
|
self.csn.value(0)
|
||||||
|
spi.send(MCP6S91.GAIN_ADDR)
|
||||||
|
spi.send(gainval)
|
||||||
|
self.csn.value(1)
|
||||||
|
|
||||||
|
# Two cascaded MCP6S91 devices provide gains 1, 2, 5, 10, 20, 50, 100
|
||||||
|
class PGA():
|
||||||
|
gvals = { 1 : (1, 1), 2 : (2, 1), 5 : (5, 1), 10 : (10, 1),
|
||||||
|
20 : (10, 2), 50 : (10, 5), 100 : (10, 10) }
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.amp0 = MCP6S91(0)
|
||||||
|
self.amp1 = MCP6S91(1)
|
||||||
|
|
||||||
|
def gain(self, value):
|
||||||
|
try:
|
||||||
|
g0, g1 = PGA.gvals[value]
|
||||||
|
except KeyError:
|
||||||
|
raise ValueError('PGA invalid gain {}'.format(value))
|
||||||
|
self.amp0.gain(g0)
|
||||||
|
self.amp1.gain(g1)
|
||||||
|
|
||||||
|
# ************* Data Acquisition *************
|
||||||
|
|
||||||
|
# Integer sample data put into vsamples and isamples global arrays.
|
||||||
|
# Results are in range +-2047
|
||||||
|
|
||||||
|
# SIMULATION PARAMETERS
|
||||||
|
VPEAK = 0.545 # Relative to ADC FSD
|
||||||
|
IPEAK = 0.6
|
||||||
|
VPHASE = 0
|
||||||
|
IPHASE = radians(45)
|
||||||
|
|
||||||
|
def sample(simulate=False):
|
||||||
|
# Acquire just over 2 full cycles of AC
|
||||||
|
if simulate:
|
||||||
|
for n in range(NSAMPLES):
|
||||||
|
isamples[n] = int(2047 + IPEAK * 2047 * sin(4.2 * pi * n / NSAMPLES + IPHASE))
|
||||||
|
vsamples[n] = int(2047 + VPEAK * 2047 * sin(4.2 * pi * n / NSAMPLES + VPHASE))
|
||||||
|
else:
|
||||||
|
for n in range(NSAMPLES):
|
||||||
|
isamples[n] = iadc.read()
|
||||||
|
vsamples[n] = vadc.read()
|
||||||
|
for n in range(NSAMPLES): # Normalise to -2047 to +2048
|
||||||
|
vsamples[n] -= 2047
|
||||||
|
if simulate:
|
||||||
|
isamples[n] -= 2047
|
||||||
|
else:
|
||||||
|
isamples[n] = 2047 - isamples[n] # Sod's law. That's the way I wired the CT.
|
||||||
|
|
||||||
|
# ************* Preprocessing *************
|
||||||
|
|
||||||
|
# Filter data. Produce a single cycle of floating point data in two datasets.
|
||||||
|
# Both are scaled -1.0 to +1.0.
|
||||||
|
# Plot data is scaled such that the data exactly fits the range.
|
||||||
|
# Output data is scaled such that DAC FS fits the range.
|
||||||
|
|
||||||
|
class Preprocessor():
|
||||||
|
arraysize = const(NSAMPLES // 2) # We acquire > 2 cycles
|
||||||
|
plotsize = const(50)
|
||||||
|
vplot = array('f', arr_gen(plotsize)) # Plot data
|
||||||
|
iplot = array('f', arr_gen(plotsize))
|
||||||
|
vscale = array('f', arr_gen(arraysize)) # Output data
|
||||||
|
iscale = array('f', arr_gen(arraysize))
|
||||||
|
def __init__(self, simulate, verbose):
|
||||||
|
self.avg_len = 4
|
||||||
|
self.avg_half = self.avg_len // 2
|
||||||
|
gc.collect()
|
||||||
|
self.simulate = simulate
|
||||||
|
self.verbose = verbose
|
||||||
|
self.overrange = False
|
||||||
|
self.threshold = 1997
|
||||||
|
|
||||||
|
def vprint(self, *args):
|
||||||
|
if self.verbose:
|
||||||
|
print(*args)
|
||||||
|
|
||||||
|
async def run(self):
|
||||||
|
self.overrange = False
|
||||||
|
sample(self.simulate)
|
||||||
|
return await self.analyse()
|
||||||
|
|
||||||
|
# Calculate average of avg_len + 1 numbers around a centre value. avg_len must be divisible by 2.
|
||||||
|
# This guarantees symmetry around the centre index.
|
||||||
|
def avg(self, arr, centre):
|
||||||
|
return sum(arr[centre - self.avg_half : centre + 1 + self.avg_half]) / (self.avg_len + 1)
|
||||||
|
|
||||||
|
# Filter a set of samples around a centre index in an array
|
||||||
|
def filt(self, arr, centre):
|
||||||
|
avg0 = self.avg(arr, centre - self.avg_half)
|
||||||
|
avg1 = self.avg(arr, centre + self.avg_half)
|
||||||
|
return avg0, avg1
|
||||||
|
|
||||||
|
async def analyse(self):
|
||||||
|
# Determine the first and second rising edge of voltage
|
||||||
|
self.overrange = False
|
||||||
|
nfirst = -1 # Index of 1st upward voltage transition
|
||||||
|
lastv = 0 # previous max
|
||||||
|
ovr = self.threshold # Overrange threshold
|
||||||
|
for n in range(self.avg_len, NSAMPLES - self.avg_len + 1):
|
||||||
|
vavg0, vavg1 = self.filt(vsamples, n)
|
||||||
|
iavg0, iavg1 = self.filt(isamples, n)
|
||||||
|
vmax = max(vavg0, vavg1)
|
||||||
|
vmin = min(vavg0, vavg1)
|
||||||
|
imax = max(iavg0, iavg1)
|
||||||
|
imin = min(iavg0, iavg1)
|
||||||
|
if vmax > ovr or vmin < -ovr or imax > ovr or imin < -ovr:
|
||||||
|
self.overrange = True
|
||||||
|
self.vprint('overrange', vmax, vmin, imax, imin)
|
||||||
|
if nfirst == -1:
|
||||||
|
if vavg0 < 0 and vavg1 > 0 and abs(vmin) < lastv:
|
||||||
|
nfirst = n if err > abs(abs(vmin) - lastv) else n - 1
|
||||||
|
irising = iavg0 < iavg1 # Save current rising state for phase calculation
|
||||||
|
elif n > nfirst + NSAMPLES // 6:
|
||||||
|
if vavg0 < 0 and vavg1 > 0 and abs(vmin) < lastv:
|
||||||
|
nsecond = n if err > abs(abs(vmin) - lastv) else n - 1
|
||||||
|
break
|
||||||
|
lastv = vmax
|
||||||
|
err = abs(abs(vmin) - lastv)
|
||||||
|
yield
|
||||||
|
else: # Should never occur because voltage should be present.
|
||||||
|
raise OSError('Failed to find a complete cycle.')
|
||||||
|
self.vprint(nfirst, nsecond, vsamples[nfirst], vsamples[nsecond], isamples[nfirst], isamples[nsecond])
|
||||||
|
|
||||||
|
# Produce datasets for a single cycle of V.
|
||||||
|
# Scale ADC FSD [-FSD 0 FSD] -> [-1.0 0 +1.0]
|
||||||
|
nelems = nsecond - nfirst + 1 # No. of samples in current cycle
|
||||||
|
p = 0
|
||||||
|
for n in range(nfirst, nsecond + 1):
|
||||||
|
self.vscale[p] = vsamples[n] / 2047
|
||||||
|
self.iscale[p] = isamples[n] / 2047
|
||||||
|
p += 1
|
||||||
|
|
||||||
|
# Remove DC offsets
|
||||||
|
sumv = 0.0
|
||||||
|
sumi = 0.0
|
||||||
|
for p in range(nelems):
|
||||||
|
sumv += self.vscale[p]
|
||||||
|
sumi += self.iscale[p]
|
||||||
|
meanv = sumv / nelems
|
||||||
|
meani = sumi / nelems
|
||||||
|
maxv = 0.0
|
||||||
|
maxi = 0.0
|
||||||
|
yield
|
||||||
|
for p in range(nelems):
|
||||||
|
self.vscale[p] -= meanv
|
||||||
|
self.iscale[p] -= meani
|
||||||
|
maxv = max(maxv, abs(self.vscale[p])) # Scaling for plot
|
||||||
|
maxi = max(maxi, abs(self.iscale[p]))
|
||||||
|
yield
|
||||||
|
# Produce plot datasets. vplot scaled to avoid exact overlay of iplot
|
||||||
|
maxv = max(maxv * 1.1, 0.01) # Cope with "no signal" conditions
|
||||||
|
maxi = max(maxi, 0.01)
|
||||||
|
offs = 0
|
||||||
|
delta = nelems / (self.plotsize -1)
|
||||||
|
for p in range(self.plotsize):
|
||||||
|
idx = min(round(offs), nelems -1)
|
||||||
|
self.vplot[p] = self.vscale[idx] / maxv
|
||||||
|
self.iplot[p] = self.iscale[idx] / maxi
|
||||||
|
offs += delta
|
||||||
|
|
||||||
|
if self.verbose:
|
||||||
|
for p in range(nelems):
|
||||||
|
print('{:7.3f} {:7.3f} {:7.3f} {:7.3f}'.format(
|
||||||
|
self.vscale[p], self.iscale[p],
|
||||||
|
self.vplot[round(p / delta)], self.iplot[round(p / delta)]))
|
||||||
|
|
||||||
|
phase = asin(self.iplot[0]) if irising else pi - asin(self.iplot[0])
|
||||||
|
yield
|
||||||
|
# calculate power, vrms, irms etc prior to scaling
|
||||||
|
us_vrms = 0
|
||||||
|
us_pwr = 0
|
||||||
|
us_irms = 0
|
||||||
|
for p in range(nelems):
|
||||||
|
us_vrms += self.vscale[p] * self.vscale[p] # More noise-immune than calcuating from vmax * sqrt(2)
|
||||||
|
us_irms += self.iscale[p] * self.iscale[p]
|
||||||
|
us_pwr += self.iscale[p] * self.vscale[p]
|
||||||
|
us_vrms = sqrt(us_vrms / nelems)
|
||||||
|
us_irms = sqrt(us_irms / nelems)
|
||||||
|
us_pwr /= nelems
|
||||||
|
return phase, us_vrms, us_irms, us_pwr, nelems
|
||||||
|
|
||||||
|
# Testing. Current provided to CT by 100ohm in series with secondary. Vsec = 7.8Vrms so i = 78mArms == 18W at 230Vrms.
|
||||||
|
# Calculated current scaling:
|
||||||
|
# FSD 3.3Vpp out = 1.167Vrms.
|
||||||
|
# Secondary current Isec = 1.167/180ohm = 6.5mArms.
|
||||||
|
# Ratio = 2000. Primary current = 12.96Arms.
|
||||||
|
# At FSD iscale is +-1 = 0.707rms
|
||||||
|
# Imeasured = us_irms * 12.96/(0.707 * self.pga_gain) = us_irms * 18.331 / self.pga_gain
|
||||||
|
|
||||||
|
# Voltage scaling. Measured ADC I/P = 1.8Vpp == 0.545fsd pp == 0.386fsd rms
|
||||||
|
# so vscale = 230/0.386 = 596
|
||||||
|
|
||||||
|
class Scaling():
|
||||||
|
# FS Watts to PGA gain
|
||||||
|
valid_gains = (3000, 1500, 600, 300, 150, 60, 30)
|
||||||
|
vscale = 679 # These were re-measured with the new build. Zero load.
|
||||||
|
iscale = 18.0 # Based on measurement with 2.5KW resistive load (kettle)
|
||||||
|
|
||||||
|
def __init__(self, simulate=False, verbose=False):
|
||||||
|
self.cb = None
|
||||||
|
self.preprocessor = Preprocessor(simulate, verbose)
|
||||||
|
self.pga = PGA()
|
||||||
|
self.set_range(3000)
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
loop.create_task(self._run())
|
||||||
|
|
||||||
|
async def _run(self):
|
||||||
|
while True:
|
||||||
|
if self.cb is not None:
|
||||||
|
phase, us_vrms, us_irms, us_pwr, nelems = await self.preprocessor.run() # Get unscaled values. Takes 360ms.
|
||||||
|
if self.cb is not None: # May have changed during await
|
||||||
|
vrms = us_vrms * self.vscale
|
||||||
|
irms = us_irms * self.iscale / self.pga_gain
|
||||||
|
pwr = us_pwr * self.iscale * self.vscale / self.pga_gain
|
||||||
|
self.cb(phase, vrms, irms, pwr, nelems, self.preprocessor.overrange)
|
||||||
|
yield
|
||||||
|
|
||||||
|
def set_callback(self, cb=None):
|
||||||
|
self.cb = cb # Set to None to pause acquisition
|
||||||
|
|
||||||
|
def set_range(self, val):
|
||||||
|
if val in self.valid_gains:
|
||||||
|
self.pga_gain = 3000 // val
|
||||||
|
self.pga.gain(self.pga_gain)
|
||||||
|
else:
|
||||||
|
raise ValueError('Invalid range. Valid ranges (W) {}'.format(self.valid_gains))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def vplot(self):
|
||||||
|
return self.preprocessor.vplot
|
||||||
|
|
||||||
|
@property
|
||||||
|
def iplot(self):
|
||||||
|
return self.preprocessor.iplot
|
||||||
|
|
||||||
|
def test():
|
||||||
|
def cb(phase, vrms, irms, pwr, nelems, ovr):
|
||||||
|
print('Phase {:5.1f}rad {:5.0f}Vrms {:6.3f}Arms {:6.1f}W Nsamples = {:3d}'.format(phase, vrms, irms, pwr, nelems))
|
||||||
|
if ovr:
|
||||||
|
print('Overrange')
|
||||||
|
s = Scaling(True, True)
|
||||||
|
s.set_range(300)
|
||||||
|
s.set_callback(cb)
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
loop.run_forever()
|
|
@ -0,0 +1,224 @@
|
||||||
|
# mt.py Display module for the power meter
|
||||||
|
|
||||||
|
# The MIT License (MIT)
|
||||||
|
#
|
||||||
|
# Copyright (c) 2017 Peter Hinch
|
||||||
|
#
|
||||||
|
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
# of this software and associated documentation files (the "Software"), to deal
|
||||||
|
# in the Software without restriction, including without limitation the rights
|
||||||
|
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
# copies of the Software, and to permit persons to whom the Software is
|
||||||
|
# furnished to do so, subject to the following conditions:
|
||||||
|
#
|
||||||
|
# The above copyright notice and this permission notice shall be included in
|
||||||
|
# all copies or substantial portions of the Software.
|
||||||
|
#
|
||||||
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
# THE SOFTWARE.
|
||||||
|
|
||||||
|
import uasyncio as asyncio
|
||||||
|
from mains import Scaling
|
||||||
|
from constants import *
|
||||||
|
from lcd160_gui import Button, Label, Screen, Dropdown, Dial, LED, ButtonList
|
||||||
|
from lplot import CartesianGraph, Curve
|
||||||
|
import font10
|
||||||
|
from lcd_local import setup
|
||||||
|
from utime import ticks_ms, ticks_diff
|
||||||
|
from os import listdir
|
||||||
|
|
||||||
|
if 'mt.py' in listdir('/flash'):
|
||||||
|
mains_device = Scaling() # Real
|
||||||
|
# mains_device = Scaling(True, False) # Simulate
|
||||||
|
else: # Running on SD card - test setup
|
||||||
|
mains_device = Scaling(True, False) # Simulate
|
||||||
|
|
||||||
|
# STANDARD BUTTONS
|
||||||
|
def fwdbutton(y, screen, text, color):
|
||||||
|
def fwd(button, screen):
|
||||||
|
Screen.change(screen)
|
||||||
|
return Button((109, y), font = font10, fontcolor = BLACK, callback = fwd,
|
||||||
|
args = [screen], fgcolor = color, text = text)
|
||||||
|
|
||||||
|
def backbutton():
|
||||||
|
def back(button):
|
||||||
|
Screen.back()
|
||||||
|
return Button((139, 0), font = font10, fontcolor = BLACK, callback = back,
|
||||||
|
fgcolor = RED, text = 'X', height = 20, width = 20)
|
||||||
|
|
||||||
|
def plotbutton(y, screen, color):
|
||||||
|
def fwd(button, screen):
|
||||||
|
Screen.change(screen)
|
||||||
|
return Button((139, y), font = font10, fontcolor = BLACK, callback = fwd,
|
||||||
|
args = [screen], fgcolor = color, text = '~', height = 20, width = 20)
|
||||||
|
|
||||||
|
# **** BASE SCREEN ****
|
||||||
|
|
||||||
|
class BaseScreen(Screen):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.pwr_range = 3000
|
||||||
|
# Buttons
|
||||||
|
fwdbutton(57, IntegScreen, 'Integ', CYAN)
|
||||||
|
fwdbutton(82, PlotScreen, 'Plot', YELLOW)
|
||||||
|
# Labels
|
||||||
|
self.lbl_pf = Label((0, 31), font = font10, width = 75, border = 2, bgcolor = DARKGREEN, fgcolor = RED)
|
||||||
|
self.lbl_v = Label((0, 56), font = font10, width = 75, border = 2, bgcolor = DARKGREEN, fgcolor = RED)
|
||||||
|
self.lbl_i = Label((0, 81), font = font10, width = 75, border = 2, bgcolor = DARKGREEN, fgcolor = RED)
|
||||||
|
self.lbl_p = Label((0,106), font = font10, width = 75, border = 2, bgcolor = DARKGREEN, fgcolor = RED)
|
||||||
|
self.lbl_va = Label((80,106), font = font10, width = 79, border = 2, bgcolor = DARKGREEN, fgcolor = RED)
|
||||||
|
# Dial
|
||||||
|
self.dial = Dial((109, 0), fgcolor = YELLOW, border = 2)
|
||||||
|
# Dropdown
|
||||||
|
self.dropdown = Dropdown((0, 0), font = font10, width = 80, callback = self.cbdb,
|
||||||
|
elements = ('3000W', '600W', '150W', '60W', '30W'))
|
||||||
|
self.led = LED((84, 0), color = GREEN)
|
||||||
|
self.led.value(True)
|
||||||
|
# Dropdown callback: set range
|
||||||
|
def cbdb(self, dropdown):
|
||||||
|
self.pwr_range = int(dropdown.textvalue()[: -1]) # String of form 'nnnW'
|
||||||
|
mains_device.set_range(self.pwr_range)
|
||||||
|
# print('Range set to', self.pwr_range, dropdown.value())
|
||||||
|
|
||||||
|
def reading(self, phase, vrms, irms, pwr, nelems, ovr):
|
||||||
|
# print(phase, vrms, irms, pwr, nelems)
|
||||||
|
self.lbl_v.value('{:5.1f}V'.format(vrms))
|
||||||
|
if ovr:
|
||||||
|
self.lbl_i.value('----')
|
||||||
|
self.lbl_p.value('----')
|
||||||
|
self.lbl_pf.value('----')
|
||||||
|
self.lbl_va.value('----')
|
||||||
|
else:
|
||||||
|
self.lbl_i.value('{:6.3f}A'.format(irms))
|
||||||
|
self.lbl_p.value('{:5.1f}W'.format(pwr))
|
||||||
|
self.lbl_pf.value('PF:{:4.2f}'.format(pwr /(vrms * irms)))
|
||||||
|
self.lbl_va.value('{:5.1f}VA'.format(vrms * irms))
|
||||||
|
self.dial.value(phase + 1.5708) # Conventional phasor orientation.
|
||||||
|
if ovr:
|
||||||
|
self.led.color(RED) # Overrange
|
||||||
|
elif abs(pwr) < abs(self.pwr_range) / 5:
|
||||||
|
self.led.color(YELLOW) # Underrange
|
||||||
|
else:
|
||||||
|
self.led.color(GREEN) # OK
|
||||||
|
|
||||||
|
def on_hide(self):
|
||||||
|
mains_device.set_callback(None) # Stop readings
|
||||||
|
|
||||||
|
def after_open(self):
|
||||||
|
mains_device.set_callback(self.reading)
|
||||||
|
|
||||||
|
# **** PLOT SCREEN ****
|
||||||
|
|
||||||
|
class PlotScreen(Screen):
|
||||||
|
@staticmethod
|
||||||
|
def populate(curve, data):
|
||||||
|
xinc = 1 / len(data)
|
||||||
|
x = 0
|
||||||
|
for y in data:
|
||||||
|
curve.point(x, y)
|
||||||
|
x += xinc
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
backbutton()
|
||||||
|
Label((142, 45), font = font10, fontcolor = YELLOW, value = 'V')
|
||||||
|
Label((145, 70), font = font10, fontcolor = RED, value = 'I')
|
||||||
|
g = CartesianGraph((0, 0), height = 127, width = 135, xorigin = 0) # x >= 0
|
||||||
|
Curve(g, self.populate, args = (mains_device.vplot,))
|
||||||
|
Curve(g, self.populate, args = (mains_device.iplot,), color = RED)
|
||||||
|
|
||||||
|
# **** INTEGRATOR SCREEN ****
|
||||||
|
|
||||||
|
class IntegScreen(Screen):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
# Buttons
|
||||||
|
backbutton()
|
||||||
|
plotbutton(80, PlotScreen, YELLOW)
|
||||||
|
# Labels
|
||||||
|
self.lbl_p = Label((0, 0), font = font10, width = 78, border = 2, bgcolor = DARKGREEN, fgcolor = RED)
|
||||||
|
Label((90, 4), font = font10, value = 'Power')
|
||||||
|
self.lbl_pmax = Label((0, 30), font = font10, width = 78, border = 2, bgcolor = DARKGREEN, fgcolor = RED)
|
||||||
|
Label((90, 34), font = font10, value = 'Max')
|
||||||
|
self.lbl_pin = Label((0, 55), font = font10, width = 78, border = 2, bgcolor = DARKGREEN, fgcolor = RED)
|
||||||
|
self.lbl_pmin = Label((0, 80), font = font10, width = 78, border = 2, bgcolor = DARKGREEN, fgcolor = RED)
|
||||||
|
Label((90, 84), font = font10, value = 'Min')
|
||||||
|
self.lbl_w_hr = Label((0,105), font = font10, width = 78, border = 2, bgcolor = DARKGREEN, fgcolor = RED)
|
||||||
|
self.lbl_t = Label((88, 105), font = font10, width = 70, border = 2, bgcolor = DARKGREEN, fgcolor = RED)
|
||||||
|
|
||||||
|
table = [
|
||||||
|
{'fgcolor' : GREEN, 'text' : 'Max Gen', 'args' : (True,)},
|
||||||
|
{'fgcolor' : BLUE, 'text' : 'Mean', 'args' : (False,)},
|
||||||
|
]
|
||||||
|
bl = ButtonList(self.buttonlist_cb)
|
||||||
|
for t in table: # Buttons overlay each other at same location
|
||||||
|
bl.add_button((90, 56), width = 70, font = font10, fontcolor = BLACK, **t)
|
||||||
|
self.showmean = False
|
||||||
|
self.t_reading = None # Time of last reading
|
||||||
|
self.t_start = None # Time of 1st reading
|
||||||
|
self.joules = 0 # Cumulative energy
|
||||||
|
self.overrange = False
|
||||||
|
self.wmax = 0 # Max power out
|
||||||
|
self.wmin = 0 # Max power in
|
||||||
|
self.pwr_min = 10000 # Power corresponding to minimum absolute value
|
||||||
|
|
||||||
|
def reading(self, phase, vrms, irms, pwr, nelems, ovr):
|
||||||
|
self.wmax = max(self.wmax, pwr)
|
||||||
|
self.wmin = min(self.wmin, pwr)
|
||||||
|
if abs(pwr) < abs(self.pwr_min):
|
||||||
|
self.pwr_min = pwr
|
||||||
|
if ovr:
|
||||||
|
self.overrange = True
|
||||||
|
t_last = self.t_reading # Time of last reading (ms)
|
||||||
|
self.t_reading = ticks_ms()
|
||||||
|
if self.t_start is None: # 1st reading
|
||||||
|
self.t_start = self.t_reading # Time of 1st reading
|
||||||
|
else:
|
||||||
|
self.joules += pwr * ticks_diff(self.t_reading, t_last) / 1000
|
||||||
|
|
||||||
|
secs_since_start = ticks_diff(self.t_reading, self.t_start) / 1000 # Runtime
|
||||||
|
mins, secs = divmod(int(secs_since_start), 60)
|
||||||
|
hrs, mins = divmod(mins, 60)
|
||||||
|
self.lbl_t.value('{:02d}:{:02d}:{:02d}'.format(hrs, mins, secs))
|
||||||
|
if ovr:
|
||||||
|
self.lbl_p.value('----')
|
||||||
|
else:
|
||||||
|
self.lbl_p.value('{:5.1f}W'.format(pwr))
|
||||||
|
|
||||||
|
if self.showmean:
|
||||||
|
self.lbl_pin.value('{:5.1f}W'.format(self.joules / max(secs_since_start, 1)))
|
||||||
|
else:
|
||||||
|
self.lbl_pin.value('{:5.1f}W'.format(self.wmin))
|
||||||
|
|
||||||
|
self.lbl_pmin.value('{:5.1f}W'.format(self.pwr_min))
|
||||||
|
if self.overrange: # An overrange occurred during the measurement
|
||||||
|
self.lbl_w_hr.value('----')
|
||||||
|
self.lbl_pmax.value('----')
|
||||||
|
else:
|
||||||
|
self.lbl_pmax.value('{:5.1f}W'.format(self.wmax))
|
||||||
|
units = self.joules / 3600
|
||||||
|
if units < 1000:
|
||||||
|
self.lbl_w_hr.value('{:6.0f}Wh'.format(units))
|
||||||
|
else:
|
||||||
|
self.lbl_w_hr.value('{:6.2f}KWh'.format(units / 1000))
|
||||||
|
|
||||||
|
def buttonlist_cb(self, button, arg):
|
||||||
|
self.showmean = arg
|
||||||
|
|
||||||
|
def on_hide(self):
|
||||||
|
mains_device.set_callback(None) # Stop readings
|
||||||
|
|
||||||
|
def after_open(self):
|
||||||
|
mains_device.set_callback(self.reading)
|
||||||
|
|
||||||
|
def test():
|
||||||
|
print('Running...')
|
||||||
|
setup()
|
||||||
|
Screen.change(BaseScreen)
|
||||||
|
|
||||||
|
test()
|