Power data logger with GUI

I made this system to monitor and record the power production of my photovoltaic plant and the power consumed by the house and the heat pumps. The reason for this is to investigate the convenience of a battery storage. This is my first computer graphical interface, made in Python 3.7 with the PyQt 5.

The active power is read by using a three-phase multimeter with three hall effect current sensors. Being the system single phase, the three voltages inputs of the multimeter are connected together to the single phase. The three current sensors read: current from the photovoltaic plant, current to the heat pump, current to the house (including heat pump). The multimeter does the calculation of the active power.

Electric cabinet, before:

Electric cabinet, after:

 

The multimeter is connected to an Arduino Uno with a RS485 interface. The Arduino read the measured power using the Modbus RTU protocol and send them via radio in csv format using a HC-12 433 Mhz serial radio module.

A second HC-12 is connected to the computer using a serial TTL to USB adapter with CH340 chip. The computer is running Ubuntu 16.04, Python 3.7, and PyQt 5.

 

About current measurements

I could have used the SCT0013 (15 A/ 1V) current transformers which are available on ebay for 5€ each, quite popular to use with Arduino. The problem is that I would have need also to measure the voltage, and calculate the active power with Arduino, which isn’t fast enough. The 10 bit ADC resolution isn’t enough, Atmega328 does not have hardware multiplication. So, a 150-300€ multimeter was chosen for better accuracy.

Obviously, I could not resist to open up the expensive Electrex Femto D4 multimer to see what components it use. The main chip is a ADUC7022 from Analog Devices. There are two LVC4066 (four single pole, single-throw analog switch functions). Two 050L photo-transistor optocoupler (probably for the two digital outputs). One i2c 24LC32 Eeprom probably used to store the settings. The RS485 transceiver is a VP12 from Texas.

It seems that all the analog and digital circuits are not isolated from the high voltage side. Maybe the optocouplers are used to isolate the RS485 chip.

 

Challenges I faced

Making a 32 bit floating point from Modbus variables

The Modbus is meant to read/write 16 bit values. The multimeter uses two Modbus addresses to provide a 32 bit (floating point) number. So, the software in the Arduino makes the 32 bit value merging the two 16 bit byte. We define the variable u as a type union. u is a 32 bit variable which can be read as a usigned integer 32 bit, or as a floating point. So, we write the one 16 bit byte into the first half of the 32 bit byte, then the other 16 bit byte into the second half of the 32 bit byte. Then, we read it as if it was (it is!) a floating point variable, into the P1, which is defined as float.

//variable definition
float P1,P2,P3;

union
{
uint32_t x;
float f;
} u;

// in the program loop we make the float value from the two 16 bit integer
u.x=(((unsigned long)data[0] << 16) | data[1]); P1=u.f;

 

Threading (multi tasking) in the GUI

One loop of the program need to read the incoming serial data, while a second independent loop need to update the GUI (graphical user interface). If only one loop is used, the graphical interface is frozen while waiting the next incoming character.

The multi tasking is made using the threading module provided in the PyQt binding (not the multithreading of python).

 

Arduino program

/*
* Optimum load for PV plant
* Giorgio Demurtas
* 10-07-2018
* v2
*/


/*
 * Pin connections to LCD 84x48, like nokia 5510
 * Note that the LCD works at 3.3V, and put a 10 k resistor between LCD and arduino Uno pin
 * 
 * Pin connection to current sensor

 */
 
#include <SPI.h>
#include <Adafruit_GFX.h>
#include <Adafruit_PCD8544.h>


#include <ModbusMaster.h>
/*!
  We're using a MAX485-compatible RS485 Transceiver.
  Rx/Tx is hooked up to the hardware serial port at 'Serial'.
  The Data Enable and Receiver Enable pins are hooked up as follows:
*/
#define MAX485_DE      2
#define MAX485_RE_NEG  2

#define LCD_LIGHT 9
// instantiate ModbusMaster object
ModbusMaster node;

