Close

Raspberry Tests with new python code

A project log for Single SuperCapacitor UPS for Raspberry Pi

A last gasp Uninterruptible Power Supply for any Raspberry Pi computer.

bud-bennettBud Bennett 12/11/2019 at 05:430 Comments

The updated LG5 boards just arrived from OSH Park today, so all of these results, such as they are, were obtained with a modified LG4 board. 

I decided to update the python code that monitors the status of the ups and decides when to shutdown the Pi. I took current load data on alll of the Raspberry Pi units that aren’t currently dedicated: RPi1B, RPi2B, RPi3B, RPiZW. The single-core units, RPi1B and RPiZW, were measured at idle, 100%, and shutdown. The 2B and 3B units were measured at idle, 25%, 50%, 75%, 100%, and shutdown. This is the data that I obtained on the units that I measured. 

All of the RPi models were connected to HDMI, a powered USB expander for the keyboard and mouse, and had a 16GB thumb drive connected to the USB on the RPi (except the RPiZW). The other columns of the spreadsheet calculate the minimum capacitance values required to support the model at max current with dual or single supercapacitors, and the parameters for counting out the period to shutdown as a function of cpu loading.

The new code now takes into account the number and value of supercaps, the Raspberry Pi model, and the loading of the cpu on a dynamic basis in order to determine when to begin the shutdown procedure. The code relies on psutil to calculate the percentage of cpu activity on a second-by-second basis and adjust the time to shutdown accordingly. Since the code is written in python3, the psutil module is installed by:

sudo apt-get update
sudo apt-get install python3-psutil

powerfail.py [Edit 2019-12-14: made a few changes to improve robustness. Improved the calculation to determine time to recharge the supercap if PWRGOOD goes high before a shutdown event. Changed the calculation for a single supercapacitor to allow voltage drop to 2.3V, as long as the current draw is less than 1A. Latest code can be downloaded from the files section of this project.]

#!/usr/bin/env python
'''
This program monitors the status of the PWRGOOD signal from the UPS circuit.
PWRGOOD is normally high. If it goes low then the input power has failed.
This program senses when PWRGOOD falls and then samples it once/second. An
accumulator counts up by 1+(cpu_pc * (imax/idle - 1)) if power is bad and
counts down by icharge/idle if power is good. This accounts for the difference in
supercap charge current vs. load current.
If the accumulator exceeds count_max then it signals the UPS to disconnect the
power by asserting SHUTDOWN. This will still cause a shutdown condition even when
the power is cycling on and off. After it commits the hardware to shutdown it also
sends a shutdown command to the Linux system. The UPS will hold power up for 20 seconds
after it receives the SHUTDOWN signal, which allows the Pi to enter a shutdown state
prior to power removal.
'''
import RPi.GPIO as GPIO
import time
import logging
import logging.handlers
import psutil
from math import sqrt
import subprocess, sys

#--logger definitions
logger = logging.getLogger(__name__) 
logger.setLevel(logging.INFO)  # Could be e.g. "TRACE", "ERROR", "" or "WARNING"
handler = logging.handlers.RotatingFileHandler("/var/log/bud_logs/ups_mon.log", maxBytes=10000, backupCount=4) # save 4 logs
formatter = logging.Formatter('%(asctime)s %(levelname)-8s %(message)s')
handler.setFormatter(formatter)
logger.addHandler(handler)

class MyLogger():
    '''
    A class that can be used to capture stdout and sterr to put it in the log

    '''
    def __init__(self, level, logger):
            '''Needs a logger and a logger level.'''
            self.logger = logger
            self.level = level

    def write(self, message):
        # Only log if there is a message (not just a new line)
        if message.rstrip() != "":
                self.logger.log(self.level, message.rstrip())

    def flush(self):
        pass  # do nothing -- just to handle the attribute for now


def powerfail_detect(count_max, idle, imax, ichrg, cap_num, cap_value):
    # if the count exceeds 20 the system will shutdown.
    count = 0
    Vsys = 5.0
    if cap_num == 2:
        cap = cap_value/cap_num
        Vmax = 4.5
    else:
        cap = cap_value
        Vmax = 2.68
    pwr_flip = False
    while (count < count_max):
        cpu_pc = psutil.cpu_percent(interval=1)/100 # get cpu percent over the last second
        if (GPIO.input(PWRGOOD)):
            if (pwr_flip):
                logger.info("Power is good")
                logger.info("Count = {}".format(int(count)))
                # calculate time to recharge supercap
                watt_sec = 1.1 * Vsys * idle/1000 * count  # assume booster is 90% efficient
                #logger.info("watt_sec = {}".format(watt_sec))
                count_dn = count/(cap*(Vmax - sqrt(Vmax*Vmax - 2*watt_sec/cap))/(ichrg/1000))
                if count_dn < 1:
                    count_dn = 1
                #logger.info("Count_dn = {}".format(count_dn))
            count -= count_dn  
            pwr_flip = False
            if (count <= 0):
                logger.info("Returning to normal status")
                return
        else:
            if (not pwr_flip):
                logger.info("Power has failed")
            count += 1+(cpu_pc * (imax/idle - 1)) # load current dependent
            pwr_flip = True
    logger.info("powerfail iminent...shutting down the Pi!\n")

    GPIO.setup(SHUTDOWN,GPIO.OUT)
    GPIO.output(SHUTDOWN,0)
    time.sleep(0.1)
    GPIO.output(SHUTDOWN,1)
    time.sleep(0.1)
    
    p = subprocess.Popen(["sudo","shutdown","-H","now"])  # shutdown the Pi
    GPIO.cleanup()
    haltandcatchfire()
    return

