1
0
mirror of https://github.com/beestat/app.git synced 2025-05-23 18:04:14 -04:00
beestat/api/thermostat_group.php
2019-05-22 21:22:24 -04:00

428 lines
14 KiB
PHP

<?php
/**
* A group of thermostats. Thermostats are grouped by address. A thermostat
* group has any number of thermostats and a single combined temperature
* profile.
*
* @author Jon Ziebell
*/
class thermostat_group extends cora\crud {
public static $exposed = [
'private' => [
'read_id',
'generate_temperature_profiles',
'generate_temperature_profile',
'get_scores',
'update_system_types'
],
'public' => []
];
public static $cache = [
'generate_temperature_profile' => 604800, // 7 Days
'generate_temperature_profiles' => 604800, // 7 Days
'get_scores' => 604800 // 7 Days
];
public static $converged = [
'temperature_profile' => [
'type' => 'json'
]
];
public static $user_locked = true;
/**
* Generate the group temperature profile.
*
* @param int $thermostat_group_id
* @param string $begin When to begin the temperature profile at.
* @param string $end When to end the temperature profile at.
*
* @return array
*/
public function generate_temperature_profile($thermostat_group_id, $begin, $end) {
if($begin === null && $end === null) {
$save = true;
} else {
$save = false;
}
// Get all thermostats in this group.
$thermostats = $this->api(
'thermostat',
'read',
[
'attributes' => [
'thermostat_group_id' => $thermostat_group_id
]
]
);
// Generate a temperature profile for each thermostat in this group.
$temperature_profiles = [];
foreach($thermostats as $thermostat) {
$temperature_profiles[] = $this->api(
'temperature_profile',
'generate',
[
'thermostat_id' => $thermostat['thermostat_id'],
'begin' => $begin,
'end' => $end
]
);
}
// Get all of the individual deltas for averaging.
$group_temperature_profile = [];
foreach($temperature_profiles as $temperature_profile) {
foreach($temperature_profile as $type => $data) {
if($data !== null) {
foreach($data['deltas'] as $outdoor_temperature => $delta) {
$group_temperature_profile[$type]['deltas'][$outdoor_temperature][] = $delta;
}
if(isset($data['cycles_per_hour']) === true) {
$group_temperature_profile[$type]['cycles_per_hour'][] = $data['cycles_per_hour'];
}
// if(isset($data['generated_at']) === true) {
// $group_temperature_profile[$type]['generated_at'][] = $data['generated_at'];
// }
}
}
}
// Calculate the average deltas, then get the trendline and score.
foreach($group_temperature_profile as $type => $data) {
foreach($data['deltas'] as $outdoor_temperature => $delta) {
$group_temperature_profile[$type]['deltas'][$outdoor_temperature] =
array_sum($group_temperature_profile[$type]['deltas'][$outdoor_temperature]) /
count($group_temperature_profile[$type]['deltas'][$outdoor_temperature]);
}
ksort($group_temperature_profile[$type]['deltas']);
if(isset($data['cycles_per_hour']) === true) {
$group_temperature_profile[$type]['cycles_per_hour'] =
array_sum($data['cycles_per_hour']) / count($data['cycles_per_hour']);
} else {
$group_temperature_profile[$type]['cycles_per_hour'] = null;
}
$group_temperature_profile[$type]['linear_trendline'] = $this->api(
'temperature_profile',
'get_linear_trendline',
['data' => $group_temperature_profile[$type]['deltas']]
);
$group_temperature_profile[$type]['score'] = $this->api(
'temperature_profile',
'get_score',
[
'type' => $type,
'temperature_profile' => $group_temperature_profile[$type]
]
);
$group_temperature_profile[$type]['metadata'] = [
'generated_at' => date('Y-m-d H:i:s')
];
}
// Only actually save this profile to the thermostat if it was run with the
// default settings (aka the last year). Anything else is not valid to save.
if($save === true) {
$this->update(
[
'thermostat_group_id' => $thermostat_group_id,
'temperature_profile' => $group_temperature_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_temperature_profile[$type]) === false) {
$group_temperature_profile[$type] = null;
}
}
return $group_temperature_profile;
}
/**
* Generate temperature profiles for all thermostat_groups. This pretty much
* only exists for the cron job.
*/
public function generate_temperature_profiles() {
// Get all thermostat_groups.
$thermostat_groups = $this->read();
foreach($thermostat_groups as $thermostat_group) {
$this->generate_temperature_profile(
$thermostat_group['thermostat_group_id'],
null,
null
);
}
$this->api(
'user',
'update_sync_status',
['key' => 'thermostat_group.generate_temperature_profiles']
);
}
/**
* Compare this thermostat_group to all other matching ones.
*
* @param string $type resist|heat|cool
* @param array $attributes The attributes to compare to.
*
* @return array
*/
public function get_scores($type, $attributes) {
// All or none are required.
if(
(
isset($attributes['address_latitude']) === true ||
isset($attributes['address_longitude']) === true ||
isset($attributes['address_radius']) === true
) &&
(
isset($attributes['address_latitude']) === false ||
isset($attributes['address_longitude']) === false ||
isset($attributes['address_radius']) === false
)
) {
throw new Exception('If one of address_latitude, address_longitude, or address_radius are set, then all are required.');
}
// Pull these values out so they don't get queried; this comparison is done
// in PHP.
if(isset($attributes['address_radius']) === true) {
$address_latitude = $attributes['address_latitude'];
$address_longitude = $attributes['address_longitude'];
$address_radius = $attributes['address_radius'];
unset($attributes['address_latitude']);
unset($attributes['address_longitude']);
unset($attributes['address_radius']);
}
// Get all matching thermostat groups.
$other_thermostat_groups = $this->database->read(
'thermostat_group',
$attributes
);
// Get all the scores from the other thermostat groups
$scores = [];
foreach($other_thermostat_groups as $other_thermostat_group) {
if(
isset($other_thermostat_group['temperature_profile'][$type]) === true &&
isset($other_thermostat_group['temperature_profile'][$type]['score']) === true &&
$other_thermostat_group['temperature_profile'][$type]['score'] !== null &&
isset($other_thermostat_group['temperature_profile'][$type]['metadata']) === true &&
isset($other_thermostat_group['temperature_profile'][$type]['metadata']['generated_at']) === true &&
strtotime($other_thermostat_group['temperature_profile'][$type]['metadata']['generated_at']) > strtotime('-1 month')
) {
// Skip thermostat_groups that are too far away.
if(
isset($address_radius) === true &&
$this->haversine_great_circle_distance(
$address_latitude,
$address_longitude,
$other_thermostat_group['address_latitude'],
$other_thermostat_group['address_longitude']
) > $address_radius
) {
continue;
}
// Ignore profiles with too few datapoints. Ideally this would be time-
// based...so don't use a profile if it hasn't experienced a full year
// or heating/cooling system, but that isn't stored presently. A good
// approximation is to make sure there is a solid set of data driving
// the profile.
$required_delta_count = (($type === 'resist') ? 40 : 20);
if(count($other_thermostat_group['temperature_profile'][$type]['deltas']) < $required_delta_count) {
continue;
}
// Round the scores so they can be better displayed on a histogram or
// bell curve.
// TODO: Might be able to get rid of this? I don't think new scores are calculated at this level of detail anymore...
// $scores[] = round(
// $other_thermostat_group['temperature_profile'][$type]['score'],
// 1
// );
$scores[] = $other_thermostat_group['temperature_profile'][$type]['score'];
}
}
sort($scores);
return $scores;
}
/**
* Calculates the great-circle distance between two points, with the
* Haversine formula.
*
* @param float $latitude_from Latitude of start point in [deg decimal]
* @param float $longitude_from Longitude of start point in [deg decimal]
* @param float $latitude_to Latitude of target point in [deg decimal]
* @param float $longitude_to Longitude of target point in [deg decimal]
* @param float $earth_radius Mean earth radius in [mi]
*
* @link https://stackoverflow.com/a/10054282
*
* @return float Distance between points in [mi] (same as earth_radius)
*/
private function haversine_great_circle_distance($latitude_from, $longitude_from, $latitude_to, $longitude_to, $earth_radius = 3959) {
$latitude_from_radians = deg2rad($latitude_from);
$longitude_from_radians = deg2rad($longitude_from);
$latitude_to_radians = deg2rad($latitude_to);
$longitude_to_radians = deg2rad($longitude_to);
$latitude_delta = $latitude_to_radians - $latitude_from_radians;
$longitude_delta = $longitude_to_radians - $longitude_from_radians;
$angle = 2 * asin(sqrt(pow(sin($latitude_delta / 2), 2) +
cos($latitude_from_radians) * cos($latitude_to_radians) * pow(sin($longitude_delta / 2), 2)));
return $angle * $earth_radius;
}
/**
* Look at all the properties of individual thermostats in this group and
* apply them to the thermostat_group. This resolves issues where values are
* set on one thermostat but null on another.
*
* @param int $thermostat_group_id
*
* @return array The updated thermostat_group.
*/
public function sync_attributes($thermostat_group_id) {
$attributes = [
'system_type_heat',
'system_type_heat_auxiliary',
'system_type_cool',
'property_age',
'property_square_feet',
'property_stories',
'property_structure_type'
];
$thermostats = $this->api(
'thermostat',
'read',
[
'attributes' => [
'thermostat_group_id' => $thermostat_group_id
]
]
);
$final_attributes = [];
foreach($attributes as $attribute) {
$final_attributes[$attribute] = null;
foreach($thermostats as $thermostat) {
switch($attribute) {
case 'property_age':
case 'property_square_feet':
case 'property_stories':
// Use max found age, square_feet, stories
$key = str_replace('property_', '', $attribute);
if($thermostat['property'][$key] !== null) {
$final_attributes[$attribute] = max($final_attributes[$attribute], $thermostat['property'][$key]);
}
break;
case 'property_structure_type':
// Use the first non-null structure_type
if(
$thermostat['property']['structure_type'] !== null &&
$final_attributes[$attribute] === null
) {
$final_attributes[$attribute] = $thermostat['property']['structure_type'];
}
break;
case 'system_type_heat':
case 'system_type_heat_auxiliary':
case 'system_type_cool':
$type = str_replace('system_type_', '', $attribute);
// Always prefer reported, otherwise fall back to detected.
if($thermostat['system_type']['reported'][$type] !== null) {
$system_type = $thermostat['system_type']['reported'][$type];
$reported = true;
} else {
$system_type = $thermostat['system_type']['detected'][$type];
$reported = false;
}
if($reported === true) {
// User-reported values always take precedence
$final_attributes[$attribute] = $system_type;
} else if(
$final_attributes[$attribute] === null ||
(
$final_attributes[$attribute] === 'none' &&
$system_type !== null
)
) {
// None beats null
$final_attributes[$attribute] = $system_type;
}
break;
}
}
}
$final_attributes['thermostat_group_id'] = $thermostat_group_id;
return $this->update($final_attributes);
}
/**
* Update all of the thermostats in this group to a specified system type,
* then sync that forwards into the thermostat_group.
*
* @param int $thermostat_group_id
* @param array $system_types
*
* @return array The updated thermostat_group.
*/
public function update_system_types($thermostat_group_id, $system_types) {
$thermostats = $this->api(
'thermostat',
'read',
[
'attributes' => [
'thermostat_group_id' => $thermostat_group_id
]
]
);
foreach($thermostats as $thermostat) {
$current_system_types = $thermostat['system_type'];
foreach($system_types as $system_type => $value) {
$current_system_types['reported'][$system_type] = $value;
}
$this->api(
'thermostat',
'update',
[
'attributes' => [
'thermostat_id' => $thermostat['thermostat_id'],
'system_type' => $current_system_types
]
]
);
}
return $this->sync_attributes($thermostat_group_id);
}
}