1
0
mirror of https://github.com/beestat/app.git synced 2025-05-24 02:14:03 -04:00
beestat/api/thermostat.php

612 lines
20 KiB
PHP

<?php
/**
* Any type of thermostat.
*
* @author Jon Ziebell
*/
class thermostat extends cora\crud {
public static $exposed = [
'private' => [
'read_id',
'sync',
'dismiss_alert',
'restore_alert',
'set_reported_system_types',
'generate_profile',
'generate_profiles',
'get_metrics',
'update'
],
'public' => []
];
public static $cache = [
'sync' => 180, // 3 Minutes
'generate_profile' => 604800, // 7 Days
'get_metrics' => 604800 // 7 Days
];
/**
* Updates a thermostat normally, plus does the generated columns.
*
* @param array $attributes
*
* @return array
*/
public function update($attributes) {
return parent::update(array_merge($attributes, $this->get_generated_columns($attributes)));
}
/**
* Get all of the generated columns.
*
* @param array $attributes The thermostat.
*
* @return array The generated columns only.
*/
private function get_generated_columns($attributes) {
$generated_columns = [];
if(isset($attributes['system_type']) === true) {
foreach(['heat', 'auxiliary_heat', 'cool'] as $mode) {
if($attributes['system_type']['reported'][$mode]['equipment'] !== null) {
$generated_columns['system_type_' . $mode] = $attributes['system_type']['reported'][$mode]['equipment'];
} else {
$generated_columns['system_type_' . $mode] = $attributes['system_type']['detected'][$mode]['equipment'];
}
if($attributes['system_type']['reported'][$mode]['stages'] !== null) {
$generated_columns['system_type_' . $mode . '_stages'] = $attributes['system_type']['reported'][$mode]['stages'];
} else {
$generated_columns['system_type_' . $mode . '_stages'] = $attributes['system_type']['detected'][$mode]['stages'];
}
}
}
if(isset($attributes['property']) === true) {
foreach(['age', 'square_feet', 'stories', 'structure_type'] as $characteristic) {
$generated_columns['property_' . $characteristic] = $attributes['property'][$characteristic];
}
}
if(isset($attributes['address_id']) === true) {
$address = $this->api('address', 'get', $attributes['address_id']);
if(
isset($address['normalized']['metadata']) === true &&
$address['normalized']['metadata'] !== null &&
isset($address['normalized']['metadata']['latitude']) === true &&
$address['normalized']['metadata']['latitude'] !== null &&
isset($address['normalized']['metadata']['longitude']) === true &&
$address['normalized']['metadata']['longitude'] !== null
) {
$generated_columns['address_latitude'] = $address['normalized']['metadata']['latitude'];
$generated_columns['address_longitude'] = $address['normalized']['metadata']['longitude'];
} else {
$generated_columns['address_latitude'] = null;
$generated_columns['address_longitude'] = null;
}
}
return $generated_columns;
}
/**
* Update the reported system type of this thermostat.
*
* @param int $thermostat_id
* @param array $system_types
*
* @return array The updated thermostat.
*/
public function set_reported_system_types($thermostat_id, $system_types) {
// Redundant, but makes sure you have access to edit the thermostat you
// submitted.
$thermostat = $this->get($thermostat_id);
foreach($system_types as $system_type => $value) {
if(in_array($system_type, ['heat', 'auxiliary_heat', 'cool']) === true) {
$thermostat['system_type']['reported'][$system_type]['equipment'] = $value;
}
}
return $this->update($thermostat);
}
/**
* Normal read, but filter out generated columns. These columns exist only
* for indexing and searching purposes.
*
* @param array $attributes
* @param array $columns
*
* @return array
*/
public function read($attributes = [], $columns = []) {
$thermostats = parent::read($attributes, $columns);
foreach($thermostats as &$thermostat) {
unset($thermostat['system_type_heat']);
unset($thermostat['system_type_heat_stages']);
unset($thermostat['system_type_auxiliary_heat']);
unset($thermostat['system_type_auxiliary_heat_stages']);
unset($thermostat['system_type_cool']);
unset($thermostat['system_type_cool_stages']);
unset($thermostat['property_age']);
unset($thermostat['property_square_feet']);
unset($thermostat['property_stories']);
unset($thermostat['property_structure_type']);
unset($thermostat['address_latitude']);
unset($thermostat['address_longitude']);
}
return $thermostats;
}
/**
* Sync all thermostats for the current user. If we fail to get a lock, fail
* silently (catch the exception) and just return false.
*
* @return boolean true if the sync ran, false if not.
*/
public function sync() {
// Skip this for the demo
if($this->setting->is_demo() === true) {
return true;
}
try {
$lock_name = 'thermostat->sync(' . $this->session->get_user_id() . ')';
$this->database->get_lock($lock_name);
$this->api('ecobee_thermostat', 'sync');
$this->api(
'user',
'update_sync_status',
['key' => 'thermostat']
);
$this->database->release_lock($lock_name);
return true;
} catch(cora\exception $e) {
if($e->getCode() === 10511) {
throw new cora\exception($e->getMessage(), $e->getCode(), $e->getReportable(), $e->getExtraInfo(), $e->getRollback());
} else {
return false;
}
}
}
/**
* Dismiss an alert.
*
* @param int $thermostat_id
* @param string $guid
*/
public function dismiss_alert($thermostat_id, $guid) {
$thermostat = $this->get($thermostat_id);
foreach($thermostat['alerts'] as &$alert) {
if($alert['guid'] === $guid) {
$alert['dismissed'] = true;
break;
}
}
$this->update(
[
'thermostat_id' => $thermostat_id,
'alerts' => $thermostat['alerts']
]
);
}
/**
* Restore a dismissed alert.
*
* @param int $thermostat_id
* @param string $guid
*/
public function restore_alert($thermostat_id, $guid) {
$thermostat = $this->get($thermostat_id);
foreach($thermostat['alerts'] as &$alert) {
if($alert['guid'] === $guid) {
$alert['dismissed'] = false;
break;
}
}
$this->update(
[
'thermostat_id' => $thermostat_id,
'alerts' => $thermostat['alerts']
]
);
}
/**
* Generate profiles for all thermostats. This pretty much only exists for
* the cron job.
*/
public function generate_profiles() {
$thermostats = $this->read([
'inactive' => 0
]);
foreach($thermostats as $thermostat) {
$this->generate_profile(
$thermostat['thermostat_id']
);
}
$this->api(
'user',
'update_sync_status',
['key' => 'thermostat.generate_profiles']
);
}
/**
* Generate a new profile for this thermostat. This is called from the GUI
* often but is cached.
*
* @param int $thermostat_id
*/
public function generate_profile($thermostat_id) {
$this->update([
'thermostat_id' => $thermostat_id,
'profile' => $this->api('profile', 'generate', $thermostat_id)
]);
}
/**
* Compare this thermostat to all other matching ones.
*
* @param array $thermostat_id The base thermostat_id.
* @param array $attributes Optional attributes:
* property_structure_type
* property_age
* property_square_feet
* property_stories
*
* @return array
*/
public function get_metrics($thermostat_id, $attributes) {
$thermostat = $this->get($thermostat_id);
$generated_columns = $this->get_generated_columns($thermostat);
if(
$generated_columns['system_type_heat'] === null ||
$generated_columns['system_type_heat_stages'] === null ||
$generated_columns['system_type_cool'] === null ||
$generated_columns['system_type_cool_stages'] === null
) {
throw new cora\exception('System type is not defined.', 10700);
}
$where = [];
$where[] = '`profile` is not null';
$keys_generated_columns = [
'system_type_heat',
'system_type_heat_stages',
'system_type_cool',
'system_type_cool_stages'
];
foreach($keys_generated_columns as $key) {
$where[] = $this->database->column_equals_value_where(
$key,
$generated_columns[$key]
);
}
$keys_required_in_query = [
'property_age',
'property_square_feet',
'property_stories'
];
foreach($keys_required_in_query as $key) {
if(isset($attributes[$key]) === true) {
$where[] = $this->database->column_equals_value_where(
$key,
$attributes[$key]
);
} else {
// Fill these in for query performance.
$where[] = $this->database->column_equals_value_where(
$key,
['operator' => '>', 'value' => 0]
);
}
}
$keys_optional_in_query = [
'property_structure_type',
];
foreach($keys_optional_in_query as $key) {
if(isset($attributes[$key]) === true) {
$where[] = $this->database->column_equals_value_where(
$key,
$attributes[$key]
);
}
}
/**
* Normally radius implies a circle. In this case it's a square as this
* helps with some optimization.
*/
if(isset($attributes['radius']) === true) {
if(
is_array($attributes['radius']) === false ||
$attributes['radius']['operator'] !== '<'
) {
throw new \Exception('Radius must be defined as less than a value.', 10702);
}
$radius = (int) $attributes['radius']['value'];
if(
isset($generated_columns['address_latitude']) === false ||
isset($generated_columns['address_longitude']) === false
) {
// Require a valid address (latitude/longitude) when using radius.
throw new cora\exception('Cannot compare by radius if address is invalid.', 10701);
} else {
// Latitude is 69mi / °
$degrees_latitude_delta = $radius / 69 / 2;
$minimum_latitude = $generated_columns['address_latitude'] - $degrees_latitude_delta;
$maximum_latitude = $generated_columns['address_latitude'] + $degrees_latitude_delta;
if ($minimum_latitude < -90) {
$overflow = abs($minimum_latitude + 90);
$between_a = [-90, $maximum_latitude];
sort($between_a);
$between_b = [90, (90 - $overflow)];
sort($between_b);
$where[] = '(`address_latitude` between ' . $between_a[0] . ' and ' . $between_a[1] . ' or `address_latitude` between ' . $between_b[0] . ' and ' . $between_b[1] . ')';
}
else if ($maximum_latitude > 90) {
$overflow = abs($maximum_latitude - 90);
$between_a = [90, $minimum_latitude];
sort($between_a);
$between_b = [-90, (-90 + $overflow)];
sort($between_b);
$where[] = '(`address_latitude` between ' . $between_a[0] . ' and ' . $between_a[1] . ' or `address_latitude` between ' . $between_b[0] . ' and ' . $between_b[1] . ')';
}
else {
$between_a = [$minimum_latitude, $maximum_latitude];
sort($between_a);
$where[] = '`address_latitude` between ' . $between_a[0] . ' and ' . $between_a[1];
}
// Longitude is 69mi / ° at the equator and then shrinks towards the poles.
$degrees_longitude_delta = $radius / 69 / 2;
$minimum_longitude = $generated_columns['address_longitude'] - $degrees_longitude_delta;
$maximum_longitude = $generated_columns['address_longitude'] + $degrees_longitude_delta;
if ($minimum_longitude < -180) {
$overflow = abs($minimum_longitude + 180);
$between_a = [-180, $maximum_longitude];
sort($between_a);
$between_b = [180, (180 - $overflow)];
sort($between_b);
$where[] = '(`address_longitude` between ' . $between_a[0] . ' and ' . $between_a[1] . ' or `address_longitude` between ' . $between_b[0] . ' and ' . $between_b[1] . ')';
}
else if ($maximum_longitude > 180) {
$overflow = abs($maximum_longitude - 180);
$between_a = [180, $minimum_longitude];
sort($between_a);
$between_b = [-180, (-180 + $overflow)];
sort($between_b);
$where[] = '(`address_longitude` between ' . $between_a[0] . ' and ' . $between_a[1] . ' or `address_longitude` between ' . $between_b[0] . ' and ' . $between_b[1] . ')';
}
else {
$between_a = [$minimum_longitude, $maximum_longitude];
sort($between_a);
$where[] = '`address_longitude` between ' . $between_a[0] . ' and ' . $between_a[1];
}
}
} else {
// Fill these in for query performance.
$where[] = '`address_longitude` between -180 and 180';
$where[] = '`address_latitude` between -90 and 90';
}
// Should match their position in the thermostat profile exactly.
$metric_codes = [
'property' => [
'age',
'square_feet'
],
'runtime_per_degree_day' => [
'heat_1',
'heat_2',
'auxiliary_heat_1',
'auxiliary_heat_2',
'cool_1',
'cool_2'
],
'setpoint' => [
'heat',
'cool'
],
'setback' => [
'heat',
'cool'
],
'balance_point' => [
'heat_1',
'heat_2',
'resist'
]
];
// Set all of the metric intervals. If Celsius add a bit of precision.
$intervals = [];
$intervals['property'] = [
'age' => 1,
'square_feet' => 500
];
$intervals['runtime_per_degree_day'] = [
'heat_1' => 1,
'heat_2' => 1,
'auxiliary_heat_1' => 1,
'auxiliary_heat_2' => 1,
'cool_1' => 1,
'cool_2' => 1
];
$intervals['setpoint'] = [
'heat' => 0.5,
'cool' => 0.5
];
if($thermostat['temperature_unit'] === '°F') {
$intervals['setback'] = [
'heat' => 1,
'cool' => 1
];
$intervals['balance_point'] = [
'heat_1' => 1,
'heat_2' => 1,
'resist' => 1
];
} else {
$intervals['setback'] = [
'heat' => 0.5,
'cool' => 0.5
];
$intervals['balance_point'] = [
'heat_1' => 0.5,
'heat_2' => 0.5,
'resist' => 0.5
];
}
$get_metric_template = function() {
return [
'values' => [],
'histogram' => [],
'standard_deviation' => null,
'median' => null,
'precision' => null
];
};
$metrics = [];
foreach($metric_codes as $parent_metric_name => $parent_metric) {
$metrics[$parent_metric_name] = [];
foreach($parent_metric as $child_metric_name) {
$metrics[$parent_metric_name][$child_metric_name] = $get_metric_template();
$metrics[$parent_metric_name][$child_metric_name]['interval'] = $intervals[$parent_metric_name][$child_metric_name];
}
}
$memory_limit = 16; // mb
$memory_per_thermostat = 0.0054; // mb
$limit_start = 0;
$limit_count = (int) round($memory_limit / $memory_per_thermostat);
/**
* Selecting lots of rows can eventually run PHP out of memory, so chunk
* this up into several queries to avoid that.
*/
do {
$result = $this->database->query(
'select thermostat_id, profile from thermostat where ' . implode(' and ', $where) . ' limit ' . $limit_start . ',' . $limit_count . ''
);
// Get all the scores from the other thermostats
while($other_thermostat = $result->fetch_assoc()) {
$other_thermostat['profile'] = $other_thermostat['profile'] === null ? null : json_decode($other_thermostat['profile'], true);
// Only use profiles with at least a year of data
// Only use profiles generated in the past year
if(
$other_thermostat['profile']['metadata']['duration'] !== null &&
$other_thermostat['profile']['metadata']['duration'] >= 365 &&
strtotime($other_thermostat['profile']['metadata']['generated_at']) > strtotime('-1 year') &&
$other_thermostat['thermostat_id'] !== $thermostat_id
) {
foreach($metric_codes as $parent_metric_name => $parent_metric) {
foreach($parent_metric as $child_metric_name) {
if(
isset($thermostat['profile'][$parent_metric_name]) === true &&
isset($thermostat['profile'][$parent_metric_name][$child_metric_name]) === true &&
$thermostat['profile'][$parent_metric_name][$child_metric_name] !== null &&
isset($other_thermostat['profile'][$parent_metric_name]) === true &&
isset($other_thermostat['profile'][$parent_metric_name][$child_metric_name]) === true &&
$other_thermostat['profile'][$parent_metric_name][$child_metric_name] !== null
) {
$interval = $intervals[$parent_metric_name][$child_metric_name];
$data = round($other_thermostat['profile'][$parent_metric_name][$child_metric_name] / $interval) * $interval;
$precision = strlen(substr(strrchr($interval, "."), 1));
$data = number_format($data, $precision, '.', '');
$metrics[$parent_metric_name][$child_metric_name]['values'][] = $data;
}
}
}
}
}
$limit_start += $limit_count;
} while ($result->num_rows === $limit_count);
// Cleanup. Set the standard deviation, median, and remove the temporary
// values and any metrics that have no data.
foreach($metric_codes as $parent_metric_name => $parent_metric) {
foreach($parent_metric as $child_metric_name) {
$data = $this->remove_outliers($metrics[$parent_metric_name][$child_metric_name]['values']);
if(count($data['values']) > 0) {
$metrics[$parent_metric_name][$child_metric_name]['histogram'] = $data['histogram'];
$metrics[$parent_metric_name][$child_metric_name]['standard_deviation'] = array_standard_deviation($data['values']);
$metrics[$parent_metric_name][$child_metric_name]['median'] = floatval(array_median($data['values']));
unset($metrics[$parent_metric_name][$child_metric_name]['values']);
} else {
$metrics[$parent_metric_name][$child_metric_name] = null;
}
}
}
return $metrics;
}
/**
* Remove outliers more than 2 standard deviations away from the mean. This
* is an effective way to keep the scales meaningul for normal data.
*
* @param array $array Input array
*
* @return array Input array minus outliers.
*/
private function remove_outliers($array) {
$mean = array_mean($array);
$standard_deviation = array_standard_deviation($array);
$min = $mean - ($standard_deviation * 2);
$max = $mean + ($standard_deviation * 2);
$values = [];
$histogram = [];
foreach($array as $value) {
if($value >= $min && $value <= $max) {
$values[] = $value;
$value_string = strval($value);
if(isset($histogram[$value_string]) === false) {
$histogram[$value_string] = 0;
}
$histogram[$value_string]++;
}
}
return [
'values' => $values,
'histogram' => $histogram
];
}
}