basechart.py 10.7 KB
Newer Older
1 2 3 4
"""
This module holds classes that create the HTML and supply the data for Charts and
Reports.
"""
5
import time, logging, copy, importlib
6
from django.conf import settings
7
import yaml
8
import bmsapp.models, bmsapp.readingdb.bmsdata
9
import bmsapp.schedule
10 11
import bmsapp.view_util, bmsapp.data_util
import chart_config
12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36

# Make a logger for this module
_logger = logging.getLogger('bms.' + __name__)


class BldgChartType:
    """Class to provide descriptive information about a particular type of chart related
    to one building.
    """

    def __init__(self, id, title, class_name):
        """Initializes the chart type.

            Args:
                id (int): ID number uniquely identifying this chart type.
                title (string): Text that will be displayed in select controls and chart titles.
                class_name (string): The name of the Python class in this charts.py used to create the chart.
        """
        self.id = id        
        self.title = title  
        self.class_name = class_name   

# These are the possible chart types currently implemented, in the order they will be 
# presented to the User.
BLDG_CHART_TYPES = [
37 38 39 40
    BldgChartType(0, 'Dashboard', 'dashboard.Dashboard'),
    BldgChartType(1, 'Current Sensor Values', 'currentvalues.CurrentValues'),
    BldgChartType(2, 'Plot Sensor Values over Time', 'timeseries.TimeSeries'),
    BldgChartType(3, 'Hourly Profile of a Sensor', 'hourlyprofile.HourlyProfile'),
41 42 43 44
    BldgChartType(4, 'Heat Map Hourly Profile', 'hourly_heatmap.HourlyHeatMap'),
    BldgChartType(5, 'Histogram of a Sensor', 'histogram.Histogram'),
    BldgChartType(6, 'Sensor X vs Y Scatter Plot', 'xyplot.XYplot'),
    BldgChartType(7, 'Download Sensor Data to Excel', 'exportdata.ExportData')
45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76
]

# The ID of the Time Series chart above, as it is needed in code below.
TIME_SERIES_CHART_ID = 2


def find_chart_type(chart_id):
    """Returns the BldgChartType for a given ID.

        Args:
            chart_id (int): The ID number of the requested chart type.

        Returns:
            The BldgChartType having the requested ID.  Returns None if no match.
    """
    for ch in BLDG_CHART_TYPES:
        if ch.id == chart_id:
            return ch

    return None

def get_chart_object(request_params):
    """Returns the appropriate chart object identified by the arguments.

        Args:
            request_params: The parameters (request.GET) passed in by the user further qualifying the chart.

        Returns:
            A chart object descending from BaseChart.
    """

    # Get building ID and chart ID from the request parameters
77 78
    bldg_id = bmsapp.view_util.to_int(request_params['select_bldg'])
    chart_id = bmsapp.view_util.to_int(request_params['select_chart'])
79 80

    if bldg_id=='multi':
81
        chart_info = bmsapp.models.MultiBuildingChart.objects.get(id=chart_id)
82
        class_name = chart_info.chart_class
83 84 85 86
    else:
        chart_info = find_chart_type(chart_id)
        class_name = chart_info.class_name

87 88 89 90 91
    # need to dynamically get a class object based on the class_name.
    # class_name is in the format <module name>.<class name>; break it apart.
    mod_name, bare_class_name = class_name.split('.')
    mod = importlib.import_module('bmsapp.reports.%s' % mod_name.strip())
    
92
    # get a reference to the class referred to by class_name
93
    chart_class = getattr(mod, bare_class_name.strip())
94 95 96 97 98 99 100 101 102

    # instantiate and return the chart from this class
    return chart_class(chart_info, bldg_id, request_params)


class BaseChart(object):
    """Base class for all of the chart classes.
    """

103 104 105 106 107 108
    # Constants to override, if needed for the specific chart being created.
    # These constants affect configuration of the browser user interface that 
    # will be used for this particular chart.

    # This is a comma-separated list of the client HTML controls that need to be
    # visible for this chart.
109
    CTRLS = 'time_period, refresh'
110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132

    # 1 if the Sensor selection control should allow for selecting more than one
    # sensor.
    MULTI_SENSOR = 0

    # 1 if the chart should automatically recalculate and refresh when the user 
    # changes inputs.
    AUTO_RECALC = 1

    # 1 if the chart should automatically refresh every 10 minutes even without 
    # changes in user inputs
    TIMED_REFRESH = 0

    @classmethod
    def data_attributes(cls):
        '''This class method returns an string of HTML data attributes that will
        control aspects of the browser user interface.  Javascript in the browser
        reads these attributes and configures the interface appropriately.
        '''
        return 'data-ctrls="%s" data-multi_sensor="%s" data-auto_recalc="%s" data-timed_refresh="%s"' % \
            (cls.CTRLS, cls.MULTI_SENSOR, cls.AUTO_RECALC, cls.TIMED_REFRESH)


133 134
    def __init__(self, chart_info, bldg_id, request_params):
        """
135 136 137 138 139
        'chart_info' is the models.MultiBuildingChart object for the chart if it
        is a multi-building chart; for single-building charts, it is the BldgChartType
        object (the BldgChartType class is in this module).  'bldg_id'
        is the id of the building being charted, or 'multi' for multi-building
        charts. 'request_params' are the parameters
140 141 142 143 144
        passed in by the user through the Get http request.
        """
        self.chart_info = chart_info
        self.bldg_id = bldg_id