void preTransmission()
{
  digitalWrite(MAX485_RE_NEG, 1);
  digitalWrite(MAX485_DE, 1);
}
void postTransmission()
{
  digitalWrite(MAX485_RE_NEG, 0);
  digitalWrite(MAX485_DE, 0);
}


//-------serial for the radio, software
#include <SoftwareSerial.h>
SoftwareSerial radioSerial(11, 10); // RX, TX


//--------LCD 
// Software SPI (slower updates, more flexible pin options):
// pin 7 - Serial clock out (SCLK)
// pin 6 - Serial data out (DIN)
// pin 5 - Data/Command select (D/C)
// pin 4 - LCD chip select (CS)
// pin 3 - LCD reset (RST)
Adafruit_PCD8544 display = Adafruit_PCD8544(7, 6, 5, 4, 3);


//---variabili
float produzione, consumo,scambio;
long i2sum, i;
int n;
float RMS, offset, I_RMS, Power;

unsigned long timing;

int k, result;

float Power_485;
union
  {
    uint32_t x;
    float f;
  } u;

int txid=2;


//-------------------------------------------------------------------
void setup() {
  pinMode(LCD_LIGHT, INPUT);
  
  // DISPLAY
  display.begin();
  display.setContrast(50);   // you can change the contrast around to adapt the display for the best viewing!
  display.clearDisplay();   // clears the screen and buffer
  display.setTextSize(1);
  display.setTextColor(BLACK);
  display.setCursor(0,0);
  display.println("optiload_v3"); 
  display.println("Transmitter");
  display.println("");
  display.println("ingdemurtas.it");
  display.display();

  //SERIAL RADIO
  radioSerial.begin(9600);
  radioSerial.println("Radio serial begin");

  //RS485 
  pinMode(MAX485_RE_NEG, OUTPUT);
  pinMode(MAX485_DE, OUTPUT);
  // Init in receive mode
  digitalWrite(MAX485_RE_NEG, 0);
  digitalWrite(MAX485_DE, 0);
  Serial.begin(19200, SERIAL_8N1); // Modbus communication runs at 
  node.begin(2, Serial); // Modbus slave ID 
  // Callbacks allow us to configure the RS485 transceiver correctly
  node.preTransmission(preTransmission);
  node.postTransmission(postTransmission);




  delay(1000); 
}
//-------------------------------------------------------------------

void loop() {

  readTA();

  //read485();
  
} //end of loop

//--------------------------------------------------------------

void readTA(){
  if(millis()>timing){
    RMS=sqrt(i2sum/n);
    //offset=0.0004*(analogRead(A1)-512);
    offset=0;
    I_RMS=0.073313782*RMS+offset; //Amper efficaci (15A = 1V, 5V=1023 ---- 15*5/1023=0.073313782)
    Power=I_RMS*230;
    serial_out();
    update_lcd();
    i2sum=0; n=0; //azzera somma e numero di campioni
    timing=millis()+100; //periodo di campionamento in millisecondi, multiplo di 20 ms
  } else sample();  
}
  
void sample(){
  i=analogRead(A0)-512; //* (15/1023); // corrente istantanea
  i2sum=i2sum + (i*i);
  n++;
}

//-------------------------------------------------------------
void update_lcd(){
   // Print the power 
  display.clearDisplay();
  display.setTextColor(BLACK);
  display.setTextSize(2);
  
  display.setCursor(30,0);
  if(I_RMS<0) display.setCursor(20,0);
  display.print(I_RMS,1); 
  display.setCursor(70,0);  display.println("A"); 

  int ch=12;
  display.setCursor(0+5,20);
  
  if(Power<0) display.setCursor(ch*3+5,20);
  if(Power<=-10) display.setCursor(ch*2+5,20);
  if(Power>=0) display.setCursor(ch*4+5,20);
  if(Power>=10) display.setCursor(ch*3+5,20);
  if(Power>=100) display.setCursor(ch*2+5,20);
  if(Power>=1000) display.setCursor(ch*1+5,20);
  display.println(Power,0); 
  display.setCursor(70,20);  display.println("W");
  
  display.setTextSize(1);
  display.setCursor(0,40);
  //display.println(Power_485);
  display.print("ID:");
  display.println(txid);

  display.setCursor(75,40);
  display.println("-");
  
  
  display.display();  
  delay(500);

  display.setCursor(75,40);
  display.println("_");
  display.display(); 

  if(Power>10){
    pinMode(LCD_LIGHT, OUTPUT);
    digitalWrite(LCD_LIGHT, 0);
  } else pinMode(LCD_LIGHT, INPUT);
  
}


