Building Energy Boot Camp 2018 - Day 7

Today began with a TEDx talk. It was focused on climate change, and what you, as an individual, can do to help stop it. Afterwards, we continued to work on our projects for the rest of the day. As I am no longer away and have access to the school’s internet, that meant that I had to start working on using the buildingEnergyAPI to get live data from the various sensors, and do my calculations on that data. You can see my code for the day below:

# -*- coding: utf-8 -*-

from tkinter import *
import pandas as pd
import os
import threading
import time

pd.options.mode.chained_assignment = None  # Stop chained assignment warnings - I know what I'm doing

SAVED_DATA_PATH = os.path.join('CSVs', 'ahs_air_data.csv')
HOSTNAME = '10.12.4.98'
PORT = '8000'

air_values = None

background_data_update = False


def save_data():
    if air_values is not None:
        air_values.to_csv(SAVED_DATA_PATH)


def stop():
    import sys
    save_data()
    sys.exit()


def get_air_values_df(hostname, port, selected_floor, selected_wing, background_updater):
    df_dictionary = {
        'Date / Time': [],
        'Room': [],
        'Temperature': [],
        'Temperature Units': [],
        'CO2 Level': [],
        'CO2 Units': [],
        'Floor': [],
        'Wing': []
    }

    try:
        import argparse
        from bacnet_gateway_requests import get_value_and_units
        import datetime as dt

        # Read spreadsheet into a DataFrame.
        # Each row contains the following:
        #   - Location
        #   - Instance ID of CO2 sensor
        #   - Instance ID of temperature sensor
        df = pd.read_csv(os.path.join('CSVs', 'ahs_air.csv'), na_filter=False, comment='#')

        matching_floor = df['Floor'] == str(selected_floor)
        matching_wing = df['Wing'] == selected_wing

        filtered_rooms = df[matching_floor & matching_wing]

        # Iterate over the rows of the DataFrame, getting temperature and CO2 values for each location
        for row_index, row in filtered_rooms.iterrows():
            while True:
                if background_updater and not background_data_update:
                    time.sleep(5)
                    continue
                else:
                    # Retrieve data
                    temp_value, temp_units = get_value_and_units(row['Facility'], row['Temperature'], hostname, port)
                    co2_value, co2_units = get_value_and_units(row['Facility'], row['CO2'], hostname, port)

                    # Prepare to print
                    temp_value = int(temp_value) if temp_value else ''
                    temp_units = temp_units.replace('deg ', '°') if temp_units else ''
                    co2_value = int(co2_value) if co2_value else ''
                    co2_units = co2_units if co2_units else ''

                    # Update dictionary
                    df_dictionary['Date / Time'].append(dt.datetime.now().strftime("%m/%d/%Y %H:%M:%S"))
                    df_dictionary['Room'].append(row['Label'])
                    df_dictionary['Temperature'].append(temp_value)
                    df_dictionary['Temperature Units'].append(temp_units)
                    df_dictionary['CO2 Level'].append(co2_value)
                    df_dictionary['CO2 Units'].append(co2_units)
                    df_dictionary['Floor'].append(row['Floor'])
                    df_dictionary['Wing'].append(row['Wing'])
                    break

        return pd.DataFrame.from_dict(df_dictionary)
    except KeyboardInterrupt:
        stop()


def update_loaded_data(updated_df):
    global air_values
    air_values = updated_df


class BACnetThread(object):
    """
    The run() method will be started and it will run in the background
    until the application exits.
    """

    def __init__(self, interval=10):
        """ Constructor
        :type interval: int
        :param interval: Check interval, in seconds
        """
        self.interval = interval
        self.used_combos = None
        self.updated_values = None

        thread = threading.Thread(target=self.run, args=())
        thread.daemon = True  # Daemonize thread
        thread.start()  # Start the execution

    def run(self):
        """ Method that runs forever """
        while True:
            # Updates the already-requested rooms
            if air_values is not None:
                # Find which floor-wing combinations have been used so far
                self.used_combos = air_values.groupby(
                    ['Wing', 'Floor']).size().reset_index()

                for row_index, row in self.used_combos.iterrows():
                    if row['Floor'] == '' and row['Wing'] == '':
                        self.used_combos.drop(row_index)
                        continue
                    df = get_air_values_df(HOSTNAME, PORT, row['Floor'], row['Wing'], True)
                    self.updated_values = pd.concat([self.updated_values, df],
                                                    ignore_index=True) if self.updated_values is not None else df

                update_loaded_data(self.updated_values)

                self.updated_values = None

            time.sleep(self.interval)

    # TODO: def request_data(self):


thread = BACnetThread()


def update_labels(avg_measure, max_measure, max_measure_room, unit, data_timestamp):
    row_labels[0].config(
        text="Data last updated at: {0}/{1}/{2} {3}:{4}".format(data_timestamp.month, data_timestamp.day,
                                                                data_timestamp.year, data_timestamp.hour,
                                                                "%02d" % data_timestamp.minute))
    row_labels[1].config(text=(str(round(avg_measure, 2)) + ' ' + unit))
    row_labels[2].config(text=(str(round(max_measure, 2)) + ' ' + unit))
    row_labels[3].config(text=str(max_measure_room))


