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, 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_()) #