145
        # if this is a chart for a single building, get the associated building model object,
146 147
        # and the occupied schedule for the building if it is present.  Also, determine a 
        # timezone appropriate for this chart.
148
        self.schedule = None
149
        self.timezone = getattr(settings, 'TIME_ZONE', 'US/Alaska').strip()
150
        if bldg_id != 'multi':
151
            self.building = bmsapp.models.Building.objects.get(id=bldg_id)
152 153 154
            # override  the timezone if the building has one explicitly set
            if len(self.building.timezone.strip()):
                self.timezone = self.building.timezone.strip()
155
            if len(self.building.schedule.strip()):
156
                self.schedule = bmsapp.schedule.Schedule(self.building.schedule, self.timezone)
157 158 159 160

        self.request_params = request_params

        # for the multi-building chart object, take the keyword parameter string 
161
        # and convert it to a Python dictionary or list.
162
        if bldg_id == 'multi':
163
            self.chart_params = yaml.load(chart_info.parameters)
164 165 166

        # open the reading database and save it for use by the methods of this object.
        # It is closed automatically in the destructor of the BMSdata class.
167
        self.reading_db = bmsapp.readingdb.bmsdata.BMSdata()
168 169 170 171 172 173 174 175 176 177 178 179

    def get_ts_range(self):
        """
        Returns the start and stop timestamp as determined by the GET parameters that were posted
        from the "time_period" Select control.
        """
        tm_per = self.request_params['time_period']
        if tm_per != "custom":
            st_ts = int(time.time()) - int(tm_per) * 24 * 3600
            end_ts = time.time() + 3600.0    # adding an hour to be sure all records are caught
        else:
            st_date = self.request_params['start_date']
180
            st_ts = bmsapp.data_util.datestr_to_ts(st_date) if len(st_date) else 0
181
            end_date = self.request_params['end_date']
182
            end_ts = bmsapp.data_util.datestr_to_ts(end_date + " 23:59:59") if len(end_date) else time.time() + 3600.0
183 184 185 186 187 188 189 190

        return st_ts, end_ts

    def get_chart_options(self, chart_type='highcharts'):
        """
        Returns a configuration object for a Highcharts or Highstock chart.  Must make a
        copy of the original so that it is not modified.
        """
191 192
        if chart_type == 'highcharts':
            opt = chart_config.highcharts_opt
Ian Moore's avatar
Ian Moore committed
193 194
        elif chart_type == 'plotly':
            opt = chart_config.plotly_opt
195 196 197 198 199 200
        elif chart_type == 'highstock':
            opt = chart_config.highstock_opt
        elif chart_type == 'heatmap':
            opt = chart_config.heatmap_opt
        else:
            raise ValueError('Invalid Chart Type.')
201 202
        opt = copy.deepcopy(opt)
        if hasattr(self, 'building'):
Ian Moore's avatar
Ian Moore committed
203 204 205 206
            if chart_type == 'plotly':
                opt['layout']['title'] = '%s: %s' % (self.chart_info.title, self.building.title)
            else:
                opt['title']['text'] = '%s: %s' % (self.chart_info.title, self.building.title)
207
        else:
Ian Moore's avatar
Ian Moore committed
208 209 210 211
            if chart_type == 'plotly':
                opt['layout']['title'] = self.chart_info.title
            else:
                opt['title']['text'] = self.chart_info.title
212 213
        return opt

214 215 216 217 218 219 220 221 222 223 224 225
    def occupied_resolution(self):
        """Returns a string indicating the resolution to use when determining
        whether a timestamp is in the Occupied or Unoccupied period.  The return
        value depends on how many hours the data is averaged over; this is 
        indicated by the 'averaging_time' GET parameter.
        The possible return values are 
            'exact': classify the timestamp based on the exact schedule times.
            'day': classify the timestamp according to whether it falls in a day
                that is predominantly occupied.
            None: Data averaging is across long intervals that make occupied / 
                unoccupied classification not meaningful.
        """
226 227 228 229 230 231
        # get info about the requested chart
        cht_info = find_chart_type(int(self.request_params['select_chart']))

        # get the requested averaging interval in hours.  The relevant 
        # averaging input control depends on the chart type.
        if 'XY' in cht_info.class_name:
Alan Mitchell's avatar
Alan Mitchell committed
232
            averaging_hours = float(self.request_params['averaging_time_xy'])    
233
        elif 'Export' in cht_info.class_name:
Alan Mitchell's avatar
Alan Mitchell committed
234 235 236
            averaging_hours = float(self.request_params['averaging_time_export'])
        else:
            averaging_hours = float(self.request_params['averaging_time'])
237 238 239 240 241 242 243 244

        if averaging_hours < 24.0:
            return 'exact'
        elif averaging_hours==24.0:
            return 'day'
        else:
            return None

245 246 247 248 249 250 251
    def result(self):
        '''
        This method should be overridden to return a dictionary with an 
        'html' key holding the HTML of the results and an 'objects' key
        holding a list of JavaScript objects to create.  Each object is a
        two-tuple with the first element being the string identifying the
        object type and the second element being a configuration dictionary
252 253 254 255 256 257
        for that object type.  'bmsappX-Y.Z.js' must understand the string
        describing the JavaScript object.
        Alternatively, this method can return a django.http.HttpResponse
        object, which will be returned directly to the client application;
        this approach is used the exportdata.ExportData class to return an
        Excel spreadsheet.
258 259 260
        '''
        return {'html': self.__class__.__name__, 'objects': []}