1
0
mirror of https://github.com/beestat/app.git synced 2025-05-23 18:04:14 -04:00
beestat/api/profile.php
2024-06-04 19:48:32 -04:00

1291 lines
46 KiB
PHP

<?php
/**
* Some functionality for generating and working with profiles. Per ecobee
* documentation: The values supplied for any given 5-minute interval is the
* value at the start of the interval and is not an average.
*
* @author Jon Ziebell
*/
class profile extends cora\crud {
public static $exposed = [
'private' => ['generate'],
'public' => ['read_id']
];
public static $cache = [];
/**
* Generate a profile for the specified thermostat.
*
* @param int $thermostat_id
* @param boolean $debug If debug is enabled, running this API call will
* download a CSV full of useful debugging info.
*
* @return array
*/
public function generate($thermostat_id, $debug = false) {
set_time_limit(0);
if($debug === true) {
$output = fopen('php://output', 'w');
$bytes = 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,
'auxiliary_heat_1' => 300,
'auxiliary_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
// Get some stuff
$thermostat = $this->api('thermostat', 'get', $thermostat_id);
/**
* Attempt to ignore the effects of solar heating by only looking at
* samples when the sun is down.
*/
$ignore_solar_gain = $this->api(
'user',
'get_setting',
'thermostat.' . $thermostat_id . '.profile.ignore_solar_gain'
);
/**
* Allow a custom start date.
*/
$custom_range_begin = $this->api(
'user',
'get_setting',
'thermostat.' . $thermostat_id . '.profile.range_begin'
);
if($thermostat['system_type']['reported']['heat']['equipment'] !== null) {
$system_type_heat = $thermostat['system_type']['reported']['heat']['equipment'];
} else {
$system_type_heat = $thermostat['system_type']['detected']['heat']['equipment'];
}
if($thermostat['system_type']['reported']['cool']['equipment'] !== null) {
$system_type_cool = $thermostat['system_type']['reported']['cool']['equipment'];
} else {
$system_type_cool = $thermostat['system_type']['detected']['cool']['equipment'];
}
if($thermostat['system_type']['reported']['heat']['stages'] !== null) {
$heat_stages = $thermostat['system_type']['reported']['heat']['stages'];
} else {
$heat_stages = $thermostat['system_type']['detected']['heat']['stages'];
}
if($thermostat['system_type']['reported']['cool']['stages'] !== null) {
$cool_stages = $thermostat['system_type']['reported']['cool']['stages'];
} else {
$cool_stages = $thermostat['system_type']['detected']['cool']['stages'];
}
// 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();
if($custom_range_begin !== null) {
$begin_timestamp = max(
strtotime($custom_range_begin),
strtotime('-1 year', $end_timestamp)
);
} else {
$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' => [
'address_id' => $thermostat['address_id'],
'inactive' => 0
]
]
);
// Get latitude/longitude. If that's not possible, disable solar gain
// check.
if($ignore_solar_gain === true) {
if($thermostat['address_id'] === null) {
$ignore_solar_gain = false;
} else {
$address = $this->api('address', 'get', $thermostat['address_id']);
if(
isset($address['normalized']['metadata']) === false ||
isset($address['normalized']['metadata']['latitude']) === false ||
isset($address['normalized']['metadata']['longitude']) === false
) {
$ignore_solar_gain = false;
} else {
$latitude = $address['normalized']['metadata']['latitude'];
$longitude = $address['normalized']['metadata']['longitude'];
}
}
}
// Get all of the relevant data
$thermostat_ids = [];
foreach($group_thermostats as $group_thermostat) {
$thermostat_ids[] = $group_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' => []
];
$runtime_seconds = [
'heat_1' => 0,
'heat_2' => 0,
'auxiliary_heat_1' => 0,
'auxiliary_heat_2' => 0,
'cool_1' => 0,
'cool_2' => 0
];
$degree_days_base_temperature = 65;
$degree_days = [];
$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 = [];
$degree_days_date = date('Y-m-d', $current_timestamp);
$degree_days_temperatures = [];
while($row = $result->fetch_assoc()) {
$timestamp = strtotime($row['timestamp']);
$date = date('Y-m-d', $timestamp);
// Degree days
if($date !== $degree_days_date) {
$degree_days[] = (array_mean($degree_days_temperatures) / 10) - $degree_days_base_temperature;
$degree_days_date = $date;
$degree_days_temperatures = [];
}
$degree_days_temperatures[] = $row['outdoor_temperature'];
if($first_timestamp === null) {
$first_timestamp = $row['timestamp'];
}
// Normalizing heating and cooling a bit.
if(
$system_type_heat === 'compressor' ||
$system_type_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;
}
// No longer needed.
unset($row['compressor_1']);
unset($row['compressor_2']);
unset($row['compressor_mode']);
$runtime_seconds['heat_1'] += $row['heat_1'];
$runtime_seconds['heat_2'] += $row['heat_2'];
$runtime_seconds['auxiliary_heat_1'] += $row['auxiliary_heat_1'];
$runtime_seconds['auxiliary_heat_2'] += $row['auxiliary_heat_2'];
$runtime_seconds['cool_1'] += $row['cool_1'];
$runtime_seconds['cool_2'] += $row['cool_2'];
// Ignore data between sunrise and sunset.
if($ignore_solar_gain === true) {
$sun_info = date_sun_info($timestamp, $latitude, $longitude);
if(
$timestamp > $sun_info['sunrise'] &&
$timestamp < $sun_info['sunset']
) {
continue;
}
}
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];
// List of thermostat_ids that have data for this timestamp.
$relevant_thermostat_ids = [];
foreach($group_thermostats as $possible_relevant_thermostat) {
if(
$possible_relevant_thermostat['data_begin'] === null ||
strtotime($possible_relevant_thermostat['data_begin']) <= $current_timestamp
) {
$relevant_thermostat_ids[] = $possible_relevant_thermostat['thermostat_id'];
}
}
if($debug === true) {
$debug_data = [
'sample' => null
];
}
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;
}
// Log the setpoint.
if(
(
$current_runtime['system_mode'] === 'heat' ||
$current_runtime['system_mode'] === 'auto'
) &&
$current_runtime['setpoint_cool'] !== null
) {
$setpoints['heat'][] = $current_runtime['setpoint_heat'];
}
if(
(
$current_runtime['system_mode'] === 'cool' ||
$current_runtime['system_mode'] === 'auto'
) &&
$current_runtime['setpoint_cool'] !== null
) {
$setpoints['cool'][] = $current_runtime['setpoint_cool'];
}
/**
* OFF START
*/
$most_off = true;
$all_off = true;
if(
count($runtime[$current_timestamp]) < count($relevant_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['heat_1'] !== 0 ||
$thermostat_runtime['heat_2'] !== 0 ||
$thermostat_runtime['cool_1'] !== 0 ||
$thermostat_runtime['cool_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['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['auxiliary_heat_1'] === 0 &&
$current_runtime['auxiliary_heat_2'] === 0 &&
isset($begin_runtime['heat_2']) === false
) {
$begin_runtime['heat_2'] = $current_runtime;
}
/**
* AUXILIARY HEAT 1 START
*/
// Track how long the heat has been on for.
if($current_runtime['auxiliary_heat_1'] > 0) {
$auxiliary_heat_1_on_for += $current_runtime['auxiliary_heat_1'];
} else {
$auxiliary_heat_1_on_for = 0;
}
// Store the begin runtime for auxiliary heat when the auxiliary heat
// has been on for this thermostat only for the required minimum and
// everything else is off. The exception is normal heat as aux heat
// often runs with it.
if(
$most_off === true &&
$auxiliary_heat_1_on_for >= $minimum_on_for &&
isset($begin_runtime['auxiliary_heat_1']) === false
) {
$begin_runtime['auxiliary_heat_1'] = $current_runtime;
}
/**
* AUXILIARY HEAT 2 START
*/
// Track how long the heat has been on for.
if($current_runtime['auxiliary_heat_2'] > 0) {
$auxiliary_heat_2_on_for += $current_runtime['auxiliary_heat_2'];
} else {
$auxiliary_heat_2_on_for = 0;
}
// Store the begin runtime for auxiliary heat when the auxiliary heat
// has been on for this thermostat only for the required minimum and
// everything else is off. The exception is normal heat as aux heat
// often runs with it.
if(
$most_off === true &&
$auxiliary_heat_2_on_for >= $minimum_on_for &&
isset($begin_runtime['auxiliary_heat_2']) === false
) {
$begin_runtime['auxiliary_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 &&
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 2
// 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
)
) ||
(
// Auxiliary Heat 1
// Gather an "auxiliary_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 = 'auxiliary_heat_1') &&
isset($begin_runtime['auxiliary_heat_1']) === true &&
isset($previous_runtime) === true &&
(
$current_runtime['outdoor_temperature'] !== $begin_runtime['auxiliary_heat_1']['outdoor_temperature'] ||
$current_runtime['event_runtime_thermostat_text_id'] !== $begin_runtime['auxiliary_heat_1']['event_runtime_thermostat_text_id'] ||
$current_runtime['climate_runtime_thermostat_text_id'] !== $begin_runtime['auxiliary_heat_1']['climate_runtime_thermostat_text_id'] ||
$most_off === false
)
) ||
(
// Auxiliary Heat 2
// Gather an "auxiliary_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 = 'auxiliary_heat_2') &&
isset($begin_runtime['auxiliary_heat_2']) === true &&
isset($previous_runtime) === true &&
(
$current_runtime['outdoor_temperature'] !== $begin_runtime['auxiliary_heat_2']['outdoor_temperature'] ||
$current_runtime['event_runtime_thermostat_text_id'] !== $begin_runtime['auxiliary_heat_2']['event_runtime_thermostat_text_id'] ||
$current_runtime['climate_runtime_thermostat_text_id'] !== $begin_runtime['auxiliary_heat_2']['climate_runtime_thermostat_text_id'] ||
$most_off === false
)
) ||
(
// Cool 1
// 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 2
// 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 >= $minimum_sample_duration[$sample_type]) {
$sample = [
'type' => $sample_type,
'outdoor_temperature' => $begin_runtime[$sample_type]['outdoor_temperature'],
'delta' => $delta,
'duration' => $duration,
'delta_per_hour' => $delta / $duration * 3600
];
if($debug === true) {
$debug_data['sample'] = json_encode(
$sample +
[
'begin' => $begin_runtime[$sample_type]['timestamp'],
'end' => $end_runtime['timestamp']
]
);
}
$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;
}
if($debug === true && isset($previous_runtime) === true) {
$debug_row = array_merge($previous_runtime, $debug_data);
if($bytes === 0) {
$bytes += fputcsv($output, array_keys($debug_row));
}
$bytes += fputcsv($output, array_values($debug_row));
}
$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(
$auxiliary_heat_1_on_for === 0 ||
$current_runtime['outdoor_temperature'] === null ||
$current_runtime['indoor_temperature'] === null
) {
unset($begin_runtime['auxiliary_heat_1']);
}
if(
$auxiliary_heat_2_on_for === 0 ||
$current_runtime['outdoor_temperature'] === null ||
$current_runtime['indoor_temperature'] === null
) {
unset($begin_runtime['auxiliary_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;
}
// Process the samples
$deltas_raw = [];
foreach($samples as $sample) {
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'];
}
// Get the raw thermostat with the generated columns to store the data in
// the profile. The CRUD read doesn't return them.
$thermostat_database = $this->database->read(
'thermostat',
[
'thermostat_id' => $thermostat_id
]
);
$thermostat_database = end($thermostat_database);
// 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
],
'degree_days' => [
'heat' => null,
'cool' => null
],
'differential' => [
'heat' => null,
'cool' => null
],
'setback' => [
'heat' => null,
'cool' => null
],
'runtime' => [
'heat_1' => round($runtime_seconds['heat_1'] / 60),
'heat_2' => round($runtime_seconds['heat_2'] / 60),
'auxiliary_heat_1' => round($runtime_seconds['auxiliary_heat_1'] / 60),
'auxiliary_heat_2' => round($runtime_seconds['auxiliary_heat_2'] / 60),
'cool_1' => round($runtime_seconds['cool_1'] / 60),
'cool_2' => round($runtime_seconds['cool_2'] / 60),
],
'runtime_per_degree_day' => [
'heat_1' => null,
'heat_2' => null,
'cool_1' => null,
'cool_2' => null,
'auxiliary_heat_1' => null,
'auxiliary_heat_2' => null
],
'balance_point' => [
'heat_1' => null,
'heat_2' => null,
'resist' => null
],
'property' => [
'age' => $thermostat_database['property_age'],
'square_feet' => $thermostat_database['property_square_feet'],
'stories' => $thermostat_database['property_stories'],
'structure_type' => $thermostat_database['property_structure_type']
],
'system_type' => [
'heat' => $thermostat_database['system_type_heat'],
'heat_stages' => $thermostat_database['system_type_heat_stages'],
'auxiliary_heat' => $thermostat_database['system_type_auxiliary_heat'],
'auxiliary_heat_stages' => $thermostat_database['system_type_auxiliary_heat_stages'],
'cool' => $thermostat_database['system_type_cool'],
'cool_stages' => $thermostat_database['system_type_cool_stages']
],
'address' => [
'latitude' => $thermostat_database['address_latitude'],
'longitude' => $thermostat_database['address_longitude']
],
'metadata' => [
'generated_at' => date('c'),
'duration' => $first_timestamp === null ? null : round((time() - strtotime($first_timestamp)) / 86400),
]
];
$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']) / 10, 2);
}
}
}
foreach($deltas as $type => $data) {
if(count($data) < $required_points) {
continue;
}
ksort($deltas[$type]);
$this->remove_outliers($deltas[$type]);
$profile['temperature'][$type] = [
'deltas' => $deltas[$type],
'linear_trendline' => $this->get_linear_trendline($deltas[$type])
];
}
if(
$system_type_cool !== null &&
$system_type_cool !== 'none' &&
count($setpoints['cool']) > 0
) {
$profile['setpoint']['cool'] = round(array_mean($setpoints['cool'])) / 10;
}
if(
$system_type_heat !== null &&
$system_type_heat !== 'none' &&
count($setpoints['heat']) > 0
) {
$profile['setpoint']['heat'] = round(array_mean($setpoints['heat'])) / 10;
}
// Heating and cooling degree days.
foreach($degree_days as $degree_day) {
if($degree_day < 0) {
$profile['degree_days']['heat'] += ($degree_day * -1);
} else {
$profile['degree_days']['cool'] += ($degree_day);
}
}
if ($profile['degree_days']['cool'] !== null) {
$profile['degree_days']['cool'] = round($profile['degree_days']['cool']);
}
if ($profile['degree_days']['heat'] !== null) {
$profile['degree_days']['heat'] = round($profile['degree_days']['heat']);
}
// Runtime per degree day
if(
$profile['degree_days']['heat'] !== null &&
$profile['degree_days']['heat'] > 0
) {
if(
$system_type_heat !== null &&
$system_type_heat !== 'none'
) {
if($profile['runtime']['heat_1'] > 0) {
$profile['runtime_per_degree_day']['heat_1'] = round($profile['runtime']['heat_1'] / $profile['degree_days']['heat'], 2);
}
if($profile['runtime']['heat_2'] > 0) {
$profile['runtime_per_degree_day']['heat_2'] = round($profile['runtime']['heat_2'] / $profile['degree_days']['heat'], 2);
}
if($profile['runtime']['auxiliary_heat_1'] > 0) {
$profile['runtime_per_degree_day']['auxiliary_heat_1'] = round($profile['runtime']['auxiliary_heat_1'] / $profile['degree_days']['heat'], 2);
}
if($profile['runtime']['auxiliary_heat_2'] > 0) {
$profile['runtime_per_degree_day']['auxiliary_heat_2'] = round($profile['runtime']['auxiliary_heat_2'] / $profile['degree_days']['heat'], 2);
}
}
}
if(
$profile['degree_days']['cool'] !== null &&
$profile['degree_days']['cool'] > 0
) {
if(
$system_type_cool !== null &&
$system_type_cool !== 'none'
) {
if($profile['runtime']['cool_1'] > 0) {
$profile['runtime_per_degree_day']['cool_1'] = round($profile['runtime']['cool_1'] / $profile['degree_days']['cool'], 2);
}
if($profile['runtime']['cool_2'] > 0) {
$profile['runtime_per_degree_day']['cool_2'] = round($profile['runtime']['cool_2'] / $profile['degree_days']['cool'], 2);
}
}
}
// Balance point
if($system_type_heat === 'compressor') {
if(
$profile['temperature']['heat_1'] !== null &&
$profile['temperature']['heat_1']['linear_trendline'] !== null
) {
$linear_trendline = $profile['temperature']['heat_1']['linear_trendline'];
if($linear_trendline['slope'] > 0) {
$profile['balance_point']['heat_1'] = round((-1 * $linear_trendline['intercept']) / $linear_trendline['slope'], 1);
}
}
if(
$profile['temperature']['heat_2'] !== null &&
$profile['temperature']['heat_2']['linear_trendline'] !== null
) {
$linear_trendline = $profile['temperature']['heat_2']['linear_trendline'];
if($linear_trendline['slope'] > 0) {
$profile['balance_point']['heat_2'] = round((-1 * $linear_trendline['intercept']) / $linear_trendline['slope'], 1);
}
}
}
if(
$profile['temperature']['resist'] !== null &&
$profile['temperature']['resist']['linear_trendline'] !== null
) {
$linear_trendline = $profile['temperature']['resist']['linear_trendline'];
if($linear_trendline['slope'] > 0) {
$profile['balance_point']['resist'] = round((-1 * $linear_trendline['intercept']) / $linear_trendline['slope'], 1);
}
}
// Differential
if(isset($thermostat['settings']['differential_heat']) === true) {
$profile['differential']['heat'] = $thermostat['settings']['differential_heat'];
}
if(isset($thermostat['settings']['differential_cool']) === true) {
$profile['differential']['cool'] = $thermostat['settings']['differential_cool'];
}
// Setback
if(isset($thermostat['program']['climates']) === true) {
foreach($thermostat['program']['climates'] as $climate) {
if($climate['climateRef'] === 'home') {
$temperature_home_cool = $climate['coolTemp'];
$temperature_home_heat = $climate['heatTemp'];
} else if($climate['climateRef'] === 'away') {
$temperature_away_cool = $climate['coolTemp'];
$temperature_away_heat = $climate['heatTemp'];
}
}
}
if(
$system_type_cool !== null &&
$system_type_cool !== 'none' &&
isset($temperature_home_cool) === true &&
isset($temperature_away_cool) === true &&
$temperature_away_cool >= $temperature_home_cool
) {
$profile['setback']['cool'] = $temperature_away_cool - $temperature_home_cool;
}
if(
$system_type_heat !== null &&
$system_type_heat !== 'none' &&
isset($temperature_home_heat) === true &&
isset($temperature_away_heat) === true &&
$temperature_home_heat >= $temperature_away_heat
) {
$profile['setback']['heat'] = $temperature_home_heat - $temperature_away_heat;
}
// Debug
if($debug === true) {
fclose($output);
$this->request->set_headers([
'Content-Type' => 'text/csv',
'Content-Length' => $bytes,
'Content-Disposition' => 'attachment; filename="Debug - ' . $thermostat_id . '.csv"',
'Pragma' => 'no-cache',
'Expires' => '0',
], true);
}
/**
* Store the profile. A single profile can be stored per day for
* flexibility purposes, but this code forces a single profile to be
* stored per week. This makes the GUI easy and intuitive.
*/
$day_of_week = date(
'w',
strtotime($profile['metadata']['generated_at'])
);
$start_of_week = date('Y-m-d', strtotime('-' . $day_of_week . ' days'));
$dates_this_week = [
$start_of_week,
date('Y-m-d', strtotime($start_of_week . ' +1 day')),
date('Y-m-d', strtotime($start_of_week . ' +2 day')),
date('Y-m-d', strtotime($start_of_week . ' +3 day')),
date('Y-m-d', strtotime($start_of_week . ' +4 day')),
date('Y-m-d', strtotime($start_of_week . ' +5 day')),
date('Y-m-d', strtotime($start_of_week . ' +6 day'))
];
$existing_profiles = $this->read([
'thermostat_id' => $thermostat['thermostat_id'],
'date' => $dates_this_week
]);
if(count($existing_profiles) === 0) {
$this->create([
'user_id' => $thermostat['user_id'],
'thermostat_id' => $thermostat['thermostat_id'],
'date' => date(
'Y-m-d',
strtotime($profile['metadata']['generated_at'])
),
'profile' => $profile
]);
} else {
$most_recent_profile = end($existing_profiles);
$this->update([
'profile_id' => $most_recent_profile['profile_id'],
'date' => date(
'Y-m-d',
strtotime($profile['metadata']['generated_at'])
),
'profile' => $profile
]);
}
return $profile;
}
/**
* Get the properties of a linear trendline for a given set of deltas.
*
* @param array $deltas
*
* @return array [slope, intercept]
*/
public function get_linear_trendline($deltas) {
// Requires at least two points.
if(count($deltas) < 2) {
return null;
}
$sum_x = 0;
$sum_y = 0;
$sum_xy = 0;
$sum_x_squared = 0;
$n = 0;
foreach($deltas 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, 4),
'intercept' => round($intercept, 4)
];
}
/**
* Remove outliers from the deltas list by eliminating them if they fall too
* far away from the trendline. Too far is simply more than two standard
* deviations away from the mean spread.
*
* Modifies the original array.
*
* @param array &$deltas The deltas to remove outliers from.
*/
public function remove_outliers(&$deltas) {
$linear_trendline = $this->get_linear_trendline($deltas);
$spreads = [];
foreach($deltas as $x => $y) {
$trendline_y = ($linear_trendline['slope'] * $x) + $linear_trendline['intercept'];
$spreads[] += abs($y - $trendline_y);
}
$mean = array_mean($spreads);
$standard_deviation = array_standard_deviation($spreads);
$min = $mean - ($standard_deviation * 2);
$max = $mean + ($standard_deviation * 2);
$good_deltas = [];
foreach($deltas as $x => $y) {
$trendline_y = ($linear_trendline['slope'] * $x) + $linear_trendline['intercept'];
$spread = abs($y - $trendline_y);
if($spread >= $min && $spread <= $max) {
$good_deltas[$x] = $y;
}
}
$deltas = $good_deltas;
}
}