/** * Runtime summary card. Compare to the ecobee weather impact chart. * * @param {number} thermostat_id The thermostat_id this card is displaying * data for. */ beestat.component.card.runtime_thermostat_summary = function(thermostat_id) { var self = this; this.thermostat_id_ = thermostat_id; /* * Initialize a variable to store when the card was first loaded to guess how * long the sync will take to complete. */ this.sync_begin_m_ = moment(); this.sync_begin_progress_ = beestat.thermostat.get_sync_progress(thermostat_id); /* * When a setting is changed clear all of the data. Then rerender which will * trigger the loading state. Also do this when the cache changes. * * Debounce so that multiple setting changes don't re-trigger the same * event. This fires on the trailing edge so that all changes are accounted * for when rerendering. */ var change_function = beestat.debounce(function() { self.rerender(); }, 10); beestat.dispatcher.addEventListener( [ 'setting.runtime_thermostat_summary_time_count', 'setting.runtime_thermostat_summary_time_period', 'setting.runtime_thermostat_summary_group_by', 'setting.runtime_thermostat_summary_gap_fill', 'setting.runtime_thermostat_summary_smart_scale', 'cache.runtime_thermostat_summary' ], change_function ); beestat.component.card.apply(this, arguments); }; beestat.extend(beestat.component.card.runtime_thermostat_summary, beestat.component.card); /** * Decorate. * * @param {rocket.Elements} parent */ beestat.component.card.runtime_thermostat_summary.prototype.decorate_contents_ = function(parent) { var container = $.createElement('div').style({ 'position': 'relative' }); parent.appendChild(container); var chart_container = $.createElement('div'); container.appendChild(chart_container); var data = this.get_data_(); this.chart_ = new beestat.component.chart.runtime_thermostat_summary(data); this.chart_.render(chart_container); var sync_progress = beestat.thermostat.get_sync_progress(this.thermostat_id_); if (sync_progress === null) { chart_container.style('filter', 'blur(3px)'); var no_data = $.createElement('div'); no_data.style({ 'position': 'absolute', 'top': 0, 'left': 0, 'width': '100%', 'height': '100%', 'display': 'flex', 'flex-direction': 'column', 'justify-content': 'center', 'text-align': 'center' }); no_data.innerText('No data to display'); container.appendChild(no_data); } else if (sync_progress < 100) { var time_taken = moment.duration(moment().diff(this.sync_begin_m_)); var percent_taken = sync_progress - this.sync_begin_progress_; var percent_per_second = percent_taken / time_taken.asSeconds(); var time_remain = (100 - sync_progress) / percent_per_second; var string_remain; if (time_remain === Infinity) { string_remain = 'A few minutes'; } else { if (time_remain > 59) { string_remain = Math.round(time_remain / 60) + 'm '; } else { string_remain = Math.round(time_remain) + 's'; } } this.show_loading_('Syncing (' + sync_progress + '%)
' + string_remain + ' remaining'); window.setTimeout(function() { var api = new beestat.api(); api.add_call( 'runtime_thermostat_summary', 'read_id', {}, 'runtime_thermostat_summary' ); api.add_call( 'thermostat', 'read_id', { 'attributes': { 'inactive': 0 } }, 'thermostat' ); api.set_callback(function(response) { beestat.cache.set('thermostat', response.thermostat); beestat.cache.set('runtime_thermostat_summary', response.runtime_thermostat_summary); }); api.send(); }, 10000); } }; /** * Get all of the series data. * * @return {object} The series data. */ beestat.component.card.runtime_thermostat_summary.prototype.get_data_ = function() { var data = { 'x': [], 'series': {}, 'metadata': { 'series': {}, 'chart': { 'title': this.get_title_(), 'subtitle': this.get_subtitle_() } } }; [ 'sum_compressor_cool_1', 'sum_compressor_cool_2', 'sum_compressor_heat_1', 'sum_compressor_heat_2', 'sum_auxiliary_heat_1', 'sum_auxiliary_heat_2', 'sum_fan', 'sum_humidifier', 'sum_dehumidifier', 'sum_ventilator', 'sum_economizer', 'avg_outdoor_temperature', 'avg_outdoor_humidity', 'min_outdoor_temperature', 'max_outdoor_temperature', 'extreme_outdoor_temperature', 'sum_heating_degree_days', 'sum_cooling_degree_days', 'avg_indoor_temperature', 'avg_indoor_humidity' ].forEach(function(series_code) { data.series[series_code] = []; data.metadata.series[series_code] = { 'active': false }; }); var buckets = this.get_buckets_(); if (buckets === null) { return data; } var begin_m; if (beestat.setting('runtime_thermostat_summary_time_period') === 'all') { begin_m = moment(beestat.cache.thermostat[this.thermostat_id_].sync_begin); } else { var time_periods = [ 'day', 'week', 'month', 'year' ]; /** * See #145. This makes the date range more intuitive when the group by * duration is less than the time period you select. */ var subtract; if ( time_periods.indexOf(beestat.setting('runtime_thermostat_summary_group_by')) < time_periods.indexOf(beestat.setting('runtime_thermostat_summary_time_period')) ) { subtract = 0; } else { subtract = 1; } begin_m = moment() .subtract( (beestat.setting('runtime_thermostat_summary_time_count') - subtract), beestat.setting('runtime_thermostat_summary_time_period') ) .startOf( beestat.setting('runtime_thermostat_summary_group_by') === 'week' ? 'isoweek' : beestat.setting('runtime_thermostat_summary_group_by') ); } // Make sure the current month, etc gets included (see #159). var end_m = moment() .endOf( beestat.setting('runtime_thermostat_summary_group_by') === 'week' ? 'isoweek' : beestat.setting('runtime_thermostat_summary_group_by') ); var current_m = begin_m; while (current_m.isSameOrAfter(end_m) === false) { var next_m = current_m .clone() .add(1, beestat.setting('runtime_thermostat_summary_group_by')); var bucket_key = this.get_bucket_key_( current_m, beestat.setting('runtime_thermostat_summary_group_by') ); var bucket = buckets[bucket_key]; if (bucket !== undefined) { data.x.push(current_m.clone()); for (var key in data.series) { if (key === 'extreme_outdoor_temperature') { // Outdoor temperature extremes if ( bucket !== undefined && bucket.min_outdoor_temperature !== null && bucket.max_outdoor_temperature !== null ) { data.series.extreme_outdoor_temperature.push([ current_m.clone(), bucket.min_outdoor_temperature, bucket.max_outdoor_temperature ]); data.metadata.series[key].active = true; } else { data.series.extreme_outdoor_temperature.push(null); } } else { var value = (bucket !== undefined) ? bucket[key] : null; /* * If Gap Fill is on, and it's a gap fillable value, and it's not the * last bucket, gap fill it. */ if ( beestat.setting('runtime_thermostat_summary_gap_fill') === true && key.substring(0, 3) === 'sum' && next_m.isSameOrAfter(end_m) === false ) { value = this.gap_fill_( value, bucket.count, beestat.setting('runtime_thermostat_summary_group_by'), bucket_key ); } data.series[key].push(value); var this_active = key.includes('temperature') ? true : (value !== 0); data.metadata.series[key].active = data.metadata.series[key].active || this_active; } } } current_m.add(1, beestat.setting('runtime_thermostat_summary_group_by')); } return data; }; /** * Just calls a couple of helper functions to get the buckets. * * @return {object} The buckets. */ beestat.component.card.runtime_thermostat_summary.prototype.get_buckets_ = function() { if (beestat.cache.runtime_thermostat_summary.length === 0) { return null; } return this.get_buckets_combined_(this.get_buckets_group_()); }; /** * Combine all the runtime_thermostat_summary rows into one row per * day/week/month/year. Each bucket key has an array of values, not a sum, * average, etc. * * @return {object} The buckets. */ beestat.component.card.runtime_thermostat_summary.prototype.get_buckets_group_ = function() { var buckets = {}; for (var runtime_thermostat_summary_id in beestat.cache.runtime_thermostat_summary) { var runtime_thermostat_summary = beestat.cache.runtime_thermostat_summary[ runtime_thermostat_summary_id ]; if (runtime_thermostat_summary.thermostat_id === this.thermostat_id_) { var bucket_key = this.get_bucket_key_( moment.utc(runtime_thermostat_summary.date), beestat.setting('runtime_thermostat_summary_group_by') ); if (buckets[bucket_key] === undefined) { buckets[bucket_key] = { 'count': [], 'sum_compressor_cool_1': [], 'sum_compressor_cool_2': [], 'sum_compressor_heat_1': [], 'sum_compressor_heat_2': [], 'sum_auxiliary_heat_1': [], 'sum_auxiliary_heat_2': [], 'sum_fan': [], 'sum_humidifier': [], 'sum_dehumidifier': [], 'sum_ventilator': [], 'sum_economizer': [], 'avg_outdoor_temperature': [], 'avg_outdoor_humidity': [], 'min_outdoor_temperature': [], 'max_outdoor_temperature': [], 'sum_heating_degree_days': [], 'sum_cooling_degree_days': [], 'avg_indoor_temperature': [], 'avg_indoor_humidity': [] }; } for (var key in buckets[bucket_key]) { buckets[bucket_key][key].push(runtime_thermostat_summary[key]); } } } return buckets; }; /** * Get the key for a bucket from a date and a grouping. * * @param {moment} date_m * @param {string} group_by day|week|month|year * * @return {string} The bucket key. */ beestat.component.card.runtime_thermostat_summary.prototype.get_bucket_key_ = function(date_m, group_by) { var bucket_key; switch (group_by) { case 'day': bucket_key = date_m.format('YYYY-DDDD'); break; case 'week': bucket_key = date_m.format('YYYY-WW'); break; case 'month': bucket_key = date_m.format('YYYY-MM'); break; case 'year': bucket_key = date_m.format('YYYY'); break; } return bucket_key; }; /** * Combine the individual array values in each bucket key by getting the sum, * average, min, max, etc. * * @param {object} buckets The buckets. * * @return {object} The combined buckets. */ beestat.component.card.runtime_thermostat_summary.prototype.get_buckets_combined_ = function(buckets) { // Basically just excludes degree_days. const keys_to_convert_from_seconds_to_hours = [ 'sum_compressor_cool_1', 'sum_compressor_cool_2', 'sum_compressor_heat_1', 'sum_compressor_heat_2', 'sum_auxiliary_heat_1', 'sum_auxiliary_heat_2', 'sum_fan', 'sum_humidifier', 'sum_dehumidifier', 'sum_ventilator', 'sum_economizer' ]; for (var bucket_key in buckets) { var bucket = buckets[bucket_key]; bucket.count = bucket.count.reduce(function(accumulator, current_value) { return accumulator + current_value; }, 0); for (var key in buckets[bucket_key]) { switch (key.substring(0, 3)) { case 'avg': var sum = bucket[key].reduce(function(accumulator, current_value) { return accumulator + current_value; }, 0); bucket[key] = sum / bucket[key].length; if (key.substring(key.length - 11) === 'temperature') { bucket[key] = beestat.temperature(bucket[key]); } bucket[key] = Math.round(bucket[key]); break; case 'min': bucket[key] = Math.min.apply(null, bucket[key]); if (key.substring(key.length - 11) === 'temperature') { bucket[key] = beestat.temperature(bucket[key]); } break; case 'max': bucket[key] = Math.max.apply(null, bucket[key]); if (key.substring(key.length - 11) === 'temperature') { bucket[key] = beestat.temperature(bucket[key]); } break; case 'sum': bucket[key] = bucket[key].reduce(function(accumulator, current_value) { return accumulator + current_value; }, 0); /* * This is a really good spot for Gap Fill to happen but it doesn't work * here because there's no order to the buckets so I can't ignore the * last bucket. */ // Convert seconds to hours. if (keys_to_convert_from_seconds_to_hours.includes(key) === true) { bucket[key] /= 3600; } break; } } } return buckets; }; /** * Try to account for missing data based on how much is missing from the series. * * @param {number} value The sum to gap fill. * @param {number} count The number of values in the sum. * @param {string} group_by How the data is grouped. * @param {string} bucket_key Which group this is in. * * @return {number} The gap filled sum. */ beestat.component.card.runtime_thermostat_summary.prototype.gap_fill_ = function(value, count, group_by, bucket_key) { var adjustment_factor; var year; var month; switch (group_by) { case 'year': year = bucket_key; var is_leap_year = moment(year, 'YYYY').isLeapYear(); var days_in_year = is_leap_year === true ? 366 : 365; adjustment_factor = days_in_year * 288; break; case 'month': year = bucket_key.substring(0, 4); month = bucket_key.substring(5, 7); var days_in_month = moment(year + '-' + month, 'YYYY-MM').daysInMonth(); adjustment_factor = days_in_month * 288; break; case 'week': adjustment_factor = 2016; break; case 'day': adjustment_factor = 288; break; } return value * adjustment_factor / count; }; /** * Get the title of the card. * * @return {string} The title. */ beestat.component.card.runtime_thermostat_summary.prototype.get_title_ = function() { return 'Thermostat Summary'; }; /** * Decorate the menu * * @param {rocket.Elements} parent */ beestat.component.card.runtime_thermostat_summary.prototype.decorate_top_right_ = function(parent) { var self = this; var menu = (new beestat.component.menu()).render(parent); if (beestat.thermostat.get_sync_progress(this.thermostat_id_) !== null) { menu.add_menu_item(new beestat.component.menu_item() .set_text('Past 3 Months') .set_icon('calendar_month') .set_callback(function() { if ( beestat.setting('runtime_thermostat_summary_time_count') !== 3 || beestat.setting('runtime_thermostat_summary_time_period') !== 'month' || beestat.setting('runtime_thermostat_summary_group_by') !== 'day' ) { beestat.setting({ 'runtime_thermostat_summary_time_count': 3, 'runtime_thermostat_summary_time_period': 'month', 'runtime_thermostat_summary_group_by': 'day' }); } })); menu.add_menu_item(new beestat.component.menu_item() .set_text('Past 12 Months') .set_icon('calendar_month') .set_callback(function() { if ( beestat.setting('runtime_thermostat_summary_time_count') !== 12 || beestat.setting('runtime_thermostat_summary_time_period') !== 'month' || beestat.setting('runtime_thermostat_summary_group_by') !== 'week' ) { beestat.setting({ 'runtime_thermostat_summary_time_count': 12, 'runtime_thermostat_summary_time_period': 'month', 'runtime_thermostat_summary_group_by': 'week' }); } })); menu.add_menu_item(new beestat.component.menu_item() .set_text('All Time') .set_icon('calendar_month') .set_callback(function() { if ( beestat.setting('runtime_thermostat_summary_time_count') !== 0 || beestat.setting('runtime_thermostat_summary_time_period') !== 'all' || beestat.setting('runtime_thermostat_summary_group_by') !== 'month' ) { beestat.setting({ 'runtime_thermostat_summary_time_count': 0, 'runtime_thermostat_summary_time_period': 'all', 'runtime_thermostat_summary_group_by': 'month' }); } })); menu.add_menu_item(new beestat.component.menu_item() .set_text('Custom') .set_icon('calendar_edit') .set_callback(function() { (new beestat.component.modal.runtime_thermostat_summary_custom()).render(); })); menu.add_menu_item(new beestat.component.menu_item() .set_text('Download Chart') .set_icon('download') .set_callback(function() { self.chart_.export(); })); menu.add_menu_item(new beestat.component.menu_item() .set_text('Reset Zoom') .set_icon('magnify_close') .set_callback(function() { self.chart_.reset_zoom(); })); } menu.add_menu_item(new beestat.component.menu_item() .set_text('Help') .set_icon('help_circle') .set_callback(function() { window.open('https://doc.beestat.io/3225b739ebbc42d68a18260565fda4f1'); })); }; /** * Get the subtitle of the card. * * @return {string} The subtitle. */ beestat.component.card.runtime_thermostat_summary.prototype.get_subtitle_ = function() { var s = (beestat.setting('runtime_thermostat_summary_time_count') > 1) ? 's' : ''; var string = ''; if (beestat.setting('runtime_thermostat_summary_time_period') === 'all') { string = 'All time'; } else { string = 'Past ' + beestat.setting('runtime_thermostat_summary_time_count') + ' ' + beestat.setting('runtime_thermostat_summary_time_period') + s; } string += ', ' + ' grouped by ' + beestat.setting('runtime_thermostat_summary_group_by'); const gap_fill_string = beestat.setting('runtime_thermostat_summary_gap_fill') === true ? 'On' : 'Off'; const smart_scale_string = beestat.setting('runtime_thermostat_summary_smart_scale') === true ? 'On' : 'Off'; string += ' (Gap Fill: ' + gap_fill_string + ', Smart Scale: ' + smart_scale_string + ')'; return string; };