diff --git a/README.md b/README.md index da791b7..b6964b9 100644 --- a/README.md +++ b/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). diff --git a/power/README.md b/power/README.md new file mode 100644 index 0000000..42dea84 --- /dev/null +++ b/power/README.md @@ -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. diff --git a/power/SignalConditioner.fzz b/power/SignalConditioner.fzz new file mode 100644 index 0000000..aefad47 Binary files /dev/null and b/power/SignalConditioner.fzz differ diff --git a/power/images/IMAGES.md b/power/images/IMAGES.md new file mode 100644 index 0000000..6a4b88a --- /dev/null +++ b/power/images/IMAGES.md @@ -0,0 +1,23 @@ +# Sample Images + +#Puhbuttons + +![Exterior](./outside.JPG) + +![Interior](./interior.JPG) +Interior construction. + +![Microwave](./microwave.JPG) +Microwave oven. +![Plot](./plot.JPG) +Microwave waveforms. + +![Integrate](./integrate.JPG) +Integration screen. + +![Underrange](./underrange.JPG) +Soldering iron on 3KW range. + +![Correct range](./correctrange.JPG) +Same iron on 30W range + diff --git a/power/images/correctrange.JPG b/power/images/correctrange.JPG new file mode 100644 index 0000000..6c648e4 Binary files /dev/null and b/power/images/correctrange.JPG differ diff --git a/power/images/integrate.JPG b/power/images/integrate.JPG new file mode 100644 index 0000000..cc32c8a Binary files /dev/null and b/power/images/integrate.JPG differ diff --git a/power/images/interior.JPG b/power/images/interior.JPG new file mode 100644 index 0000000..d1ab720 Binary files /dev/null and b/power/images/interior.JPG differ diff --git a/power/images/microwave.JPG b/power/images/microwave.JPG new file mode 100644 index 0000000..d474387 Binary files /dev/null and b/power/images/microwave.JPG differ diff --git a/power/images/outside.JPG b/power/images/outside.JPG new file mode 100644 index 0000000..4110d14 Binary files /dev/null and b/power/images/outside.JPG differ diff --git a/power/images/plot.JPG b/power/images/plot.JPG new file mode 100644 index 0000000..7ab6e4e Binary files /dev/null and b/power/images/plot.JPG differ diff --git a/power/images/underrange.JPG b/power/images/underrange.JPG new file mode 100644 index 0000000..9ff330f Binary files /dev/null and b/power/images/underrange.JPG differ diff --git a/power/mains.py b/power/mains.py new file mode 100644 index 0000000..657c6ac --- /dev/null +++ b/power/mains.py @@ -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() diff --git a/power/mt.py b/power/mt.py new file mode 100644 index 0000000..30bbc94 --- /dev/null +++ b/power/mt.py @@ -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()