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).
|
||||
|
||||
# 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.
|
||||
|
||||
# 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
|
||||
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
|
||||
|
||||
Classes for handling incremental rotary position encoders. Note that the Pyboard timers can
|
||||
do this in hardware. These samples cater for cases where that solution can't be used. The
|
||||
encoder_timed.py sample provides rate information by timing successive edges. In practice this
|
||||
is likely to need filtering to reduce jitter caused by imperfections in the encoder geometry.
|
||||
Classes for handling incremental rotary position encoders. Note that the Pyboard
|
||||
timers can do this in hardware. These samples cater for cases where that
|
||||
solution can't be used. The encoder_timed.py sample provides rate information by
|
||||
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.
|
||||
|
||||
These were written for encoders producing TTL outputs. For switches, adapt the pull definition
|
||||
to provide a pull up or pull down as required.
|
||||
These were written for encoders producing TTL outputs. For switches, adapt the
|
||||
pull definition to provide a pull up or pull down as required.
|
||||
|
||||
The `encoder.portable.py` version should work on all MicroPython platforms.
|
||||
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.
|
||||
|
||||
# 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
|
||||
|
||||
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()
|