def haltandcatchfire():
    while True:
        time.sleep(10) # hang here until poweroff
    return

# If this script is installed as a Daemon, set this flag to True:
DAEMON = True # when run as daemon, pipe all console information to the log file
# --Replace stdout and stderr with logging to file so we can run it as a daemon
# and still see what is going on
if DAEMON :
    sys.stdout = MyLogger(logging.INFO, logger)
    sys.stderr = MyLogger(logging.ERROR, logger)

# get Raspberry Pi model information
p = subprocess.Popen(["cat","/proc/device-tree/model"], stdout = subprocess.PIPE)
model = p.communicate()[0].decode("utf-8")

# set UPS parameters
CAP_NUM = 1
CAP_VALUE = 100  # value of single cap, or each of dual caps
ICHRG = 1000  # charge current in mA
if ("Raspberry Pi Zero W" in model):
    logger.info(" ")
    logger.info("Setting UPS parameters for {}".format(model))
    IDLE = 130
    IMAX = 225
    SEC2SHUTDN = 20 # seconds required for Pi to complete shutdown
elif ("Raspberry Pi Model B" in model):
    logger.info(" ")
    logger.info("Setting UPS parameters for {}".format(model))
    IDLE = 380
    IMAX = 411
    SEC2SHUTDN = 20 # seconds required for Pi to complete shutdown
elif ("Raspberry Pi 2 Model B" in model):
    logger.info(" ")
    logger.info("Setting UPS parameters for {}".format(model))
    IDLE = 304
    IMAX = 511
    SEC2SHUTDN = 20 # seconds required for Pi to complete shutdown
elif ("Raspberry Pi 3 Model B" in model):
    logger.info(" ")
    logger.info("Setting UPS parameters for {}".format(model))
    IDLE = 300
    IMAX = 825
    SEC2SHUTDN = 20 # seconds required for Pi to complete shutdown
else:
    logger.error("No UPS parameters for {}\n\tUPS is disabled.".format(model))
    haltandcatchfire  # sleep instead of terminate to avoid daemon restart

if (CAP_NUM == 2):
    # how many seconds of power loss before the Pi shuts down if idle
    COUNT_MAX = 0.9*CAP_VALUE/CAP_NUM * (4.5**2 - 2.4**2)/(2*1.1*4.9*IDLE/1000) - SEC2SHUTDN
elif (CAP_NUM == 1):
    COUNT_MAX = CAP_VALUE/CAP_NUM * (2.68**2 - 2.3**2)/(2*1.1*4.9*IDLE/1000) - SEC2SHUTDN
else:
    logger.error("Invalid number of supercapacitors -- UPS is disabled.")
    haltandcatchfire()

# set the GPIO pin assignments
PWRGOOD = 23
SHUTDOWN = 24

# configure the GPIO ports:
GPIO.setmode(GPIO.BCM)
# PWRGOOD is an input with weak pullup
GPIO.setup(PWRGOOD, GPIO.IN, pull_up_down = GPIO.PUD_UP)

try:
    logger.info("Enabled")
    logger.info("\tNumber of Supercaps: {}".format(CAP_NUM))
    logger.info("\tSupercap value: {}F".format(CAP_VALUE))
    logger.info("\tSupercap Charging current: {}mA".format(ICHRG))
    logger.info("\tLoad Current at Idle: {}mA".format(IDLE))
    logger.info("\tMax Load Current: {}mA".format(IMAX))
    logger.info("\tSeconds of Power Loss at Idle: {}".format(int(COUNT_MAX)))
    if (COUNT_MAX < 5 or (IMAX > 1000 and CAP_NUM == 1)):
        logger.error("UPS is unable to support {} with given parameters.".format(model))
        haltandcatchfire()
    while True:
        time.sleep(1)
        if (not GPIO.input(PWRGOOD)):
            powerfail_detect(count_max=COUNT_MAX, idle=IDLE, imax=IMAX, ichrg=ICHRG,
                             cap_num=CAP_NUM, cap_value=CAP_VALUE)
            
except Exception as e:
    print (e)
    GPIO.cleanup()  # clean up GPIO on CTRL+C exit 

fourcores.py -- cpu loading is changed by varying the number in the for loop

#!/usr/bin/env python3

import time
import datetime
import multiprocessing

def busy():
    junknum = 0
    while True:
        junknum += 1

if __name__ == '__main__':
    jobs = []
    for i in range(4):
        p = multiprocessing.Process(target=busy)
        jobs.append(p)
        p.start()

I tested the code on the modified LG4 board, with two 25F supercaps, by running the fourcores.py program with a varying number of cores engaged and then pulled the power adapter from the wall. In some cases, if there was time, I would start and stop the fourcores program to see if powerfail.py could accurately predict the point at which to shutdown the system -- how close it would come to 2.5V at SCAP+. The code appears to work as intended on the limited number of Raspberry Pi models that I tested -- RPiZW and RPi2B. If it shut down too early, then the UPS would just hold up the power for several seconds until the SCAP voltage fell below 2.45V. It did not shutdown too late during my rather relaxed testing. I'll perform more thorough testing when the LG5 board is built.

Discussions