Building Energy Boot Camp 2018 - Day 8

Once again, today was mostly spent on our projects.

I spent the majority of the time attempting to figure out a system to create a request thread, and kill it when necessary. Sadly, Python doesn’t offer a method of killing thread, so I realized I’d have to make one myself, and therefore decided to first get the request threads working, then figure out how to kill them, but I had a lot of trouble dealing with threads. I plan to fully rework my system tomorrow, and give request threads their own class.

Once again, I don’t much to show except my code, which you can find below. I’ll explain everything in great detail on Day 9, when I plan to be finished.

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

from tkinter import *
import pandas as pd
import numpy
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'

request_thread = None
air_values = None

background_data_update = True


def save_data():
    if air_values is not None:
        print('Saving session data... please wait')
        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 = round(int(temp_value)) if temp_value else ''
                    temp_units = temp_units.replace('deg ', '°') if temp_units else ''
                    co2_value = round(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"))
                    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'])

                    print(row['Label'])
                    break

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


def update_loaded_data(updated_df):
    global air_values
    air_values = updated_df
    if air_values is not None:
        air_values['Wing'] = air_values['Wing'].to_string()
        fill_fields(floor.get(), str(wing.get()), measurement.get())


def add_to_cache(new_data_df):
    global air_values
    if air_values is None:
        air_values = new_data_df
    else:
        air_values = pd.concat([air_values, new_data_df], ignore_index=True)

    if air_values is not None:
        if air_values['Floor'].dtype != numpy.float64:
            air_values['Floor'] = air_values['Floor'].astype(str).astype(int)
        fill_fields(floor.get(), str(wing.get()), measurement.get())


def request(hostname, port, selected_floor, selected_wing, background):
    global background_data_update

    try:
        df = get_air_values_df(hostname, port, selected_floor, selected_wing, background)
        add_to_cache(df)

        background_data_update = True

    except KeyboardInterrupt:
        stop()


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)

    def request_data(self, selected_floor, selected_wing):
        global background_data_update
        background_data_update = False  # Stop the background updater

        global request_thread
        request_thread = threading.Thread(target=request, args=(HOSTNAME, PORT, selected_floor, selected_wing, False))
        request_thread.start()


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} EST".format(data_timestamp))
    row_labels[1].config(text=(str(round(avg_measure, 2)) + ' ' + str(unit)))
    row_labels[2].config(text=(str(round(max_measure, 2)) + ' ' + str(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'

    selected_df = None
    global request_thread

    # Check if the session cache has data, request the data otherwise
    if air_values is not None:
        matching_floor = air_values['Floor'] == selected_floor
        matching_wing = air_values['Wing'] == selected_wing
        filtered_rooms = air_values[matching_floor & matching_wing]
        if len(filtered_rooms) >= 1 and len(filtered_rooms[measurement_column]) != 0:
            # The session cache has non-empty data for the wing
            enough_info = True
            selected_df = air_values
        else:
            if request_thread is None or not request_thread.isAlive():
                thread.request_data(selected_floor, selected_wing)

    else:
        if request_thread is None or not request_thread.isAlive():
            thread.request_data(selected_floor, selected_wing)

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

    if not enough_info:
        df = pd.read_csv(SAVED_DATA_PATH, index_col=False)

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

    # TODO: Fallback to an emergency file

    if enough_info:

        matching_floor = selected_df['Floor'] == selected_floor
        matching_wing = selected_df['Wing'] == selected_wing

        filtered_rooms = selected_df[matching_floor & matching_wing]

        if not filtered_rooms.empty:
            filtered_rooms[measurement_column] = pd.to_numeric(filtered_rooms[measurement_column], errors='coerce')

            for row_index, row in filtered_rooms[measurement_column].iteritems():
                if filtered_rooms[unit_column].get(row_index) == '':
                    continue

                unit = filtered_rooms[unit_column].get(row_index)
                break

            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']

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

            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()