def fill_fields(selected_floor, selected_wing, selected_measurement):
    enough_info = False
    measurement_column = 'CO2 Level' if selected_measurement == 0 else 'Temperature'
    unit_column = 'CO2 Units' if selected_measurement == 0 else 'Temperature Units'

    if len(air_values[(air_values['Floor'] == selected_floor) & (air_values['Wing'] == selected_wing)]) >= 1 and len(
            air_values[measurement_column]) != 0:
        # The session cache has non-empty data for the wing
        enough_info = True

    # TODO: Check if the output file from the last session has data

    # TODO: Fallback to an emergency file

    if enough_info:
        matching_floor = air_values['Floor'] == selected_floor
        matching_wing = air_values['Wing'] == selected_wing

        filtered_rooms = air_values[matching_floor & matching_wing]

        unit = filtered_rooms[unit_column].iloc[len(filtered_rooms[unit_column]) - 1]  # All are the same units

        avg_measure = filtered_rooms[measurement_column].mean()

        max_measure = 0
        max_measure_room = 'None'

        for row_index, row in filtered_rooms.iterrows():
            if row[measurement_column] > max_measure:
                max_measure = row[measurement_column]
                max_measure_room = row['Room']

        filtered_rooms['Date / Time'] = pd.to_datetime(filtered_rooms['Date / Time'], format='%m/%d/%Y %H:%M',
                                                       utc=True) + pd.Timedelta('5:00:00')

        import time
        utc_difference_in_hours = time.timezone / 3600.0  # Returns a value like 4.5 (hours)
        utc_split = str(utc_difference_in_hours).split('.')  # Turns 4.5 into ['4', '5']
        decimal = int(utc_split[1]) / pow(10, len(utc_split[1]))  # Turns the '5' into 0.5

        tz_difference_string = "{0}:{1}:00".format(utc_split[0], "%02d" % (round(decimal * 60)))  # Build the string

        filtered_rooms['Date / Time'] -= pd.Timedelta(tz_difference_string)

        data_timestamp = filtered_rooms['Date / Time'].get(0)

        update_labels(avg_measure, max_measure, max_measure_room, unit, data_timestamp)


root = Tk()
root.title("AHS Air Data")
root.configure(background='white')
root.resizable(False, False)

wing = StringVar(root, value='A')  # The selected wing
floor = IntVar(root, value=1)  # The selected floor
measurement = IntVar(root, value=1)  # The selected measurement


def set_wing():
    fill_fields(floor.get(), str(wing.get()), measurement.get())


def set_floor():
    fill_fields(floor.get(), str(wing.get()), measurement.get())


def set_measurement():
    fill_fields(floor.get(), str(wing.get()), measurement.get())


# Setup table layout
COLUMN_TITLES = ['Floor', 'Wing', 'Measurement', 'Average', 'Maximum', 'Room Number With Maximum']
col_number = 0
row_labels = []

for col in COLUMN_TITLES:
    label = Label(text=col, fg="Blue", bg="White", width="30")

    label.grid(row=0, column=col_number, pady=(10, 0), sticky='we', ipady="2")

    # Add floor options
    if col_number == 0:
        FLOOR_NAMES = ['1st', '2nd', '3rd']
        current_row = 1

        for floor_name in FLOOR_NAMES:
            Radiobutton(text=floor_name, fg="Black", bg="White", variable=floor, value=int(floor_name[0]),
                        command=set_floor).grid(row=current_row, column=col_number, sticky='we')
            current_row += 1

    # Add wing options
    elif col_number == 1:
        WING_LETTERS = ['A', 'B', 'C', 'D']
        current_row = 1
        for index, wing_letter in enumerate(WING_LETTERS):
            if index == len(WING_LETTERS) - 1:
                Radiobutton(text=wing_letter, fg="Black", bg="White", variable=wing, value=wing_letter,
                            command=set_wing).grid(row=current_row, column=col_number, sticky='we',
                                                   pady=(0, 10))
            else:
                Radiobutton(text=wing_letter, fg="Black", bg="White", variable=wing, value=wing_letter,
                            command=set_wing).grid(row=current_row, column=col_number, sticky='we')
            current_row += 1

        row_label = Label(bg="White", fg="Blue", relief=FLAT, text="Data last updated at: 1/1/1970 00:00")
        row_labels.append(row_label)
        row_label.grid(row=current_row, column=0, columnspan=len(COLUMN_TITLES), sticky='we', ipady="2", padx=10,
                       pady=(0, 20))

    # Add measurement options

    elif col_number == 2:
        MEASUREMENTS = ['CO2', 'Temperature']
        current_row = 1

        for index, measure in enumerate(MEASUREMENTS):
            Radiobutton(text=measure, fg="Black", bg="White", variable=measurement, value=index,
                        command=set_measurement).grid(row=current_row, column=col_number, sticky='we')
            current_row += 1

    else:
        # Create empty cell for value
        row_label = Label(bg="White", fg="Black", relief=RIDGE, width="30")
        row_labels.append(row_label)

        row_label.grid(row=1, column=col_number, sticky='we', ipady="2", padx=5)

    col_number += 1

root.grid_columnconfigure(0, weight=1)
fill_fields(floor.get(), str(wing.get()), measurement.get())
try:
    root.mainloop()
except KeyboardInterrupt:
    stop()

A major issue that I found was that getting all the data for every room at once was slow. In fact, getting data for any individual wing would take at least 10 seconds! And I realized that having to wait 10 seconds before the application even opens makes the application impractical. So, I began learning threading, and started with the updating thread BACnetThread class. I shall explain its logic further on ​Day 9, but for now, all you really need to know is that it checks which wing-floor combinations have been loaded, and updates them one by one.