void serial_out(){
  
  
  /*Serial.print(txid); Serial.print(", ");
  Serial.print(i); Serial.print(", ");
  Serial.print(n); Serial.print(", ");
  Serial.print(i2sum); Serial.print(", ");
  Serial.print(RMS); Serial.print(", ");
  Serial.print(I_RMS); Serial.print("A, ");
  Serial.print(Power,0); Serial.print("W, ");
  Serial.print(offset);
  Serial.println("");


 */
  radioSerial.print(txid); radioSerial.print(",");
  //radioSerial.print(n); radioSerial.print(", ");
  //radioSerial.print(i2sum); radioSerial.print(", ");
  //radioSerial.print(RMS); radioSerial.print(",");
  radioSerial.print(I_RMS); radioSerial.print(",");
  radioSerial.print(Power,0); radioSerial.print("");
  //radioSerial.print(Power_485,0); radioSerial.print(", ");
  //radioSerial.print(offset);
   radioSerial.println("");
}

//---------------------------------------------------------------

void read485(){
  uint16_t data[]={0,0,0,0,0,0};
  float measures[]={0,0,0,0};
  // Read n registers starting at 220 
  //V1 is registers 220 221
  //P1 is registers 240-241
  int n=6;// locations to read
  int start_at=220;  //starting at address
  
  result = node.readInputRegisters(start_at, n);
  if (result == node.ku8MBSuccess)
  {
    radioSerial.println("----data----");
    //leggi tutti i registri del multimetro
    int j;
    for (j = 0; j < n; j++)
    {
      data[j] = node.getResponseBuffer(j);
      radioSerial.print(" [");
      radioSerial.print(j+start_at);
      radioSerial.print("]=");
      radioSerial.print(data[j]);
      if((j%2)==1) radioSerial.println("");
      delay(50);
    }
    radioSerial.println("read completed");
    
    //combina i registri nelle variabili float
    int k;
    j=0;
    radioSerial.println("");
    radioSerial.println("--- values ---");
    for(j = 0; j < n; j=j+2){
      u.x = (((unsigned long)data[j] << 16) | data[j+1]);
      float z = u.f;
      measures[k]=z;
      radioSerial.print("val_"); radioSerial.print(j+start_at);  radioSerial.print(" = ");  
      radioSerial.println(z); 
      k++;
    }
    
    Power=measures[0];
    update_lcd();

    
  } else radioSerial.println("485 read fail");
  delay(1000);
}

 

Python program

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Created on Tue Aug  29 17:40:44 2018

