mirror of
https://github.com/beestat/app.git
synced 2025-05-24 02:14:03 -04:00
Removed "json_" prefixes from all columns and converted columns to actual JSON types. Also removed all converged columns and converted contents to regular columns.
427 lines
14 KiB
PHP
427 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
|
|
];
|
|
|
|
/**
|
|
* 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,
|
|
'inactive' => 0
|
|
]
|
|
]
|
|
);
|
|
|
|
// 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',
|
|
'weather'
|
|
];
|
|
|
|
$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;
|
|
default:
|
|
// Stuff that doesn't really matter (weather); just pick the last
|
|
// one.
|
|
$final_attributes[$attribute] = $thermostat[$attribute];
|
|
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);
|
|
}
|
|
}
|