diff --git a/api/index.php b/api/index.php index 8ab68d6..c6efe7f 100644 --- a/api/index.php +++ b/api/index.php @@ -49,5 +49,14 @@ function array_median($array) { return $median; } +// Useful function +function array_average($array) { + if (count($array) === 0) { + return null; + } + + return array_sum($array) / count($array); +} + // Go! $cora->process_request($_REQUEST); diff --git a/api/profile.php b/api/profile.php new file mode 100644 index 0000000..bb44362 --- /dev/null +++ b/api/profile.php @@ -0,0 +1,776 @@ + [], + 'public' => [] + ]; + + public static $cache = [ + 'generate' => 604800 // 7 Days + ]; + + /** + * Generate a profile for the specified thermostat. + * + * @param int $thermostat_id + * + * @return array + */ + public function generate($thermostat_id) { + set_time_limit(0); + + // Make sure the thermostat_id provided is one of yours since there's no + // user_id security on the runtime_thermostat table. + $thermostats = $this->api('thermostat', 'read_id'); + if (isset($thermostats[$thermostat_id]) === false) { + throw new Exception('Invalid thermostat_id.', 10300); + } + + /** + * This is an interesting thing to fiddle with. Basically, the longer the + * minimum sample duration, the better your score. For example, let's say + * I set this to 10m and my 30° delta is -1°. If I increase the time to + * 60m, I may find that my 30° delta decreases to -0.5°. + * + * Initially I thought something was wrong, but this makes logical sense. + * If I'm only utilizing datasets where the system was completely off for + * a longer period of time, then I can infer that the outdoor conditions + * were favorable to allowing that to happen. Higher minimums most likely + * only include sunny periods with low wind. + * + * For now this is set to 30m, which I feel is an appropriate requirement. + * I am not factoring in any variables outside of temperature for now. + * Note that 30m is a MINIMUM due to the event_runtime_thermostat_text_id logic that + * will go back in time by 30m to account for sensor changes if the + * calendar event changes. + */ + $minimum_sample_duration = [ + 'heat_1' => 300, + 'heat_2' => 300, + 'auxliary_heat_1' => 300, + 'auxliary_heat_2' => 300, + 'cool_1' => 300, + 'cool_2' => 300, + 'resist' => 1800 + ]; + + /** + * How long the system must be on/off for before starting a sample. Setting + * this to 5 minutes will use the very first sample which is fine if you + * assume the temperature in the sample is taken at the end of the 5m. + */ + $minimum_off_for = 300; + $minimum_on_for = 300; + + /** + * Increasing this value will decrease the number of data points by + * allowing for larger outdoor temperature swings in a single sample. For + * example, a value of 1 will start a new sample if the temperature + * changes by 1°, and a value of 5 will start a new sample if the + * temperature changes by 5°. + */ + $smoothing = 1; + + /** + * Require this many individual samples in a delta for a specific outdoor + * temperature. Increasing this basically cuts off the extremes where + * there are fewer samples. + */ + $required_samples = 2; + + /** + * Require this many individual points before a valid temperature profile + * can be returned. + */ + $required_points = 5; + + /** + * How far back to query for additional data. For example, when the + * event_runtime_thermostat_text_id changes I pull data from 30m ago. If that data is + * not available in the current runtime chunk, then it will fail. This + * will make sure that data is always included. + */ + $max_lookback = 1800; // 30 min + + /** + * How far in the future to query for additional data. For example, if a + * sample ends 20 minutes prior to an event change, I need to look ahead + * to see if an event change is in the future. If so, I need to adjust for + * that because the sensor averages will already be wrong. + */ + $max_lookahead = 1800; // 30 min + + /** + * Attempt to ignore the effects of solar heating by only looking at + * samples when the sun is down. + */ + $ignore_solar_heating = true; + + // Get some stuff + $thermostat = $this->api('thermostat', 'get', $thermostat_id); + + // Figure out all the starting and ending times. Round begin/end to the + // nearest 5 minutes to help with the looping later on. + $end_timestamp = time(); + $begin_timestamp = strtotime('-1 year', $end_timestamp); + + // Round to 5 minute intervals. + $begin_timestamp = floor($begin_timestamp / 300) * 300; + $end_timestamp = floor($end_timestamp / 300) * 300; + + $group_thermostats = $this->api( + 'thermostat', + 'read', + [ + 'attributes' => [ + 'thermostat_group_id' => $thermostat['thermostat_group_id'], + 'inactive' => 0 + ] + ] + ); + + // Get all of the relevant data + $thermostat_ids = []; + foreach($group_thermostats as $thermostat) { + $thermostat_ids[] = $thermostat['thermostat_id']; + } + + /** + * Get the largest possible chunk size given the number of thermostats I + * have to select data for. This is necessary to prevent the script from + * running out of memory. Also, as of PHP 7, structures have 6-7x of + * memory overhead. + */ + $memory_limit = 16; // mb + $memory_per_thermostat_per_day = 0.6; // mb + $days = (int) floor($memory_limit / ($memory_per_thermostat_per_day * count($thermostat_ids))); + + $chunk_size = $days * 86400; + + if($chunk_size === 0) { + throw new Exception('Too many thermostats; cannot generate temperature profile.', 10301); + } + + $current_timestamp = $begin_timestamp; + $chunk_end_timestamp = 0; + $five_minutes = 300; + $thirty_minutes = 1800; + $all_off_for = 0; + $heat_1_on_for = 0; + $heat_2_on_for = 0; + $auxiliary_heat_1_on_for = 0; + $auxiliary_heat_2_on_for = 0; + $cool_1_on_for = 0; + $cool_2_on_for = 0; + $samples = []; + $first_timestamp = null; + $setpoints = [ + 'heat' => [], + 'cool' => [] + ]; + $begin_runtime = []; + + while($current_timestamp <= $end_timestamp) { + // Get a new chunk of data. + if($current_timestamp >= $chunk_end_timestamp) { + $chunk_end_timestamp = $current_timestamp + $chunk_size; + + $query = ' + select + `timestamp`, + `thermostat_id`, + `indoor_temperature`, + `outdoor_temperature`, + `compressor_1`, + `compressor_2`, + `compressor_mode`, + `auxiliary_heat_1`, + `auxiliary_heat_2`, + `system_mode`, + `setpoint_heat`, + `setpoint_cool`, + `event_runtime_thermostat_text_id`, + `climate_runtime_thermostat_text_id` + from + `runtime_thermostat` + where + `thermostat_id` in (' . implode(',', $thermostat_ids) . ') + and `timestamp` >= "' . date('Y-m-d H:i:s', ($current_timestamp - $max_lookback)) . '" + and `timestamp` < "' . date('Y-m-d H:i:s', ($chunk_end_timestamp + $max_lookahead)) . '" + '; + $result = $this->database->query($query); + + // Move some things around so that heat/cool/aux columns are + // consistently represented instead of having to do this logic + // throughout the generator. + $runtime = []; + while($row = $result->fetch_assoc()) { + $timestamp = strtotime($row['timestamp']); + $hour = date('G', $timestamp); + + if ( + $ignore_solar_heating === true && + $hour > 6 && + $hour < 22 + ) { + continue; + } + + if($first_timestamp === null) { + $first_timestamp = $row['timestamp']; + } + + if( + $thermostat['system_type']['detected']['heat'] === 'compressor' || + $thermostat['system_type']['detected']['heat'] === 'geothermal' + ) { + if($row['compressor_mode'] === 'heat') { + $row['heat_1'] = $row['compressor_1']; + $row['heat_2'] = $row['compressor_2']; + } else { + $row['heat_1'] = 0; + $row['heat_2'] = 0; + } + } else { + $row['heat_1'] = $row['auxiliary_heat_1']; + $row['heat_2'] = $row['auxiliary_heat_2']; + $row['auxiliary_heat_1'] = 0; + $row['auxiliary_heat_2'] = 0; + } + + if($row['compressor_mode'] === 'cool') { + $row['cool_1'] = $row['compressor_1']; + $row['cool_2'] = $row['compressor_2']; + } else { + $row['cool_1'] = 0; + $row['cool_2'] = 0; + } + + if (isset($runtime[$timestamp]) === false) { + $runtime[$timestamp] = []; + } + $runtime[$timestamp][$row['thermostat_id']] = $row; + } + } + + if( + isset($runtime[$current_timestamp]) === true && // Had data for at least one thermostat + isset($runtime[$current_timestamp][$thermostat_id]) === true // Had data for the requested thermostat + ) { + $current_runtime = $runtime[$current_timestamp][$thermostat_id]; + if($current_runtime['outdoor_temperature'] !== null) { + // Rounds to the nearest degree (because temperatures are stored in tenths). + $current_runtime['outdoor_temperature'] = round($current_runtime['outdoor_temperature'] / 10); + + // Applies further smoothing if required. + $current_runtime['outdoor_temperature'] = round($current_runtime['outdoor_temperature'] / $smoothing) * $smoothing; + } + + // If the system mode was heat or cool, log the setpoint. + if($current_runtime['system_mode'] === 'heat') { + $setpoints['heat'][] = $current_runtime['setpoint_heat']; + } else if($current_runtime['system_mode'] === 'cool') { + $setpoints['cool'][] = $current_runtime['setpoint_cool']; + } + + /** + * OFF START + */ + + $most_off = true; + $all_off = true; + if( + count($runtime[$current_timestamp]) < count($thermostat_ids) + ) { + // If I didn't get data at this timestamp for all thermostats in the + // group, all off can't be true. + $all_off = false; + $most_off = false; + } + else { + foreach($runtime[$current_timestamp] as $runtime_thermostat_id => $thermostat_runtime) { + if( + $thermostat_runtime['compressor_1'] !== 0 || + $thermostat_runtime['compressor_2'] !== 0 || + $thermostat_runtime['auxiliary_heat_1'] !== 0 || + $thermostat_runtime['auxiliary_heat_2'] !== 0 || + $thermostat_runtime['outdoor_temperature'] === null || + $thermostat_runtime['indoor_temperature'] === null + ) { + // If I did have data at this timestamp for all thermostats in the + // group, check and see if were fully off. Also if any of the + // things used in the algorithm are just missing, assume the + // system might have been running. + $all_off = false; + + // If everything _but_ the requested thermostat is off. This is + // used for the heat/cool scores as I need to only gather samples + // when everything else is off. + if($runtime_thermostat_id !== $thermostat_id) { + $most_off = false; + } + } + } + } + + // Assume that the runtime rows represent data at the end of that 5 + // minutes. + if($all_off === true) { + $all_off_for += $five_minutes; + + // Store the begin runtime row if the system has been off for the + // requisite length. This gives the temperatures a chance to settle. + if($all_off_for === $minimum_off_for) { + $begin_runtime['resist'] = $current_runtime; + } + } + else { + $all_off_for = 0; + } + + /** + * HEAT 1 START + */ + + // Track how long the heat has been on for. + if($current_runtime['heat_1'] > 0) { + $heat_1_on_for += $current_runtime['heat_1']; + } else { + $heat_1_on_for = 0; + } + + // Store the begin runtime for heat when the heat has been on for this + // thermostat only for the required minimum and everything else is off. + if( + $most_off === true && + $heat_1_on_for >= $minimum_on_for && + // $current_runtime['heat_2'] === 0 && + $current_runtime['auxiliary_heat_1'] === 0 && + $current_runtime['auxiliary_heat_2'] === 0 && + isset($begin_runtime['heat_1']) === false + ) { + $begin_runtime['heat_1'] = $current_runtime; + } + + /** + * HEAT 2 START + */ + + // Track how long the heat has been on for. + if($current_runtime['heat_2'] > 0) { + $heat_2_on_for += $current_runtime['heat_2']; + } else { + $heat_2_on_for = 0; + } + + // Store the begin runtime for heat when the heat has been on for this + // thermostat only for the required minimum and everything else is off. + if( + $most_off === true && + $heat_2_on_for >= $minimum_on_for && + // $current_runtime['heat_1'] === 0 && + $current_runtime['auxiliary_heat_1'] === 0 && + $current_runtime['auxiliary_heat_2'] === 0 && + isset($begin_runtime['heat_2']) === false + ) { + $begin_runtime['heat_2'] = $current_runtime; + } + + /** + * COOL 1 START + */ + + // Track how long the cool has been on for. + if($current_runtime['cool_1'] > 0) { + $cool_1_on_for += $current_runtime['cool_1']; + } else { + $cool_1_on_for = 0; + } + + // Store the begin runtime for cool when the cool has been on for this + // thermostat only for the required minimum and everything else is off. + if( + $most_off === true && + $cool_1_on_for >= $minimum_on_for && + $current_runtime['cool_2'] === 0 && + isset($begin_runtime['cool_1']) === false + ) { + $begin_runtime['cool_1'] = $current_runtime; + } + + /** + * COOL 2 START + */ + + // Track how long the cool has been on for. + if($current_runtime['cool_2'] > 0) { + $cool_2_on_for += $current_runtime['cool_2']; + } else { + $cool_2_on_for = 0; + } + + // Store the begin runtime for cool when the cool has been on for this + // thermostat only for the required minimum and everything else is off. + if( + $most_off === true && + $cool_2_on_for >= $minimum_on_for && + // $current_runtime['cool_1'] === 0 && + isset($begin_runtime['cool_2']) === false + ) { + $begin_runtime['cool_2'] = $current_runtime; + } + + // Look for changes which would trigger a sample to be gathered. + if( + ( + // Heat 1 + // Gather a "heat_1" delta for one of the following reasons. + // - The outdoor temperature changed + // - The calendar event changed + // - The climate changed + // - One of the other thermostats in this group turned on + ($sample_type = 'heat_1') && + isset($begin_runtime['heat_1']) === true && + isset($previous_runtime) === true && + ( + $current_runtime['outdoor_temperature'] !== $begin_runtime['heat_1']['outdoor_temperature'] || + $current_runtime['event_runtime_thermostat_text_id'] !== $begin_runtime['heat_1']['event_runtime_thermostat_text_id'] || + $current_runtime['climate_runtime_thermostat_text_id'] !== $begin_runtime['heat_1']['climate_runtime_thermostat_text_id'] || + $most_off === false + ) + ) || + ( + // Heat 1 + // Gather a "heat_2" delta for one of the following reasons. + // - The outdoor temperature changed + // - The calendar event changed + // - The climate changed + // - One of the other thermostats in this group turned on + ($sample_type = 'heat_2') && + isset($begin_runtime['heat_2']) === true && + isset($previous_runtime) === true && + ( + $current_runtime['outdoor_temperature'] !== $begin_runtime['heat_2']['outdoor_temperature'] || + $current_runtime['event_runtime_thermostat_text_id'] !== $begin_runtime['heat_2']['event_runtime_thermostat_text_id'] || + $current_runtime['climate_runtime_thermostat_text_id'] !== $begin_runtime['heat_2']['climate_runtime_thermostat_text_id'] || + $most_off === false + ) + ) || + ( + // Cool + // Gather a "cool_1" delta for one of the following reasons. + // - The outdoor temperature changed + // - The calendar event changed + // - The climate changed + // - One of the other thermostats in this group turned on + ($sample_type = 'cool_1') && + isset($begin_runtime['cool_1']) === true && + isset($previous_runtime) === true && + ( + $current_runtime['outdoor_temperature'] !== $begin_runtime['cool_1']['outdoor_temperature'] || + $current_runtime['event_runtime_thermostat_text_id'] !== $begin_runtime['cool_1']['event_runtime_thermostat_text_id'] || + $current_runtime['climate_runtime_thermostat_text_id'] !== $begin_runtime['cool_1']['climate_runtime_thermostat_text_id'] || + $most_off === false + ) + ) || + ( + // Cool + // Gather a "cool_2" delta for one of the following reasons. + // - The outdoor temperature changed + // - The calendar event changed + // - The climate changed + // - One of the other thermostats in this group turned on + ($sample_type = 'cool_2') && + isset($begin_runtime['cool_2']) === true && + isset($previous_runtime) === true && + ( + $current_runtime['outdoor_temperature'] !== $begin_runtime['cool_2']['outdoor_temperature'] || + $current_runtime['event_runtime_thermostat_text_id'] !== $begin_runtime['cool_2']['event_runtime_thermostat_text_id'] || + $current_runtime['climate_runtime_thermostat_text_id'] !== $begin_runtime['cool_2']['climate_runtime_thermostat_text_id'] || + $most_off === false + ) + ) || + ( + // Resist + // Gather an "off" delta for one of the following reasons. + // - The outdoor temperature changed + // - The calendar event changed + // - The climate changed + // - The system turned back on after being off + ($sample_type = 'resist') && + isset($begin_runtime['resist']) === true && + isset($previous_runtime) === true && + ( + $current_runtime['outdoor_temperature'] !== $begin_runtime['resist']['outdoor_temperature'] || + $current_runtime['event_runtime_thermostat_text_id'] !== $begin_runtime['resist']['event_runtime_thermostat_text_id'] || + $current_runtime['climate_runtime_thermostat_text_id'] !== $begin_runtime['resist']['climate_runtime_thermostat_text_id'] || + $all_off === false + ) + ) + ) { + // By default the end sample is the previous sample (five minutes ago). + $offset = $five_minutes; + + // If event_runtime_thermostat_text_id or climate_runtime_thermostat_text_id changes, need to ignore data + // from the previous 30 minutes as there are sensors changing during + // that time. + if( + $current_runtime['event_runtime_thermostat_text_id'] !== $begin_runtime[$sample_type]['event_runtime_thermostat_text_id'] || + $current_runtime['climate_runtime_thermostat_text_id'] !== $begin_runtime[$sample_type]['climate_runtime_thermostat_text_id'] + ) { + $offset = $thirty_minutes; + } else { + // Start looking ahead into the next 30 minutes looking for changes + // to event_runtime_thermostat_text_id and climate_runtime_thermostat_text_id. + $lookahead = $five_minutes; + while($lookahead <= $thirty_minutes) { + if( + isset($runtime[$current_timestamp + $lookahead]) === true && + isset($runtime[$current_timestamp + $lookahead][$thermostat_id]) === true && + ( + $runtime[$current_timestamp + $lookahead][$thermostat_id]['event_runtime_thermostat_text_id'] !== $current_runtime['event_runtime_thermostat_text_id'] || + $runtime[$current_timestamp + $lookahead][$thermostat_id]['climate_runtime_thermostat_text_id'] !== $current_runtime['climate_runtime_thermostat_text_id'] + ) + ) { + $offset = ($thirty_minutes - $lookahead); + break; + } + + $lookahead += $five_minutes; + } + } + + // Now use the offset to set the proper end_runtime. This simply makes + // sure the data is present and then uses it. In the case where the + // desired data is missing, I *could* look back further but I'm not + // going to bother. It's pretty rare and adds some unwanted complexity + // to this. + if( + isset($runtime[$current_timestamp - $offset]) === true && + isset($runtime[$current_timestamp - $offset][$thermostat_id]) === true && + ($current_timestamp - $offset) > strtotime($begin_runtime[$sample_type]['timestamp']) + ) { + $end_runtime = $runtime[$current_timestamp - $offset][$thermostat_id]; + } else { + $end_runtime = null; + } + + if($end_runtime !== null) { + $delta = $end_runtime['indoor_temperature'] - $begin_runtime[$sample_type]['indoor_temperature']; + $duration = strtotime($end_runtime['timestamp']) - strtotime($begin_runtime[$sample_type]['timestamp']); + + if($duration > 0) { + $sample = [ + 'type' => $sample_type, + 'outdoor_temperature' => $begin_runtime[$sample_type]['outdoor_temperature'], + 'delta' => $delta, + 'duration' => $duration, + 'delta_per_hour' => $delta / $duration * 3600, + ]; + $samples[] = $sample; + } + } + + // If in this block of code a change in runtime was detected, so + // update $begin_runtime[$sample_type] to the current runtime. + $begin_runtime[$sample_type] = $current_runtime; + } + + $previous_runtime = $current_runtime; + } + + // After a change was detected it automatically moves begin to the + // current_runtime to start a new sample. This might be invalid so need to + // unset it if so. + if( + $heat_1_on_for === 0 || + $current_runtime['outdoor_temperature'] === null || + $current_runtime['indoor_temperature'] === null || + $current_runtime['auxiliary_heat_1'] > 0 || + $current_runtime['auxiliary_heat_2'] > 0 + ) { + unset($begin_runtime['heat_1']); + } + if( + $heat_2_on_for === 0 || + $current_runtime['outdoor_temperature'] === null || + $current_runtime['indoor_temperature'] === null || + $current_runtime['auxiliary_heat_1'] > 0 || + $current_runtime['auxiliary_heat_2'] > 0 + ) { + unset($begin_runtime['heat_2']); + } + if( + $cool_1_on_for === 0 || + $current_runtime['outdoor_temperature'] === null || + $current_runtime['indoor_temperature'] === null + ) { + unset($begin_runtime['cool_1']); + } + if( + $cool_2_on_for === 0 || + $current_runtime['outdoor_temperature'] === null || + $current_runtime['indoor_temperature'] === null + ) { + unset($begin_runtime['cool_2']); + } + if($all_off_for === 0) { + unset($begin_runtime['resist']); + } + + $current_timestamp += $five_minutes; + } + + // print_r($samples); + + // Process the samples + $deltas_raw = []; + foreach($samples as $sample) { + $is_valid_sample = true; + if($sample['duration'] < $minimum_sample_duration[$sample['type']]) { + $is_valid_sample = false; + } + + if($is_valid_sample === true) { + if(isset($deltas_raw[$sample['type']]) === false) { + $deltas_raw[$sample['type']] = []; + } + if(isset($deltas_raw[$sample['type']][$sample['outdoor_temperature']]) === false) { + $deltas_raw[$sample['type']][$sample['outdoor_temperature']] = [ + 'deltas_per_hour' => [] + ]; + } + + $deltas_raw[$sample['type']][$sample['outdoor_temperature']]['deltas_per_hour'][] = $sample['delta_per_hour']; + + } + } + + // Generate the final profile and save it. + $profile = [ + 'temperature' => [ + 'heat_1' => null, + 'heat_2' => null, + 'auxiliary_heat_1' => null, + 'auxiliary_heat_2' => null, + 'cool_1' => null, + 'cool_2' => null, + 'resist' => null + ], + 'setpoint' => [ + 'heat' => null, + 'cool' => null + ], + 'metadata' => [ + 'generated_at' => date('c'), + 'duration' => round((time() - strtotime($first_timestamp)) / 86400), + 'temperature' => [ + 'heat_1' => [ + 'deltas' => [] + ], + 'heat_2' => [ + 'deltas' => [] + ], + 'auxiliary_heat_1' => [ + 'deltas' => [] + ], + 'auxiliary_heat_2' => [ + 'deltas' => [] + ], + 'cool_1' => [ + 'deltas' => [] + ], + 'cool_2' => [ + 'deltas' => [] + ], + 'resist' => [ + 'deltas' => [] + ] + ] + ] + ]; + + $deltas = []; + foreach($deltas_raw as $type => $raw) { + if(isset($deltas[$type]) === false) { + $deltas[$type] = []; + } + foreach($raw as $outdoor_temperature => $data) { + if( + isset($deltas[$type][$outdoor_temperature]) === false && + count($data['deltas_per_hour']) >= $required_samples + ) { + $deltas[$type][$outdoor_temperature] = round(array_median($data['deltas_per_hour']), 2); + $profile['metadata']['temperature'][$type]['deltas'][$outdoor_temperature]['samples'] = count($data['deltas_per_hour']); + } + } + } + + foreach($deltas as $type => $data) { + if(count($data) < $required_points) { + continue; + } + + ksort($deltas[$type]); + + $profile['temperature'][$type] = [ + 'deltas' => $deltas[$type], + 'linear_trendline' => $this->get_linear_trendline($deltas[$type]) + ]; + } + + foreach(['heat', 'cool'] as $type) { + if(count($setpoints[$type]) > 0) { + $profile['setpoint'][$type] = round(array_average($setpoints[$type])) / 10; + $profile['metadata']['setpoint'][$type]['samples'] = count($setpoints[$type]); + } + } + + return $profile; + } + + /** + * Get the properties of a linear trendline for a given set of data. + * + * @param array $data + * + * @return array [slope, intercept] + */ + public function get_linear_trendline($data) { + // Requires at least two points. + if(count($data) < 2) { + return null; + } + + $sum_x = 0; + $sum_y = 0; + $sum_xy = 0; + $sum_x_squared = 0; + $n = 0; + + foreach($data as $x => $y) { + $sum_x += $x; + $sum_y += $y; + $sum_xy += ($x * $y); + $sum_x_squared += pow($x, 2); + $n++; + } + + $slope = (($n * $sum_xy) - ($sum_x * $sum_y)) / (($n * $sum_x_squared) - (pow($sum_x, 2))); + $intercept = (($sum_y) - ($slope * $sum_x)) / ($n); + + return [ + 'slope' => round($slope, 2), + 'intercept' => round($intercept, 2) + ]; + } +} diff --git a/api/thermostat_group.php b/api/thermostat_group.php index e879d6c..a65aa1b 100644 --- a/api/thermostat_group.php +++ b/api/thermostat_group.php @@ -14,6 +14,8 @@ class thermostat_group extends cora\crud { 'read_id', 'generate_temperature_profiles', 'generate_temperature_profile', + 'generate_profiles', + 'generate_profile', 'get_scores', 'update_system_types' ], @@ -23,9 +25,251 @@ class thermostat_group extends cora\crud { public static $cache = [ 'generate_temperature_profile' => 604800, // 7 Days 'generate_temperature_profiles' => 604800, // 7 Days + 'generate_profile' => 604800, // 7 Days + 'generate_profiles' => 604800, // 7 Days 'get_scores' => 604800 // 7 Days ]; + /** + * Generate the group temperature profile. + * + * @param int $thermostat_group_id + * + * @return array + */ + public function generate_profile($thermostat_group_id) { + // Get all thermostats in this group. + $thermostats = $this->api( + 'thermostat', + 'read', + [ + 'attributes' => [ + 'thermostat_group_id' => $thermostat_group_id, + 'inactive' => 0 + ] + ] + ); + + // Generate a temperature profile for each thermostat in this group. + $profiles = []; + foreach($thermostats as $thermostat) { + $profile = $this->api('profile', 'generate', $thermostat['thermostat_id']); + + $this->api( + 'thermostat', + 'update', + [ + 'attributes' => [ + 'thermostat_id' => $thermostat['thermostat_id'], + 'profile' => $profile + ] + ] + ); + + $profiles[] = $profile; + } + + // Get all of the individual deltas for averaging. + $group_profile = [ + 'setpoint' => [ + 'heat' => [ + 'average' => null, + 'minimum' => null, + 'maximum' => null + ], + 'cool' => [ + 'average' => null, + 'minimum' => null, + 'maximum' => null + ] + ], + 'metadata' => [ + 'generated_at' => date('c'), + 'duration' => null, + 'setpoint' => [ + 'heat' => [ + 'samples' => null + ], + 'cool' => [ + 'samples' => null + ] + ], + 'temperature' => [] + ] + ]; + + $metadata_duration = []; + + // Setpoint heat min/max/average. + $metadata_setpoint_heat_samples = []; + // $setpoint_heat_minimum = []; + // $setpoint_heat_maximum = []; + $setpoint_heat = []; + + // Setpoint cool min/max/average. + $metadata_setpoint_cool_samples = []; + // $setpoint_cool_minimum = []; + // $setpoint_cool_maximum = []; + $setpoint_cool = []; + + // Temperature profiles. + $temperature = []; + $metadata_temperature = []; + + foreach($profiles as $profile) { + // Group profile duration is the minimum of all individual profile + // durations. + if($profile['metadata']['duration'] !== null) { + $metadata_duration[] = $profile['metadata']['duration']; + } + + // Setpoint heat min/max/average. + // if($profile['setpoint']['heat']['minimum'] !== null) { + // $setpoint_heat_minimum[] = $profile['setpoint']['heat']['minimum']; + // } + // if($profile['setpoint']['heat']['maximum'] !== null) { + // $setpoint_heat_maximum[] = $profile['setpoint']['heat']['maximum']; + // } + if($profile['setpoint']['heat'] !== null) { + $setpoint_heat[] = [ + 'value' => $profile['setpoint']['heat'], + 'samples' => $profile['metadata']['setpoint']['heat']['samples'] + ]; + $metadata_setpoint_heat_samples[] = $profile['metadata']['setpoint']['heat']['samples']; + } + + // Setpoint cool min/max/average. + // if($profile['setpoint']['cool']['minimum'] !== null) { + // $setpoint_cool_minimum[] = $profile['setpoint']['cool']['minimum']; + // } + // if($profile['setpoint']['cool']['maximum'] !== null) { + // $setpoint_cool_maximum[] = $profile['setpoint']['cool']['maximum']; + // } + if($profile['setpoint']['cool'] !== null) { + $setpoint_cool[] = [ + 'value' => $profile['setpoint']['cool'], + 'samples' => $profile['metadata']['setpoint']['cool']['samples'] + ]; + $metadata_setpoint_cool_samples[] = $profile['metadata']['setpoint']['cool']['samples']; + } + + // Temperature profiles. + foreach($profile['temperature'] as $type => $data) { + if($data !== null) { + foreach($data['deltas'] as $outdoor_temperature => $delta) { + $temperature[$type]['deltas'][$outdoor_temperature][] = [ + 'value' => $delta, + 'samples' => $profile['metadata']['temperature'][$type]['deltas'][$outdoor_temperature]['samples'] + ]; + $metadata_temperature[$type]['deltas'][$outdoor_temperature]['samples'][] = + $profile['metadata']['temperature'][$type]['deltas'][$outdoor_temperature]['samples']; + } + } + } + } + + // echo '
'; + // print_r($metadata_temperature); + // die(); + + $group_profile['metadata']['duration'] = min($metadata_duration); + + // Setpoint heat min/max/average. + $group_profile['metadata']['setpoint']['heat']['samples'] = array_sum($metadata_setpoint_heat_samples); + if($group_profile['metadata']['setpoint']['heat']['samples'] > 0) { + // $group_profile['setpoint']['heat']['minimum'] = min($setpoint_heat_minimum); + // $group_profile['setpoint']['heat']['maximum'] = max($setpoint_heat_maximum); + $group_profile['setpoint']['heat'] = 0; + foreach($setpoint_heat as $data) { + $group_profile['setpoint']['heat'] += + ($data['value'] * $data['samples'] / $group_profile['metadata']['setpoint']['heat']['samples']); + } + } + + // Setpoint cool min/max/average. + $group_profile['metadata']['setpoint']['cool']['samples'] = array_sum($metadata_setpoint_cool_samples); + if($group_profile['metadata']['setpoint']['cool']['samples'] > 0) { + // $group_profile['setpoint']['cool']['minimum'] = min($setpoint_cool_minimum); + // $group_profile['setpoint']['cool']['maximum'] = max($setpoint_cool_maximum); + $group_profile['setpoint']['cool'] = 0; + foreach($setpoint_cool as $data) { + $group_profile['setpoint']['cool'] += + ($data['value'] * $data['samples'] / $group_profile['metadata']['setpoint']['cool']['samples']); + } + } + + // echo ''; + // print_r($temperature); + // die(); + + // Temperature profiles. + // TODO need to store the total number of samples per outdoor temperature + // TODO: get rid of min/max on setpoints and just use average. Then it's the same as temps + foreach($temperature as $type => $data) { + foreach($data['deltas'] as $outdoor_temperature => $delta) { + $group_profile['metadata']['temperature'][$type]['deltas'][$outdoor_temperature]['samples'] = + array_sum($metadata_temperature[$type]['deltas'][$outdoor_temperature]['samples']); + if($group_profile['metadata']['temperature'][$type]['deltas'][$outdoor_temperature]['samples'] > 0) { + $group_profile['temperature'][$type]['deltas'][$outdoor_temperature] = 0; + foreach($temperature[$type]['deltas'][$outdoor_temperature] as $data) { + $group_profile['temperature'][$type]['deltas'][$outdoor_temperature] += + ($data['value'] * $data['samples'] / $group_profile['metadata']['temperature'][$type]['deltas'][$outdoor_temperature]['samples']); + } + } + } + ksort($group_profile['temperature'][$type]['deltas']); + + $group_profile['temperature'][$type]['linear_trendline'] = $this->api( + 'profile', + 'get_linear_trendline', + ['data' => $group_profile['temperature'][$type]['deltas']] + ); + + } + + // echo ''; + // print_r($group_profile); + // die(); + + $this->update( + [ + 'thermostat_group_id' => $thermostat_group_id, + 'profile' => $group_profile + ] + ); + + // Force these to actually return, but set them to null if there's no data. + foreach(['heat', 'cool', 'resist'] as $type) { + if(isset($group_profile['temperature'][$type]) === false) { + $group_profile['temperature'][$type] = null; + } + } + + return $group_profile; + } + + /** + * Generate temperature profiles for all thermostat_groups. This pretty much + * only exists for the cron job. + */ + public function generate_profiles() { + // Get all thermostat_groups. + $thermostat_groups = $this->read(); + foreach($thermostat_groups as $thermostat_group) { + $this->generate_profile( + $thermostat_group['thermostat_group_id'], + null, + null + ); + } + + $this->api( + 'user', + 'update_sync_status', + ['key' => 'thermostat_group.generate_profiles'] + ); + } + /** * Generate the group temperature profile. *