@author: gio
"""

import numpy as np

import serial
import glob
import datetime
#from serial import Serial
import sys

from PyQt5.QtWidgets import (QDialog, QApplication, QWidget,  QPushButton,
                             QComboBox, QVBoxLayout, QCheckBox, QLCDNumber,
                             QSlider, QProgressBar, QHBoxLayout, QLabel)
from PyQt5 import QtCore
from PyQt5.QtCore import QThread, pyqtSignal

from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar
import matplotlib.pyplot as plt

#import random, time

timespan=600
load=[0]*timespan  
production=[0]*timespan
pdc=[0]*timespan




class WorkerThread(QThread):
    #mysignal_i=pyqtSignal( int, name='Signal_i') ### 1) declare the signal
    measurements_signals=pyqtSignal(int, int, name='m_signals') ### 1) declare the signal

    def __init__(self, parent=None):
        QThread.__init__(self)
        #super(WorkerThread, self).__init__(parent)
   
    def run(self):
        print("reading")
        ser = serial.Serial(
            port='/dev/ttyUSB0', # use "dmesg | grep tty" to find out the port
            baudrate=9600,
            bytesize=serial.EIGHTBITS,
            parity=serial.PARITY_NONE,
            stopbits=serial.STOPBITS_ONE
        ) 
        
        datastring=""
        while ser.isOpen():
            c=ser.read(size=1).decode("ASCII") #better ASCII
            if(c=='\n'): #arduino terminate strings with \n\r
                print("received: "+datastring)
                self.writeData(datastring)
                values = datastring.split(",")
                print(values[1])
                self.measurements_signals.emit(int(values[3]), int(values[1]))
                datastring=""
                #update_chart(int(values[0]), int(values[1]), int(values[2]), int(values[3]))
            else:
                datastring=datastring+c#.decode('utf-8')
            #print datetime.utcnow().isoformat(), datastring   
            if self.isInterruptionRequested():
               print ("exit while loop of reading serial")
               ser.close()
               self.terminate() 
        ser.close()

    def writeData(self, value):
        # Get the current data
        now = datetime.datetime.now()
        today = datetime.date.today() 
        today=now.strftime("%Y-%m-%d")
        t=now.strftime("%Y-%m-%d %H:%M:%S")
        # Open log file 2012-6-23.log and append
        logline=t+","+value+'\n'
        print(logline) 
#        with open('/home/gio/python_prove/'+str(today)+'.csv', 'a') as f: 
#            f.write(logline) 
#            f.close()
        
    def stop(self):
        #self.ser.close()
        self.terminate()    
        print("stop")



class Window(QWidget):
    def __init__(self, parent=None):
        super(Window, self).__init__(parent)
        
        
        self.setGeometry(100, 100, 800, 600) #pos pos width height
                
                
        self.PortLabel=QLabel("Port:")
        self.LabelProd=QLabel("Production:")
        self.LabelLoad=QLabel("Load:")
        
        
        port_selectBox = QComboBox(self)
        ports=self.available_serial_ports()
        
        for port in ports:
            port_selectBox.addItem(port)
        
        
        self.buttonConnect = QPushButton('Connect')
        #self.button.clicked.connect(self.plot)
        
        
        self.b1 = QCheckBox("SerialRead")
        self.b1.setChecked(True)
        #self.b1.stateChanged.connect(self.myThread.btnstate(self.b1))
        
        self.b2 = QCheckBox("activateLog")
        self.b2.setChecked(True)
        #self.b2.stateChanged.connect(lambda:self.btnstate(self.b2))
        
        
        self.figure_bar        = plt.figure()
        self.figure_timeseries = plt.figure()
  
        self.canvas_bar        = FigureCanvas(self.figure_bar)
        self.canvas_timeseries = FigureCanvas(self.figure_timeseries)
        
        # this is the Navigation widget
        # it takes the Canvas widget and a parent
        #self.toolbar = NavigationToolbar(self.canvas, self)

        # set the layout
        
        #self.b1.stateChanged.connect(self.SerialRead(self,b1))
        self.b1.stateChanged.connect(lambda:self.SerialRead(self.b1))
        self.b2.stateChanged.connect(self.SerialLog)


        self.lcdProd = QLCDNumber(self)
        self.lcdLoad = QLCDNumber(self)
        self.lcdProd.setFixedHeight(100)
        self.lcdLoad.setFixedHeight(100)
        
       
        #--------------------------- Layout
        
        col1=QVBoxLayout()
        col1.addWidget(self.PortLabel)
        col1.addWidget(port_selectBox)
        col1.addWidget(self.buttonConnect)
        col1.addWidget(self.b1)
        col1.addWidget(self.b2)
        
        col2=QVBoxLayout()
        col2.addWidget(self.LabelProd)
        col2.addWidget(self.lcdProd)
        col2.addWidget(self.LabelLoad)
        col2.addWidget(self.lcdLoad)
        
        
        toprow=QHBoxLayout()
        toprow.addLayout(col1)
        toprow.addLayout(col2)
        toprow.addWidget(self.canvas_bar)
        
        layout = QVBoxLayout()
        layout.addLayout(toprow)
        layout.addWidget(self.canvas_timeseries)
        
        #layout.addWidget(self.toolbar)
        #layout.addWidget(self.button)
        self.setLayout(layout)
        
        #---------------------------------------------------
        
        
        self.wt=WorkerThread() # This is the thread object
        self.wt.start()
        # Connect the signal from the thread to the slot_method
        self.wt.measurements_signals.connect(self.slot_method)  ### 3) connect to the slot
        app.aboutToQuit.connect(self.wt.stop) #to stop the thread when closing the GUI
        
        timespan=600
        load=[0]*timespan  
        production=[550]*timespan
        pdc=[0]*timespan
        
    def slot_method(self, p,l):
        print("p=", p)
        print("l=", l)
        self.lcdProd.display(p)
        self.lcdLoad.display(l)
        self.update_chart_timeseries(p,l)
        self.update_chart_bar(p,l)
        
    def SerialRead(self,b):
        enable=b.isChecked()
        print("enable="+str(enable))
        #self.myThread.start()
        
    def SerialLog(self,b2):
        print("b2")
        
        
    def threadDone(self):
        print("Done")
        
        
#    def update_chart(self, produzione, carico):
#        load.pop(0)
#        load.append(carico)
#        production.pop(0)
#        production.append(produzione)
#        
#        self.figure.clear() #questo è importante
#        plt.plot(production, color="b")
#        plt.plot(load, color="r")
#        #plt.set_ylim([0,max(load, production)])
#        plt.ylim(ymin=0) 
#        plt.legend(['PV', 'Load'], loc='upper left')
#        self.canvas.draw()


    def update_chart_bar(self, produzione, carico): #bar
        objects = ('Carico','Produzione', 'Immissione')
        y_pos = np.arange(len(objects))
        immissione=produzione-carico
        performance = [carico, produzione, immissione]
        
        self.figure_bar.clear()
        plt.figure(num=self.figure_bar.number) #aggiunta da massimo maggi :)
        plt.bar(y_pos, performance, align='center', alpha=0.5)
        plt.xticks(y_pos, objects)
        plt.ylabel('Power [W]')
        plt.title('Power usage') #is printing this in the wrong canvas
        self.canvas_bar.draw()


    def update_chart_timeseries(self, produzione, carico): #time series
        load.pop(0)
        load.append(carico)
        production.pop(0)
        production.append(produzione)
        
        self.figure_timeseries.clear() #questo è importante
        plt.figure(num=self.figure_timeseries.number)#aggiunta da massimo maggi :)
        plt.plot(production, color="b")
        plt.plot(load, color="r")
        #plt.set_ylim([0,max(load, production)])
        plt.ylim(ymin=0) 
        plt.legend(['PV', 'Load'], loc='upper left')
        self.canvas_timeseries.draw()

            
        
    def available_serial_ports(self):
        """ Lists serial port names
    
            :raises EnvironmentError:
                On unsupported or unknown platforms
            :returns:
                A list of the serial ports available on the system
        """
        if sys.platform.startswith('win'):
            ports = ['COM%s' % (i + 1) for i in range(256)]
        elif sys.platform.startswith('linux') or sys.platform.startswith('cygwin'):
            # this excludes your current terminal "/dev/tty"
            ports = glob.glob('/dev/tty[A-Za-z]*')
        elif sys.platform.startswith('darwin'):
            ports = glob.glob('/dev/tty.*')
        else:
            raise EnvironmentError('Unsupported platform')
    
        result = []
        for port in ports:
            try:
                s = serial.Serial(port)
                s.close()
                result.append(port)
            except (OSError, serial.SerialException):
                pass
        return result

        

if __name__ == '__main__':
    app = QApplication(sys.argv)
    main = Window()
    #main.minimumWidth(800) 
    main.show()
    sys.exit(app.exec_())
#

    



 

 

 

 

 

Potrebbe interessarti: