diff --git a/api/beestat.sql b/api/beestat.sql index 6cfdd8c..7f44259 100644 --- a/api/beestat.sql +++ b/api/beestat.sql @@ -1,8 +1,16 @@ --- MySQL dump 10.13 Distrib 8.0.18, for Linux (x86_64) +-- mysqldump -u root -p --opt beestat -d --single-transaction | sed 's/ AUTO_INCREMENT=[0-9]*//g' > beestat.sql + + + + + + + +-- MySQL dump 10.13 Distrib 8.0.20, for Linux (x86_64) -- -- Host: localhost Database: beestat -- ------------------------------------------------------ --- Server version 8.0.18 +-- Server version 8.0.20 /*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; /*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; @@ -23,8 +31,8 @@ DROP TABLE IF EXISTS `address`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `address` ( - `address_id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `user_id` int(10) unsigned NOT NULL, + `address_id` int unsigned NOT NULL AUTO_INCREMENT, + `user_id` int unsigned NOT NULL, `key` char(40) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL, `normalized` json DEFAULT NULL, `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -43,7 +51,7 @@ DROP TABLE IF EXISTS `announcement`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `announcement` ( - `announcement_id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `announcement_id` int unsigned NOT NULL AUTO_INCREMENT, `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `important` tinyint(1) NOT NULL DEFAULT '0', `title` varchar(255) COLLATE utf8_unicode_ci NOT NULL, @@ -62,14 +70,11 @@ DROP TABLE IF EXISTS `api_cache`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `api_cache` ( - `api_cache_id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `user_id` int(10) unsigned DEFAULT NULL, + `api_cache_id` int unsigned NOT NULL AUTO_INCREMENT, + `user_id` int unsigned DEFAULT NULL, `key` char(40) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL, `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `expires_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - `request_resource` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL, - `request_method` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL, - `request_arguments` text CHARACTER SET utf8 COLLATE utf8_unicode_ci, `response_data` json DEFAULT NULL, `deleted` tinyint(1) NOT NULL DEFAULT '0', PRIMARY KEY (`api_cache_id`), @@ -86,26 +91,30 @@ DROP TABLE IF EXISTS `api_log`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `api_log` ( - `api_log_id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `request_ip` int(10) unsigned NOT NULL, - `user_id` int(10) unsigned DEFAULT NULL, - `request_api_user_id` int(10) unsigned DEFAULT NULL, - `request_timestamp` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - `request_resource` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT NULL, - `request_method` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT NULL, - `request_arguments` text CHARACTER SET utf8 COLLATE utf8_unicode_ci, - `response_error_code` int(10) unsigned DEFAULT NULL, - `response_data` longtext CHARACTER SET utf8 COLLATE utf8_unicode_ci, - `response_time` decimal(10,4) unsigned DEFAULT NULL, - `response_query_count` int(10) unsigned DEFAULT NULL, - `response_query_time` decimal(10,4) unsigned DEFAULT NULL, - `from_cache` tinyint(1) unsigned DEFAULT '0', - PRIMARY KEY (`api_log_id`), + `api_log_id` int unsigned NOT NULL AUTO_INCREMENT, + `user_id` int unsigned DEFAULT NULL, + `api_user_id` int unsigned DEFAULT NULL, + `ip_address` int unsigned NOT NULL, + `timestamp` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `request` json DEFAULT NULL, + `response` json DEFAULT NULL, + `error_code` int unsigned DEFAULT NULL, + `error_detail` json DEFAULT NULL, + `total_time` decimal(10,4) unsigned DEFAULT NULL, + `query_count` int unsigned DEFAULT NULL, + `query_time` decimal(10,4) unsigned DEFAULT NULL, + PRIMARY KEY (`api_log_id`,`timestamp`), KEY `user_id` (`user_id`), - KEY `request_ip_request_timestamp` (`request_ip`,`request_timestamp`), - KEY `request_timestamp` (`request_timestamp`), - KEY `request_api_user_id_request_timestamp` (`request_api_user_id`,`request_timestamp`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; + KEY `request_ip_request_timestamp` (`ip_address`,`timestamp`), + KEY `request_timestamp` (`timestamp`), + KEY `request_api_user_id_request_timestamp` (`api_user_id`,`timestamp`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci ROW_FORMAT=COMPRESSED +/*!50100 PARTITION BY RANGE (unix_timestamp(`timestamp`)) +(PARTITION 2020_10 VALUES LESS THAN (1604188800) ENGINE = InnoDB, + PARTITION 2020_11 VALUES LESS THAN (1606780800) ENGINE = InnoDB, + PARTITION 2020_12 VALUES LESS THAN (1609459200) ENGINE = InnoDB, + PARTITION 2021_01 VALUES LESS THAN (1612137600) ENGINE = InnoDB, + PARTITION 2021_02 VALUES LESS THAN (1614556800) ENGINE = InnoDB) */; /*!40101 SET character_set_client = @saved_cs_client */; -- @@ -116,7 +125,7 @@ DROP TABLE IF EXISTS `api_user`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `api_user` ( - `api_user_id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `api_user_id` int unsigned NOT NULL AUTO_INCREMENT, `name` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL, `api_key` char(40) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL, `session_key` char(40) CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT NULL, @@ -136,7 +145,7 @@ DROP TABLE IF EXISTS `ecobee_api_cache`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `ecobee_api_cache` ( - `ecobee_api_cache_id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `ecobee_api_cache_id` int unsigned NOT NULL AUTO_INCREMENT, `key` char(40) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL, `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `response` longtext CHARACTER SET utf8 COLLATE utf8_unicode_ci, @@ -154,16 +163,17 @@ DROP TABLE IF EXISTS `ecobee_api_log`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `ecobee_api_log` ( - `ecobee_api_log_id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `user_id` int(10) unsigned DEFAULT NULL, - `api_user_id` int(10) unsigned DEFAULT NULL, + `ecobee_api_log_id` int unsigned NOT NULL AUTO_INCREMENT, + `user_id` int unsigned DEFAULT NULL, + `api_user_id` int unsigned DEFAULT NULL, `request_timestamp` timestamp NULL DEFAULT NULL, `request` json DEFAULT NULL, - `response` text CHARACTER SET utf8 COLLATE utf8_unicode_ci, + `response` longtext CHARACTER SET utf8 COLLATE utf8_unicode_ci, `deleted` tinyint(1) NOT NULL DEFAULT '0', PRIMARY KEY (`ecobee_api_log_id`), KEY `user_id` (`user_id`), - KEY `api_user_id` (`api_user_id`) + KEY `api_user_id` (`api_user_id`), + KEY `request_timestamp` (`request_timestamp`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; @@ -175,10 +185,10 @@ DROP TABLE IF EXISTS `ecobee_sensor`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `ecobee_sensor` ( - `ecobee_sensor_id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `ecobee_sensor_id` int unsigned NOT NULL AUTO_INCREMENT, `identifier` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL, - `user_id` int(10) unsigned NOT NULL, - `ecobee_thermostat_id` int(10) unsigned NOT NULL, + `user_id` int unsigned NOT NULL, + `ecobee_thermostat_id` int unsigned NOT NULL, `name` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT NULL, `type` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT NULL, `code` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT NULL, @@ -202,10 +212,10 @@ DROP TABLE IF EXISTS `ecobee_thermostat`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `ecobee_thermostat` ( - `ecobee_thermostat_id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `identifier` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT NULL, - `guid` char(40) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL, - `user_id` int(10) unsigned NOT NULL, + `ecobee_thermostat_id` int unsigned NOT NULL AUTO_INCREMENT, + `identifier` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL, + `guid` char(40) CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT NULL, + `user_id` int unsigned NOT NULL, `name` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT NULL, `connected` tinyint(1) DEFAULT NULL, `thermostat_revision` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT NULL, @@ -253,10 +263,10 @@ DROP TABLE IF EXISTS `ecobee_token`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `ecobee_token` ( - `ecobee_token_id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `user_id` int(10) unsigned NOT NULL, - `access_token` char(32) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL, - `refresh_token` char(32) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL, + `ecobee_token_id` int unsigned NOT NULL AUTO_INCREMENT, + `user_id` int unsigned NOT NULL, + `access_token` text CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL, + `refresh_token` text CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL, `timestamp` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `deleted` tinyint(1) NOT NULL DEFAULT '0', PRIMARY KEY (`ecobee_token_id`), @@ -266,41 +276,42 @@ CREATE TABLE `ecobee_token` ( /*!40101 SET character_set_client = @saved_cs_client */; -- --- Table structure for table `mailchimp_api_cache` +-- Table structure for table `mailgun_api_cache` -- -DROP TABLE IF EXISTS `mailchimp_api_cache`; +DROP TABLE IF EXISTS `mailgun_api_cache`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; -CREATE TABLE `mailchimp_api_cache` ( - `ecobee_api_cache_id` int(10) unsigned NOT NULL AUTO_INCREMENT, +CREATE TABLE `mailgun_api_cache` ( + `mailgun_api_cache_id` int unsigned NOT NULL AUTO_INCREMENT, `key` char(40) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL, `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `response` longtext CHARACTER SET utf8 COLLATE utf8_unicode_ci, `deleted` tinyint(1) NOT NULL DEFAULT '0', - PRIMARY KEY (`ecobee_api_cache_id`), + PRIMARY KEY (`mailgun_api_cache_id`), KEY `user_id_key` (`key`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -- --- Table structure for table `mailchimp_api_log` +-- Table structure for table `mailgun_api_log` -- -DROP TABLE IF EXISTS `mailchimp_api_log`; +DROP TABLE IF EXISTS `mailgun_api_log`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; -CREATE TABLE `mailchimp_api_log` ( - `mailchimp_api_log_id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `user_id` int(10) unsigned DEFAULT NULL, - `api_user_id` int(10) unsigned DEFAULT NULL, +CREATE TABLE `mailgun_api_log` ( + `mailgun_api_log_id` int unsigned NOT NULL AUTO_INCREMENT, + `user_id` int unsigned DEFAULT NULL, + `api_user_id` int unsigned DEFAULT NULL, `request_timestamp` timestamp NULL DEFAULT NULL, `request` json DEFAULT NULL, `response` text COLLATE utf8_unicode_ci, `deleted` tinyint(1) NOT NULL DEFAULT '0', - PRIMARY KEY (`mailchimp_api_log_id`), + PRIMARY KEY (`mailgun_api_log_id`), KEY `user_id` (`user_id`), - KEY `api_user_id` (`api_user_id`) + KEY `api_user_id` (`api_user_id`), + KEY `request_timestamp` (`request_timestamp`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; @@ -312,12 +323,12 @@ DROP TABLE IF EXISTS `patreon_api_cache`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `patreon_api_cache` ( - `ecobee_api_cache_id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `patreon_api_cache_id` int unsigned NOT NULL AUTO_INCREMENT, `key` char(40) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL, `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `response` longtext CHARACTER SET utf8 COLLATE utf8_unicode_ci, `deleted` tinyint(1) NOT NULL DEFAULT '0', - PRIMARY KEY (`ecobee_api_cache_id`), + PRIMARY KEY (`patreon_api_cache_id`), KEY `user_id_key` (`key`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; @@ -330,16 +341,17 @@ DROP TABLE IF EXISTS `patreon_api_log`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `patreon_api_log` ( - `patreon_api_log_id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `user_id` int(10) unsigned DEFAULT NULL, - `api_user_id` int(10) unsigned DEFAULT NULL, + `patreon_api_log_id` int unsigned NOT NULL AUTO_INCREMENT, + `user_id` int unsigned DEFAULT NULL, + `api_user_id` int unsigned DEFAULT NULL, `request_timestamp` timestamp NULL DEFAULT NULL, `request` json DEFAULT NULL, `response` text COLLATE utf8_unicode_ci, `deleted` tinyint(1) NOT NULL DEFAULT '0', PRIMARY KEY (`patreon_api_log_id`), KEY `user_id` (`user_id`), - KEY `api_user_id` (`api_user_id`) + KEY `api_user_id` (`api_user_id`), + KEY `request_timestamp` (`request_timestamp`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; @@ -351,8 +363,8 @@ DROP TABLE IF EXISTS `patreon_token`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `patreon_token` ( - `patreon_token_id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `user_id` int(10) unsigned NOT NULL, + `patreon_token_id` int unsigned NOT NULL AUTO_INCREMENT, + `user_id` int unsigned NOT NULL, `access_token` char(43) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL, `refresh_token` char(43) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL, `timestamp` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -363,6 +375,30 @@ CREATE TABLE `patreon_token` ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; +-- +-- Table structure for table `runtime_sensor` +-- + +DROP TABLE IF EXISTS `runtime_sensor`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `runtime_sensor` ( + `runtime_sensor_id` bigint unsigned NOT NULL AUTO_INCREMENT, + `sensor_id` int unsigned NOT NULL, + `timestamp` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `temperature` smallint DEFAULT NULL, + `occupancy` tinyint unsigned DEFAULT NULL, + PRIMARY KEY (`runtime_sensor_id`,`timestamp`), + UNIQUE KEY `thermostat_id_timestamp` (`sensor_id`,`timestamp`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci ROW_FORMAT=COMPRESSED +/*!50100 PARTITION BY RANGE (unix_timestamp(`timestamp`)) +(PARTITION 2020_10 VALUES LESS THAN (1604188800) ENGINE = InnoDB, + PARTITION 2020_11 VALUES LESS THAN (1606780800) ENGINE = InnoDB, + PARTITION 2020_12 VALUES LESS THAN (1609459200) ENGINE = InnoDB, + PARTITION 2021_01 VALUES LESS THAN (1612137600) ENGINE = InnoDB, + PARTITION 2021_02 VALUES LESS THAN (1614556800) ENGINE = InnoDB) */; +/*!40101 SET character_set_client = @saved_cs_client */; + -- -- Table structure for table `runtime_thermostat` -- @@ -371,45 +407,44 @@ DROP TABLE IF EXISTS `runtime_thermostat`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `runtime_thermostat` ( - `runtime_thermostat_id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `thermostat_id` int(10) unsigned NOT NULL, + `runtime_thermostat_id` int unsigned NOT NULL AUTO_INCREMENT, + `thermostat_id` int unsigned NOT NULL, `timestamp` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - `compressor_1` smallint(5) unsigned DEFAULT NULL, - `compressor_2` smallint(5) unsigned DEFAULT NULL, + `compressor_1` smallint unsigned DEFAULT NULL, + `compressor_2` smallint unsigned DEFAULT NULL, `compressor_mode` enum('heat','cool','off') CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT NULL, - `auxiliary_heat_1` smallint(5) unsigned DEFAULT NULL, - `auxiliary_heat_2` smallint(5) unsigned DEFAULT NULL, - `fan` smallint(5) unsigned DEFAULT NULL, - `accessory` smallint(5) unsigned DEFAULT NULL, + `auxiliary_heat_1` smallint unsigned DEFAULT NULL, + `auxiliary_heat_2` smallint unsigned DEFAULT NULL, + `fan` smallint unsigned DEFAULT NULL, + `accessory` smallint unsigned DEFAULT NULL, `accessory_type` enum('humidifier','dehumidifier','ventilator','economizer','off') CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT NULL, `system_mode` enum('auto','auxiliary_heat','cool','heat','off') CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT NULL, - `indoor_temperature` smallint(6) DEFAULT NULL, - `indoor_humidity` tinyint(3) unsigned DEFAULT NULL, - `outdoor_temperature` smallint(6) DEFAULT NULL, - `outdoor_humidity` tinyint(3) unsigned DEFAULT NULL, - `event_runtime_thermostat_text_id` smallint(5) unsigned DEFAULT NULL, - `climate_runtime_thermostat_text_id` smallint(5) unsigned DEFAULT NULL, - `setpoint_cool` smallint(5) unsigned DEFAULT NULL, - `setpoint_heat` smallint(5) unsigned DEFAULT NULL, + `indoor_temperature` smallint DEFAULT NULL, + `indoor_humidity` tinyint unsigned DEFAULT NULL, + `outdoor_temperature` smallint DEFAULT NULL, + `outdoor_humidity` tinyint unsigned DEFAULT NULL, + `event_runtime_thermostat_text_id` smallint unsigned DEFAULT NULL, + `climate_runtime_thermostat_text_id` smallint unsigned DEFAULT NULL, + `setpoint_cool` smallint unsigned DEFAULT NULL, + `setpoint_heat` smallint unsigned DEFAULT NULL, PRIMARY KEY (`runtime_thermostat_id`,`timestamp`), UNIQUE KEY `thermostat_id_timestamp` (`thermostat_id`,`timestamp`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci ROW_FORMAT=COMPRESSED /*!50100 PARTITION BY RANGE (unix_timestamp(`timestamp`)) -(PARTITION 2018_10 VALUES LESS THAN (1541030400) ENGINE = InnoDB, - PARTITION 2018_11 VALUES LESS THAN (1543622400) ENGINE = InnoDB, - PARTITION 2018_12 VALUES LESS THAN (1546300800) ENGINE = InnoDB, - PARTITION 2019_01 VALUES LESS THAN (1548979200) ENGINE = InnoDB, - PARTITION 2019_02 VALUES LESS THAN (1551398400) ENGINE = InnoDB, - PARTITION 2019_03 VALUES LESS THAN (1554076800) ENGINE = InnoDB, - PARTITION 2019_04 VALUES LESS THAN (1556668800) ENGINE = InnoDB, - PARTITION 2019_05 VALUES LESS THAN (1559347200) ENGINE = InnoDB, - PARTITION 2019_06 VALUES LESS THAN (1561939200) ENGINE = InnoDB, - PARTITION 2019_07 VALUES LESS THAN (1564617600) ENGINE = InnoDB, - PARTITION 2019_08 VALUES LESS THAN (1567296000) ENGINE = InnoDB, - PARTITION 2019_09 VALUES LESS THAN (1569888000) ENGINE = InnoDB, - PARTITION 2019_10 VALUES LESS THAN (1572566400) ENGINE = InnoDB, - PARTITION 2019_11 VALUES LESS THAN (1575158400) ENGINE = InnoDB, - PARTITION 2019_12 VALUES LESS THAN (1577836800) ENGINE = InnoDB) */; +(PARTITION 2020_01 VALUES LESS THAN (1580515200) ENGINE = InnoDB, + PARTITION 2020_02 VALUES LESS THAN (1583020800) ENGINE = InnoDB, + PARTITION 2020_03 VALUES LESS THAN (1585699200) ENGINE = InnoDB, + PARTITION 2020_04 VALUES LESS THAN (1588291200) ENGINE = InnoDB, + PARTITION 2020_05 VALUES LESS THAN (1590969600) ENGINE = InnoDB, + PARTITION 2020_06 VALUES LESS THAN (1593561600) ENGINE = InnoDB, + PARTITION 2020_07 VALUES LESS THAN (1596240000) ENGINE = InnoDB, + PARTITION 2020_08 VALUES LESS THAN (1598918400) ENGINE = InnoDB, + PARTITION 2020_09 VALUES LESS THAN (1601510400) ENGINE = InnoDB, + PARTITION 2020_10 VALUES LESS THAN (1604188800) ENGINE = InnoDB, + PARTITION 2020_11 VALUES LESS THAN (1606780800) ENGINE = InnoDB, + PARTITION 2020_12 VALUES LESS THAN (1609459200) ENGINE = InnoDB, + PARTITION 2021_01 VALUES LESS THAN (1612137600) ENGINE = InnoDB, + PARTITION 2021_02 VALUES LESS THAN (1614556800) ENGINE = InnoDB) */; /*!40101 SET character_set_client = @saved_cs_client */; -- @@ -420,28 +455,28 @@ DROP TABLE IF EXISTS `runtime_thermostat_summary`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `runtime_thermostat_summary` ( - `runtime_thermostat_summary_id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `user_id` int(10) unsigned NOT NULL, - `thermostat_id` int(10) unsigned NOT NULL, + `runtime_thermostat_summary_id` int unsigned NOT NULL AUTO_INCREMENT, + `user_id` int unsigned NOT NULL, + `thermostat_id` int unsigned NOT NULL, `date` date NOT NULL, - `count` smallint(5) unsigned NOT NULL, - `sum_compressor_cool_1` mediumint(8) unsigned NOT NULL, - `sum_compressor_cool_2` mediumint(8) unsigned NOT NULL, - `sum_compressor_heat_1` mediumint(8) unsigned NOT NULL, - `sum_compressor_heat_2` mediumint(8) unsigned NOT NULL, - `sum_auxiliary_heat_1` mediumint(8) unsigned NOT NULL, - `sum_auxiliary_heat_2` mediumint(8) unsigned NOT NULL, - `sum_fan` mediumint(8) unsigned NOT NULL, - `sum_humidifier` mediumint(8) unsigned NOT NULL, - `sum_dehumidifier` mediumint(8) unsigned NOT NULL, - `sum_ventilator` mediumint(8) unsigned NOT NULL, - `sum_economizer` mediumint(8) unsigned NOT NULL, - `avg_outdoor_temperature` smallint(6) NOT NULL, - `avg_outdoor_humidity` tinyint(3) unsigned NOT NULL, - `min_outdoor_temperature` smallint(6) NOT NULL, - `max_outdoor_temperature` smallint(6) NOT NULL, - `avg_indoor_temperature` smallint(6) NOT NULL, - `avg_indoor_humidity` tinyint(3) unsigned NOT NULL, + `count` smallint unsigned NOT NULL, + `sum_compressor_cool_1` mediumint unsigned NOT NULL, + `sum_compressor_cool_2` mediumint unsigned NOT NULL, + `sum_compressor_heat_1` mediumint unsigned NOT NULL, + `sum_compressor_heat_2` mediumint unsigned NOT NULL, + `sum_auxiliary_heat_1` mediumint unsigned NOT NULL, + `sum_auxiliary_heat_2` mediumint unsigned NOT NULL, + `sum_fan` mediumint unsigned NOT NULL, + `sum_humidifier` mediumint unsigned NOT NULL, + `sum_dehumidifier` mediumint unsigned NOT NULL, + `sum_ventilator` mediumint unsigned NOT NULL, + `sum_economizer` mediumint unsigned NOT NULL, + `avg_outdoor_temperature` smallint NOT NULL, + `avg_outdoor_humidity` tinyint unsigned NOT NULL, + `min_outdoor_temperature` smallint NOT NULL, + `max_outdoor_temperature` smallint NOT NULL, + `avg_indoor_temperature` smallint NOT NULL, + `avg_indoor_humidity` tinyint unsigned NOT NULL, `deleted` tinyint(1) NOT NULL DEFAULT '0', PRIMARY KEY (`runtime_thermostat_summary_id`), UNIQUE KEY `thermostat_id_date` (`thermostat_id`,`date`), @@ -459,7 +494,7 @@ DROP TABLE IF EXISTS `runtime_thermostat_text`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `runtime_thermostat_text` ( - `runtime_thermostat_text_id` smallint(5) unsigned NOT NULL AUTO_INCREMENT, + `runtime_thermostat_text_id` smallint unsigned NOT NULL AUTO_INCREMENT, `value` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL, `deleted` tinyint(1) NOT NULL DEFAULT '0', PRIMARY KEY (`runtime_thermostat_text_id`), @@ -475,16 +510,18 @@ DROP TABLE IF EXISTS `sensor`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `sensor` ( - `sensor_id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `user_id` int(10) unsigned NOT NULL, - `thermostat_id` int(10) unsigned NOT NULL, - `ecobee_sensor_id` int(10) unsigned DEFAULT NULL, + `sensor_id` int unsigned NOT NULL AUTO_INCREMENT, + `user_id` int unsigned NOT NULL, + `thermostat_id` int unsigned NOT NULL, + `ecobee_sensor_id` int unsigned DEFAULT NULL, + `identifier` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT NULL, `name` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT NULL, `type` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT NULL, `in_use` tinyint(1) DEFAULT NULL, `temperature` decimal(4,1) DEFAULT NULL, - `humidity` int(10) unsigned DEFAULT NULL, + `humidity` int unsigned DEFAULT NULL, `occupancy` tinyint(1) DEFAULT NULL, + `capability` json DEFAULT NULL, `inactive` tinyint(1) NOT NULL DEFAULT '0', `deleted` tinyint(1) NOT NULL DEFAULT '0', PRIMARY KEY (`sensor_id`), @@ -505,16 +542,16 @@ DROP TABLE IF EXISTS `session`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `session` ( - `session_id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `session_id` int unsigned NOT NULL AUTO_INCREMENT, `session_key` char(128) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL, - `user_id` int(10) unsigned DEFAULT NULL, - `api_user_id` int(10) unsigned DEFAULT NULL, - `timeout` int(10) unsigned DEFAULT NULL, - `life` int(10) unsigned DEFAULT NULL, + `user_id` int unsigned DEFAULT NULL, + `api_user_id` int unsigned DEFAULT NULL, + `timeout` int unsigned DEFAULT NULL, + `life` int unsigned DEFAULT NULL, `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - `created_by` int(10) unsigned NOT NULL, + `created_by` int unsigned NOT NULL, `last_used_at` timestamp NULL DEFAULT NULL, - `last_used_by` int(10) unsigned NOT NULL, + `last_used_by` int unsigned NOT NULL, `deleted` tinyint(1) NOT NULL DEFAULT '0', PRIMARY KEY (`session_id`), UNIQUE KEY `key` (`session_key`), @@ -533,7 +570,7 @@ DROP TABLE IF EXISTS `smarty_streets_api_cache`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `smarty_streets_api_cache` ( - `smarty_streets_api_cache_id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `smarty_streets_api_cache_id` int unsigned NOT NULL AUTO_INCREMENT, `key` char(40) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL, `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `response` longtext CHARACTER SET utf8 COLLATE utf8_unicode_ci, @@ -551,16 +588,17 @@ DROP TABLE IF EXISTS `smarty_streets_api_log`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `smarty_streets_api_log` ( - `smarty_streets_api_log_id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `user_id` int(10) unsigned DEFAULT NULL, - `api_user_id` int(10) unsigned DEFAULT NULL, + `smarty_streets_api_log_id` int unsigned NOT NULL AUTO_INCREMENT, + `user_id` int unsigned DEFAULT NULL, + `api_user_id` int unsigned DEFAULT NULL, `request_timestamp` timestamp NULL DEFAULT NULL, `request` json DEFAULT NULL, `response` text COLLATE utf8_unicode_ci, `deleted` tinyint(1) NOT NULL DEFAULT '0', PRIMARY KEY (`smarty_streets_api_log_id`), KEY `user_id` (`user_id`), - KEY `api_user_id` (`api_user_id`) + KEY `api_user_id` (`api_user_id`), + KEY `request_timestamp` (`request_timestamp`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; @@ -572,34 +610,55 @@ DROP TABLE IF EXISTS `thermostat`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `thermostat` ( - `thermostat_id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `user_id` int(10) unsigned NOT NULL, - `ecobee_thermostat_id` int(10) unsigned DEFAULT NULL, - `thermostat_group_id` int(10) unsigned DEFAULT NULL, - `address_id` int(10) unsigned DEFAULT NULL, + `thermostat_id` int unsigned NOT NULL AUTO_INCREMENT, + `user_id` int unsigned NOT NULL, + `ecobee_thermostat_id` int unsigned DEFAULT NULL, + `thermostat_group_id` int unsigned DEFAULT NULL, + `address_id` int unsigned DEFAULT NULL, `name` varchar(255) DEFAULT NULL, `temperature` decimal(4,1) DEFAULT NULL, `temperature_unit` enum('°F','°C') DEFAULT NULL, - `humidity` int(10) unsigned DEFAULT NULL, + `humidity` int unsigned DEFAULT NULL, `alerts` json DEFAULT NULL, `first_connected` timestamp NULL DEFAULT NULL, `sync_begin` timestamp NULL DEFAULT NULL, `sync_end` timestamp NULL DEFAULT NULL, + `data_begin` timestamp NULL DEFAULT NULL, + `data_end` timestamp NULL DEFAULT NULL, `time_zone` varchar(255) DEFAULT NULL, `filters` json DEFAULT NULL, `temperature_profile` json DEFAULT NULL, + `profile` json DEFAULT NULL, `property` json DEFAULT NULL, `system_type` json DEFAULT NULL, + `system_type2` json DEFAULT NULL, `weather` json DEFAULT NULL, - `fan_fixed` timestamp NULL DEFAULT NULL, - `fan_fixed2` timestamp NULL DEFAULT NULL, + `settings` json DEFAULT NULL, + `program` json DEFAULT NULL, + `system_type_heat` enum('geothermal','compressor','boiler','gas','oil','electric','none') DEFAULT NULL, + `system_type_heat_stages` int unsigned DEFAULT NULL, + `system_type_heat_auxiliary` enum('electric','gas','oil','none') DEFAULT NULL, + `system_type_heat_auxiliary_stages` int unsigned DEFAULT NULL, + `system_type_cool` enum('geothermal','compressor','none') DEFAULT NULL, + `system_type_cool_stages` int unsigned DEFAULT NULL, + `property_age` int unsigned DEFAULT NULL, + `property_square_feet` int unsigned DEFAULT NULL, + `property_stories` int unsigned DEFAULT NULL, + `property_structure_type` enum('detached','apartment','condominium','loft','multiplex','townhouse','semi-detached') DEFAULT NULL, + `address_latitude` decimal(8,6) DEFAULT NULL, + `address_longitude` decimal(9,6) DEFAULT NULL, `inactive` tinyint(1) NOT NULL DEFAULT '0', `deleted` tinyint(1) NOT NULL DEFAULT '0', PRIMARY KEY (`thermostat_id`), KEY `ecobee_thermostat_id` (`ecobee_thermostat_id`), KEY `user_id` (`user_id`), KEY `thermostat_group_id` (`thermostat_group_id`), - KEY `address_id` (`address_id`) + KEY `address_id` (`address_id`), + KEY `comparison` (`system_type_heat`,`system_type_cool`,`system_type_heat_stages`,`system_type_cool_stages`,`property_structure_type`,`address_latitude`,`address_longitude`), + CONSTRAINT `thermostat_ibfk_1` FOREIGN KEY (`ecobee_thermostat_id`) REFERENCES `ecobee_thermostat` (`ecobee_thermostat_id`) ON DELETE RESTRICT ON UPDATE RESTRICT, + CONSTRAINT `thermostat_ibfk_2` FOREIGN KEY (`user_id`) REFERENCES `user` (`user_id`) ON DELETE RESTRICT ON UPDATE RESTRICT, + CONSTRAINT `thermostat_ibfk_3` FOREIGN KEY (`thermostat_group_id`) REFERENCES `thermostat_group` (`thermostat_group_id`) ON DELETE RESTRICT ON UPDATE RESTRICT, + CONSTRAINT `thermostat_ibfk_4` FOREIGN KEY (`address_id`) REFERENCES `address` (`address_id`) ON DELETE RESTRICT ON UPDATE RESTRICT ) ENGINE=InnoDB DEFAULT CHARSET=utf8; /*!40101 SET character_set_client = @saved_cs_client */; @@ -611,19 +670,20 @@ DROP TABLE IF EXISTS `thermostat_group`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `thermostat_group` ( - `thermostat_group_id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `user_id` int(10) unsigned NOT NULL, - `address_id` int(10) unsigned NOT NULL, + `thermostat_group_id` int unsigned NOT NULL AUTO_INCREMENT, + `user_id` int unsigned NOT NULL, + `address_id` int unsigned NOT NULL, `system_type_heat` enum('geothermal','compressor','boiler','gas','oil','electric','none') CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT NULL, `system_type_heat_auxiliary` enum('electric','gas','oil','none') CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT NULL, `system_type_cool` enum('geothermal','compressor','none') CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT NULL, - `property_age` int(10) unsigned DEFAULT NULL, - `property_square_feet` int(10) unsigned DEFAULT NULL, - `property_stories` int(10) unsigned DEFAULT NULL, + `property_age` int unsigned DEFAULT NULL, + `property_square_feet` int unsigned DEFAULT NULL, + `property_stories` int unsigned DEFAULT NULL, `property_structure_type` enum('detached','apartment','condominium','loft','multiplex','townhouse','semi-detached') CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT NULL, `address_latitude` decimal(10,8) DEFAULT NULL, `address_longitude` decimal(11,8) DEFAULT NULL, `temperature_profile` json DEFAULT NULL, + `profile` json DEFAULT NULL, `weather` json DEFAULT NULL, `deleted` tinyint(1) NOT NULL DEFAULT '0', PRIMARY KEY (`thermostat_group_id`), @@ -644,13 +704,15 @@ DROP TABLE IF EXISTS `user`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `user` ( - `user_id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `user_id` int unsigned NOT NULL AUTO_INCREMENT, `username` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT NULL, `password` char(60) CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT NULL, `anonymous` tinyint(1) NOT NULL, + `email_address` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL, `settings` json DEFAULT NULL, `patreon_status` json DEFAULT NULL, `sync_status` json DEFAULT NULL, + `debug` tinyint(1) DEFAULT '0', `comment` text CHARACTER SET utf8 COLLATE utf8_unicode_ci, `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, `deleted` tinyint(1) NOT NULL DEFAULT '0', @@ -668,4 +730,4 @@ CREATE TABLE `user` ( /*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; /*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; --- Dump completed on 2019-12-09 14:00:17 +-- Dump completed on 2021-01-23 19:10:19 diff --git a/api/cora/database.php b/api/cora/database.php index 6891ad5..f473528 100644 --- a/api/cora/database.php +++ b/api/cora/database.php @@ -292,7 +292,7 @@ final class database extends \mysqli { * @return string The appropriate escaped string. Examples: `foo` is null * `foo` in(1,2,3) `foo`='bar' */ - private function column_equals_value_where($column, $value) { + public function column_equals_value_where($column, $value) { if($value === null) { return $this->escape_identifier($column) . ' is null'; } diff --git a/api/ecobee_thermostat.php b/api/ecobee_thermostat.php index 5c8ebe6..dd824a4 100644 --- a/api/ecobee_thermostat.php +++ b/api/ecobee_thermostat.php @@ -212,39 +212,42 @@ class ecobee_thermostat extends cora\crud { $attributes['property'] = $this->get_property($thermostat, $ecobee_thermostat); $attributes['filters'] = $this->get_filters($thermostat, $ecobee_thermostat); $attributes['weather'] = $this->get_weather($thermostat, $ecobee_thermostat); + $attributes['settings'] = $this->get_settings($thermostat, $ecobee_thermostat); $attributes['time_zone'] = $this->get_time_zone($thermostat, $ecobee_thermostat); + $attributes['program'] = $this->get_program($thermostat, $ecobee_thermostat); - $detected_system_type = $this->get_detected_system_type($thermostat, $ecobee_thermostat); - if($thermostat['system_type'] === null) { - $attributes['system_type'] = [ + $detected_system_type2 = $this->get_detected_system_type2($thermostat, $ecobee_thermostat); + if($thermostat['system_type2'] === null) { + $attributes['system_type2'] = [ 'reported' => [ - 'heat' => null, - 'heat_auxiliary' => null, - 'cool' => null + 'heat' => [ + 'equipment' => null, + 'stages' => null + ], + 'heat_auxiliary' => [ + 'equipment' => null, + 'stages' => null + ], + 'cool' => [ + 'equipment' => null, + 'stages' => null + ] ], - 'detected' => $detected_system_type + 'detected' => $detected_system_type2 ]; } else { - $attributes['system_type'] = [ - 'reported' => $thermostat['system_type']['reported'], - 'detected' => $detected_system_type + $attributes['system_type2'] = [ + 'reported' => $thermostat['system_type2']['reported'], + 'detected' => $detected_system_type2 ]; } $attributes['alerts'] = $this->get_alerts( $thermostat, $ecobee_thermostat, - $attributes['system_type'] + $attributes['system_type2'] ); - $thermostat_group = $this->get_thermostat_group( - $thermostat, - $ecobee_thermostat, - $attributes['property'], - $address - ); - $attributes['thermostat_group_id'] = $thermostat_group['thermostat_group_id']; - $this->api( 'thermostat', 'update', @@ -255,16 +258,6 @@ class ecobee_thermostat extends cora\crud { ) ] ); - - // Update the thermostat_group system type and property type columns with - // the merged data from all of the thermostats in it. - $this->api( - 'thermostat_group', - 'sync_attributes', - [ - 'thermostat_group_id' => $thermostat_group['thermostat_group_id'] - ] - ); } // Update the email_address on the user. @@ -426,7 +419,7 @@ class ecobee_thermostat extends cora\crud { if(isset($ecobee_thermostat['house_details']['size']) === true) { $square_feet = $ecobee_thermostat['house_details']['size']; if(ctype_digit((string) $square_feet) === true && $square_feet > 0) { - $property['square_feet'] = (int) $square_feet; + $property['square_feet'] = round($square_feet / 500) * 500; } } @@ -559,14 +552,14 @@ class ecobee_thermostat extends cora\crud { // Has heat or cool if($system_type['reported']['heat'] !== null) { - $system_type_heat = $system_type['reported']['heat']; + $system_type_heat = $system_type['reported']['heat']['equipment']; } else { - $system_type_heat = $system_type['detected']['heat']; + $system_type_heat = $system_type['detected']['heat']['equipment']; } if($system_type['reported']['heat_auxiliary'] !== null) { - $system_type_heat_auxiliary = $system_type['reported']['heat_auxiliary']; + $system_type_heat_auxiliary = $system_type['reported']['heat_auxiliary']['equipment']; } else { - $system_type_heat_auxiliary = $system_type['detected']['heat_auxiliary']; + $system_type_heat_auxiliary = $system_type['detected']['heat_auxiliary']['equipment']; } $has_heat = ( $system_type_heat !== 'none' || @@ -574,9 +567,9 @@ class ecobee_thermostat extends cora\crud { ); if($system_type['reported']['cool'] !== null) { - $system_type_cool = $system_type['reported']['cool']; + $system_type_cool = $system_type['reported']['cool']['equipment']; } else { - $system_type_cool = $system_type['detected']['cool']; + $system_type_cool = $system_type['detected']['cool']['equipment']; } $has_cool = ($system_type_cool !== 'none'); @@ -653,42 +646,6 @@ class ecobee_thermostat extends cora\crud { return sha1($alert['text'] . $alert['source']); } - /** - * Figure out which group this thermostat belongs in based on the address. - * - * @param array $thermostat - * @param array $ecobee_thermostat - * @param array $property - * @param array $address - * - * @return array - */ - private function get_thermostat_group($thermostat, $ecobee_thermostat, $property, $address) { - $thermostat_group = $this->api( - 'thermostat_group', - 'get', - [ - 'attributes' => [ - 'address_id' => $address['address_id'] - ] - ] - ); - - if($thermostat_group === null) { - $thermostat_group = $this->api( - 'thermostat_group', - 'create', - [ - 'attributes' => [ - 'address_id' => $address['address_id'] - ] - ] - ); - } - - return $thermostat_group; - } - /** * Try and detect the type of HVAC system. * @@ -697,7 +654,7 @@ class ecobee_thermostat extends cora\crud { * * @return array System type for each of heat, cool, and aux. */ - private function get_detected_system_type($thermostat, $ecobee_thermostat) { + private function get_detected_system_type2($thermostat, $ecobee_thermostat) { $detected_system_type = []; $settings = $ecobee_thermostat['settings']; @@ -716,12 +673,16 @@ class ecobee_thermostat extends cora\crud { } // Heat + $detected_system_type['heat'] = [ + 'equipment' => null, + 'stages' => null + ]; if($settings['heatPumpGroundWater'] === true) { - $detected_system_type['heat'] = 'geothermal'; + $detected_system_type['heat']['equipment'] = 'geothermal'; } else if($settings['hasHeatPump'] === true) { - $detected_system_type['heat'] = 'compressor'; + $detected_system_type['heat']['equipment'] = 'compressor'; } else if($settings['hasBoiler'] === true) { - $detected_system_type['heat'] = 'boiler'; + $detected_system_type['heat']['equipment'] = 'boiler'; } else if(in_array('heat1', $outputs) === true) { // This is the fastest way I was able to determine this. The further north // you are the less likely you are to use electric heat. @@ -731,39 +692,67 @@ class ecobee_thermostat extends cora\crud { isset($address['normalized']['metadata']['latitude']) === true && $address['normalized']['metadata']['latitude'] > 30 ) { - $detected_system_type['heat'] = 'gas'; + $detected_system_type['heat']['equipment'] = 'gas'; } else { - $detected_system_type['heat'] = 'electric'; + $detected_system_type['heat']['equipment'] = 'electric'; } } else { - $detected_system_type['heat'] = 'electric'; + $detected_system_type['heat']['equipment'] = 'electric'; } } else { - $detected_system_type['heat'] = 'none'; + $detected_system_type['heat']['equipment'] = 'none'; } + // Rudimentary aux heat guess. It's pretty good overall but not as good as // heat/cool. + $detected_system_type['heat_auxiliary'] = [ + 'equipment' => null, + 'stages' => null + ]; if( - $detected_system_type['heat'] === 'gas' || - $detected_system_type['heat'] === 'boiler' || - $detected_system_type['heat'] === 'oil' || - $detected_system_type['heat'] === 'electric' + $detected_system_type['heat']['equipment'] === 'gas' || + $detected_system_type['heat']['equipment'] === 'boiler' || + $detected_system_type['heat']['equipment'] === 'oil' || + $detected_system_type['heat']['equipment'] === 'electric' ) { - $detected_system_type['heat_auxiliary'] = 'none'; - } else if($detected_system_type['heat'] === 'compressor') { - $detected_system_type['heat_auxiliary'] = 'electric'; - } else { - $detected_system_type['heat_auxiliary'] = null; + $detected_system_type['heat_auxiliary']['equipment'] = 'none'; + } else if($detected_system_type['heat']['equipment'] === 'compressor') { + $detected_system_type['heat_auxiliary']['equipment'] = 'electric'; } // Cool + $detected_system_type['cool'] = [ + 'equipment' => null, + 'stages' => null + ]; if($settings['heatPumpGroundWater'] === true) { - $detected_system_type['cool'] = 'geothermal'; + $detected_system_type['cool']['equipment'] = 'geothermal'; } else if(in_array('compressor1', $outputs) === true) { - $detected_system_type['cool'] = 'compressor'; + $detected_system_type['cool']['equipment'] = 'compressor'; } else { - $detected_system_type['cool'] = 'none'; + $detected_system_type['cool']['equipment'] = 'none'; + } + + /** + * Stages. For whatever reason, heat stages seem wrong. They appear to + * match the number of "furnace" stages on the ecobee. Attempt to fix this + * by assuming that if heat and cool are both a compressor then pick the + * max of these two for both. + */ + if( + $detected_system_type['heat']['equipment'] === 'compressor' && + $detected_system_type['cool']['equipment'] === 'compressor' + ) { + $stages = max( + $ecobee_thermostat['settings']['coolStages'], + $ecobee_thermostat['settings']['heatStages'] + ); + $detected_system_type['heat']['stages'] = $stages; + $detected_system_type['cool']['stages'] = $stages; + } else { + $detected_system_type['heat']['stages'] = $ecobee_thermostat['settings']['heatStages']; + $detected_system_type['cool']['stages'] = $ecobee_thermostat['settings']['coolStages']; } return $detected_system_type; @@ -779,7 +768,6 @@ class ecobee_thermostat extends cora\crud { */ private function get_weather($thermostat, $ecobee_thermostat) { $weather = [ - 'station' => null, 'dew_point' => null, 'barometric_pressure' => null, 'humidity_relative' => null, @@ -791,10 +779,6 @@ class ecobee_thermostat extends cora\crud { 'condition' => null ]; - if(isset($ecobee_thermostat['weather']['weatherStation']) === true) { - $weather['station'] = $ecobee_thermostat['weather']['weatherStation']; - } - if( isset($ecobee_thermostat['weather']['forecasts']) === true && isset($ecobee_thermostat['weather']['forecasts'][0]) === true @@ -910,6 +894,55 @@ class ecobee_thermostat extends cora\crud { return $weather; } + /** + * Get certain settings. + * + * @param array $thermostat + * @param array $ecobee_thermostat + * + * @return array + */ + private function get_settings($thermostat, $ecobee_thermostat) { + $settings = []; + + if(isset($ecobee_thermostat['settings']['stage1CoolingDifferentialTemp']) === true) { + $settings['differential_cool'] = ($ecobee_thermostat['settings']['stage1CoolingDifferentialTemp'] / 10); + } + + if(isset($ecobee_thermostat['settings']['stage1HeatingDifferentialTemp']) === true) { + $settings['differential_heat'] = ($ecobee_thermostat['settings']['stage1HeatingDifferentialTemp'] / 10); + } + + return $settings; + } + + /** + * Get program. This is just a clone of the ecobee_thermostat program with a + * slight modification to the temperature values. First, divide by 10 since + * this data is returned directly by the API. Second, round. This is weird + * to do, but as an example one of my comfort settings said "74" in the + * ecobee GUI but "73.5" in the API. After changing it in the GUI the + * fractional amount was removed. I assume this is an old ecobee bug but it + * affects like 10% of thermostats in my database. + * + * @param array $thermostat + * @param array $ecobee_thermostat + * + * @return array + */ + private function get_program($thermostat, $ecobee_thermostat) { + $program = $ecobee_thermostat['program']; + if(isset($program['climates']) === true) { + foreach($program['climates'] as &$climate) { + $climate['coolTemp'] = round($climate['coolTemp'] / 10); + $climate['heatTemp'] = round($climate['heatTemp'] / 10); + } + } + + return $program; + } + + /** * Get the current time zone. It's usually set. If not set use the offset * minutes to find it. Worst case default to the most common time zone. diff --git a/api/profile.php b/api/profile.php index e85cad8..2cc4bad 100644 --- a/api/profile.php +++ b/api/profile.php @@ -14,9 +14,7 @@ class profile extends cora\api { 'public' => [] ]; - public static $cache = [ - 'generate' => 604800 // 7 Days - ]; + public static $cache = []; /** * Generate a profile for the specified thermostat. @@ -118,6 +116,30 @@ class profile extends cora\api { // Get some stuff $thermostat = $this->api('thermostat', 'get', $thermostat_id); + if($thermostat['system_type2']['reported']['heat']['equipment'] !== null) { + $system_type_heat = $thermostat['system_type2']['reported']['heat']['equipment']; + } else { + $system_type_heat = $thermostat['system_type2']['detected']['heat']['equipment']; + } + if($thermostat['system_type2']['reported']['cool']['equipment'] !== null) { + $system_type_cool = $thermostat['system_type2']['reported']['cool']['equipment']; + } else { + $system_type_cool = $thermostat['system_type2']['detected']['cool']['equipment']; + } + + if($thermostat['system_type2']['reported']['heat']['stages'] !== null) { + $heat_stages = $thermostat['system_type2']['reported']['heat']['stages']; + } else { + $heat_stages = $thermostat['system_type2']['detected']['heat']['stages']; + } + if($thermostat['system_type2']['reported']['cool']['stages'] !== null) { + $cool_stages = $thermostat['system_type2']['reported']['cool']['stages']; + } else { + $cool_stages = $thermostat['system_type2']['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(); @@ -132,7 +154,7 @@ class profile extends cora\api { 'read', [ 'attributes' => [ - 'thermostat_group_id' => $thermostat['thermostat_group_id'], + 'address_id' => $thermostat['address_id'], 'inactive' => 0 ] ] @@ -185,7 +207,7 @@ class profile extends cora\api { 'cool_1' => 0, 'cool_2' => 0 ]; - $degree_days_baseline = 65; + $degree_days_base_temperature = 65; $degree_days = []; $begin_runtime = []; @@ -232,7 +254,7 @@ class profile extends cora\api { // Degree days if($date !== $degree_days_date) { - $degree_days[] = (array_mean($degree_days_temperatures) / 10) - $degree_days_baseline; + $degree_days[] = (array_mean($degree_days_temperatures) / 10) - $degree_days_base_temperature; $degree_days_date = $date; $degree_days_temperatures = []; } @@ -700,40 +722,40 @@ class profile extends cora\api { 'heat' => null, 'cool' => null ], + 'differential' => [ + 'heat' => null, + 'cool' => null + ], + 'setback' => [ + 'heat' => null, + 'cool' => null + ], 'runtime' => [ - 'heat_1' => round($runtime_seconds['heat_1'] / 3600), - 'heat_2' => round($runtime_seconds['heat_2'] / 3600), - 'auxiliary_heat_1' => round($runtime_seconds['auxiliary_heat_1'] / 3600), - 'auxiliary_heat_2' => round($runtime_seconds['auxiliary_heat_2'] / 3600), - 'cool_1' => round($runtime_seconds['cool_1'] / 3600), - 'cool_2' => round($runtime_seconds['cool_2'] / 3600), + '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 + ], + 'balance_point' => [ + 'heat_1' => null, + 'heat_2' => null, + 'resist' => null + ], + 'property' => [ + 'age' => null, + 'square_feet' => 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' => [] - ] - ] ] ]; @@ -748,7 +770,6 @@ class profile extends cora\api { count($data['deltas_per_hour']) >= $required_samples ) { $deltas[$type][$outdoor_temperature] = round(array_median($data['deltas_per_hour']) / 10, 2); - $profile['metadata']['temperature'][$type]['deltas'][$outdoor_temperature]['samples'] = count($data['deltas_per_hour']); } } } @@ -766,19 +787,28 @@ class profile extends cora\api { ]; } - foreach(['heat', 'cool'] as $type) { - if(count($setpoints[$type]) > 0) { - $profile['setpoint'][$type] = round(array_mean($setpoints[$type])) / 10; - $profile['metadata']['setpoint'][$type]['samples'] = count($setpoints[$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']['cool'] += ($degree_day * -1); + $profile['degree_days']['heat'] += ($degree_day * -1); } else { - $profile['degree_days']['heat'] += ($degree_day); + $profile['degree_days']['cool'] += ($degree_day); } } if ($profile['degree_days']['cool'] !== null) { @@ -788,6 +818,113 @@ class profile extends cora\api { $profile['degree_days']['heat'] = round($profile['degree_days']['heat']); } + // Runtime per degree day + if($profile['degree_days']['heat'] !== null) { + if( + $system_type_heat !== null && + $system_type_heat !== 'none' + ) { + $profile['runtime_per_degree_day']['heat_1'] = round($profile['runtime']['heat_1'] / $profile['degree_days']['heat'], 2); + if($heat_stages === 2) { + $profile['runtime_per_degree_day']['heat_2'] = round($profile['runtime']['heat_2'] / $profile['degree_days']['heat'], 2); + } + } + } + + if($profile['degree_days']['cool'] !== null) { + if( + $system_type_cool !== null && + $system_type_cool !== 'none' + ) { + $profile['runtime_per_degree_day']['cool_1'] = round($profile['runtime']['cool_1'] / $profile['degree_days']['cool'], 2); + if($cool_stages === 2) { + $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; + } + + // Property + if(isset($thermostat['property']['age']) === true) { + $profile['property']['age'] = $thermostat['property']['age']; + } + if(isset($thermostat['property']['square_feet']) === true) { + $profile['property']['square_feet'] = $thermostat['property']['square_feet']; + } return $profile; } diff --git a/api/temperature_profile.php b/api/temperature_profile.php deleted file mode 100644 index ce0a7d3..0000000 --- a/api/temperature_profile.php +++ /dev/null @@ -1,752 +0,0 @@ - [], - 'public' => [] - ]; - - public static $cache = [ - 'generate' => 604800 // 7 Days - ]; - - /** - * Generate a temperature 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' => 300, - 'cool' => 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); - - // 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 = array_column($group_thermostats, '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; - $cool_on_for = 0; - $heat_on_for = 0; - $samples = []; - $times = [ - 'heat' => [], - 'cool' => [], - 'resist' => [] - ]; - $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`, - `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); - - $runtime = []; - while($row = $result->fetch_assoc()) { - if( - $thermostat['system_type']['detected']['heat'] === 'compressor' || - $thermostat['system_type']['detected']['heat'] === 'geothermal' - ) { - if($row['compressor_mode'] === 'heat') { - $row['heat'] = max( - $row['compressor_1'], - $row['compressor_2'] - ); - } else { - $row['heat'] = 0; - } - $row['auxiliary_heat'] = max( - $row['auxiliary_heat_1'], - $row['auxiliary_heat_2'] - ); - } else { - $row['heat'] = max( - $row['auxiliary_heat_1'], - $row['auxiliary_heat_2'] - ); - $row['auxiliary_heat'] = 0; - } - - if($row['compressor_mode'] === 'cool') { - $row['cool'] = max( - $row['compressor_1'], - $row['compressor_2'] - ); - } else { - $row['cool'] = 0; - } - - $timestamp = strtotime($row['timestamp']); - 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) * 10; - - // Applies further smoothing if required. - $current_runtime['outdoor_temperature'] = round($current_runtime['outdoor_temperature'] / $smoothing) * $smoothing; - } - - /** - * 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 || - ( - // Wasn't syncing this until mid-November 2018. Just going with December to be safe. - $thermostat_runtime['climate_runtime_thermostat_text_id'] === null && - $current_timestamp > 1543640400 - ) - ) { - // 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 START - */ - - // Track how long the heat has been on for. - if($current_runtime['heat'] > 0) { - $heat_on_for += $current_runtime['heat']; - } else { - if($heat_on_for > 0) { - $times['heat'][] = $heat_on_for; - } - $heat_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_on_for >= $minimum_on_for && - $current_runtime['auxiliary_heat'] === 0 && - isset($begin_runtime['heat']) === false - ) { - $begin_runtime['heat'] = $current_runtime; - } - - /** - * COOL START - */ - - // Track how long the cool has been on for. - if($current_runtime['cool'] > 0) { - $cool_on_for += $current_runtime['cool']; - } else { - if($cool_on_for > 0) { - $times['cool'][] = $cool_on_for; - } - $cool_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_on_for >= $minimum_on_for && - isset($begin_runtime['cool']) === false - ) { - $begin_runtime['cool'] = $current_runtime; - } - - - // Look for changes which would trigger a sample to be gathered. - if( - ( - // Heat - // Gather a "heat" 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') && - isset($begin_runtime['heat']) === true && - isset($previous_runtime) === true && - ( - $current_runtime['outdoor_temperature'] !== $begin_runtime['heat']['outdoor_temperature'] || - $current_runtime['event_runtime_thermostat_text_id'] !== $begin_runtime['heat']['event_runtime_thermostat_text_id'] || - $current_runtime['climate_runtime_thermostat_text_id'] !== $begin_runtime['heat']['climate_runtime_thermostat_text_id'] || - $most_off === false - ) - ) || - ( - // Cool - // Gather a "cool" 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') && - isset($begin_runtime['cool']) === true && - isset($previous_runtime) === true && - ( - $current_runtime['outdoor_temperature'] !== $begin_runtime['cool']['outdoor_temperature'] || - $current_runtime['event_runtime_thermostat_text_id'] !== $begin_runtime['cool']['event_runtime_thermostat_text_id'] || - $current_runtime['climate_runtime_thermostat_text_id'] !== $begin_runtime['cool']['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_on_for === 0 || - $current_runtime['outdoor_temperature'] === null || - $current_runtime['indoor_temperature'] === null || - $current_runtime['auxiliary_heat'] > 0 - ) { - unset($begin_runtime['heat']); - } - if( - $cool_on_for === 0 || - $current_runtime['outdoor_temperature'] === null || - $current_runtime['indoor_temperature'] === null - ) { - unset($begin_runtime['cool']); - } - if($all_off_for === 0) { - unset($begin_runtime['resist']); - } - - $current_timestamp += $five_minutes; - } - - // 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']; - - } - } - - $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); - } - } - } - - // Generate the final temperature profile and save it. - $temperature_profile = []; - foreach($deltas as $type => $data) { - if(count($data) < $required_points) { - continue; - } - - ksort($deltas[$type]); - - // For heating/cooling, factor in cycle time. - if(count($times[$type]) > 0) { - $cycles_per_hour = round(60 / (array_median($times[$type]) / 60), 2); - } else { - $cycles_per_hour = null; - } - - - $linear_trendline = $this->api( - 'temperature_profile', - 'get_linear_trendline', - [ - 'data' => $deltas[$type] - ] - ); - $temperature_profile[$type] = [ - 'deltas' => $deltas[$type], - 'linear_trendline' => $linear_trendline, - 'cycles_per_hour' => $cycles_per_hour, - 'metadata' => [ - 'generated_at' => date('Y-m-d H:i:s') - ] - ]; - - $temperature_profile[$type]['score'] = $this->api( - 'temperature_profile', - 'get_score', - [ - 'type' => $type, - 'temperature_profile' => $temperature_profile[$type] - ] - ); - - } - - // 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->api( - 'thermostat', - 'update', - [ - 'attributes' => [ - 'thermostat_id' => $thermostat['thermostat_id'], - 'temperature_profile' => $temperature_profile - ] - ] - ); - // } - - $this->database->set_time_zone(0); - - // Force these to actually return, but set them to null if there's no data. - foreach(['heat', 'cool', 'resist'] as $type) { - if( - isset($temperature_profile[$type]) === false || - count($temperature_profile[$type]['deltas']) === 0 - ) { - $temperature_profile[$type] = null; - } - } - - return $temperature_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) - ]; - } - - /** - * Get the score from a linear trendline. For heating and cooling the slope - * is most of the score. For resist it is all of the score. - * - * Slope score is calculated as a percentage between 0 and whatever 3 - * standard deviations from the mean is. For example, if that gives a range - * from 0-5, a slope of 2.5 would give you a base score of 0.5 which is then - * weighted in with the rest of the factors. - * - * Cycles per hour score is calculated as a flat 0.25 base score for every - * CPH under 4. For example, a CPH of 1 - * - * @param array $temperature_profile - * - * @return int - */ - public function get_score($type, $temperature_profile) { - if( - $temperature_profile['linear_trendline'] === null - ) { - return null; - } - - $weights = [ - 'heat' => [ - 'slope' => 0.6, - 'cycles_per_hour' => 0.1, - 'balance_point' => 0.3 - ], - 'cool' => [ - 'slope' => 0.6, - 'cycles_per_hour' => 0.1, - 'balance_point' => 0.3 - ], - 'resist' => [ - 'slope' => 1 - ] - ]; - - // Slope score - switch($type) { - case 'heat': - $slope_mean = 0.042; - $slope_standard_deviation = 0.179; - $balance_point_mean = -12.235; - // This is arbitrary. The actual SD is really high due to what I think - // is poor data. Further investigating but for now this does a pretty - // good job. - $balance_point_standard_deviation = 20; - break; - case 'cool': - $slope_mean = 0.066; - $slope_standard_deviation = 0.29; - $balance_point_mean = 90.002; - // This is arbitrary. The actual SD is really high due to what I think - // is poor data. Further investigating but for now this does a pretty - // good job. - $balance_point_standard_deviation = 20; - break; - case 'resist': - $slope_mean = 0.034; - $slope_standard_deviation = 0.018; - break; - } - - $parts = []; - $slope_max = $slope_mean + ($slope_standard_deviation * 3); - $parts['slope'] = ($slope_max - $temperature_profile['linear_trendline']['slope']) / $slope_max; - $parts['slope'] = max(0, min(1, $parts['slope'])); - - if($type === 'heat' || $type === 'cool') { - if($temperature_profile['linear_trendline']['slope'] == 0) { - $parts['balance_point'] = 1; - } else { - $balance_point_min = $balance_point_mean - ($balance_point_standard_deviation * 3); - $balance_point_max = $balance_point_mean + ($balance_point_standard_deviation * 3); - $balance_point = -$temperature_profile['linear_trendline']['intercept'] / $temperature_profile['linear_trendline']['slope']; - $parts['balance_point'] = ($balance_point - $balance_point_min) / ($balance_point_max - $balance_point_min); - $parts['balance_point'] = max(0, min(1, $parts['balance_point'])); - } - } - - // Cycles per hour score - if($temperature_profile['cycles_per_hour'] !== null) { - $parts['cycles_per_hour'] = (4 - $temperature_profile['cycles_per_hour']) * 0.25; - $parts['cycles_per_hour'] = max(0, min(1, $parts['cycles_per_hour'])); - } - - $score = 0; - foreach($parts as $key => $value) { - $score += $value * $weights[$type][$key]; - } - - return round($score * 10, 1); - } - -} diff --git a/api/thermostat.php b/api/thermostat.php index b0e2965..08f0387 100644 --- a/api/thermostat.php +++ b/api/thermostat.php @@ -12,15 +12,135 @@ class thermostat extends cora\crud { 'read_id', 'sync', 'dismiss_alert', - 'restore_alert' + 'restore_alert', + 'set_reported_system_types', + 'generate_profile', + 'get_metrics' ], 'public' => [] ]; public static $cache = [ - 'sync' => 180 // 3 Minutes + '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_type2']) === true) { + foreach(['heat', 'heat_auxiliary', 'cool'] as $mode) { + if($attributes['system_type2']['reported'][$mode]['equipment'] !== null) { + $generated_columns['system_type_' . $mode] = $attributes['system_type2']['reported'][$mode]['equipment']; + } else { + $generated_columns['system_type_' . $mode] = $attributes['system_type2']['detected'][$mode]['equipment']; + } + if($attributes['system_type2']['reported'][$mode]['stages'] !== null) { + $generated_columns['system_type_' . $mode . '_stages'] = $attributes['system_type2']['reported'][$mode]['stages']; + } else { + $generated_columns['system_type_' . $mode . '_stages'] = $attributes['system_type2']['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', 'heat_auxiliary', 'cool']) === true) { + $thermostat['system_type2']['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_heat_auxiliary']); + unset($thermostat['system_type_heat_auxiliary_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. @@ -96,4 +216,368 @@ class thermostat extends cora\crud { ] ); } + + /** + * Generate a new profile for this thermostat. + * + * @param int $thermostat_id + */ + public function generate_profile($thermostat_id) { + return $this->update([ + 'thermostat_id' => $thermostat_id, + 'profile' => $this->api('profile', 'generate', $thermostat_id) + ]); + } + + /** + * Compare this thermostat to all other matching ones. + * + * @param array $thermosat_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 = []; + + $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_custom = [ + 'property_structure_type', + 'property_age', + 'property_square_feet', + 'property_stories' + ]; + foreach($keys_custom 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]; + } + } + } + + // Should match their position in the thermostat profile exactly. + $metric_codes = [ + 'property' => [ + 'age', + 'square_feet' + ], + 'runtime_per_degree_day' => [ + 'heat_1', + '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, + '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]; + } + } + + $limit_start = 0; + $limit_count = 250; + + /** + * 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 + * + 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'] = 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'] >= 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']); + // print_r($data); + if(count($data['values']) > 0) { + $metrics[$parent_metric_name][$child_metric_name]['histogram'] = $data['histogram']; + $metrics[$parent_metric_name][$child_metric_name]['standard_deviation'] = $this->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; + } + + /** + * Calculate the standard deviation of an array of numbers. + * + * @param array $array The values. + * + * @return int The standard deviation. + */ + private function standard_deviation($array) { + $count = count($array); + + if ($count === 0) { + return null; + } + + $mean = array_mean($array); + + $variance = 0; + foreach($array as $i) { + $variance += pow(($i - $mean), 2); + } + + return round(sqrt($variance / $count), 1); + } + + /** + * 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 = $this->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 + ]; + } } diff --git a/api/thermostat_group.php b/api/thermostat_group.php deleted file mode 100644 index df0085d..0000000 --- a/api/thermostat_group.php +++ /dev/null @@ -1,904 +0,0 @@ - [ - 'read_id', - 'generate_temperature_profiles', - 'generate_temperature_profile', - 'generate_profiles', - 'generate_profile', - 'get_scores', - 'get_metrics', - 'update_system_types' - ], - 'public' => [] - ]; - - 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 - // 'get_metrics' => 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' => null, - 'cool' => null - ], - 'degree_days' => [ - 'heat' => null, - 'cool' => null - ], - 'runtime' => [ - 'heat_1' => 0, - 'heat_2' => 0, - 'auxiliary_heat_1' => 0, - 'auxiliary_heat_2' => 0, - 'cool_1' => 0, - 'cool_2' => 0 - ], - 'metadata' => [ - 'generated_at' => date('c'), - 'duration' => null, - 'setpoint' => [ - 'heat' => [ - 'samples' => null - ], - 'cool' => [ - 'samples' => null - ] - ], - 'temperature' => [] - ] - ]; - - if (count($profiles) === 0) { - $this->update( - [ - 'thermostat_group_id' => $thermostat_group_id, - 'profile' => $group_profile - ] - ); - - return $group_profile; - } - - $metadata_duration = []; - - // Setpoint heat min/max/average. - $metadata_setpoint_heat_samples = []; - $setpoint_heat = []; - - // Setpoint cool min/max/average. - $metadata_setpoint_cool_samples = []; - $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']; - } - - 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']; - } - - 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']; - } - } - } - - // Degree days. - if($profile['degree_days']['heat'] !== null) { - $group_profile['degree_days']['heat'] += $profile['degree_days']['heat']; - } - if($profile['degree_days']['cool'] !== null) { - $group_profile['degree_days']['cool'] += $profile['degree_days']['cool']; - } - - // Runtime - $group_profile['runtime']['heat_1'] += $profile['runtime']['heat_1']; - $group_profile['runtime']['heat_2'] += $profile['runtime']['heat_2']; - $group_profile['runtime']['auxiliary_heat_1'] += $profile['runtime']['auxiliary_heat_1']; - $group_profile['runtime']['auxiliary_heat_2'] += $profile['runtime']['auxiliary_heat_2']; - $group_profile['runtime']['cool_1'] += $profile['runtime']['cool_1']; - $group_profile['runtime']['cool_2'] += $profile['runtime']['cool_2']; - } - - // echo '
';
-    // print_r($profiles);
-    // 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'] = 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'] = 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.
-    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.
-   *
-   * @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) {
-    // See #281
-    set_time_limit(30);
-
-    // 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']);
-    }
-
-    $scores = [];
-    $limit_start = 0;
-    $limit_count = 100;
-
-    /**
-     * Selecting lots of rows can eventually run PHP out of memory, so chunk
-     * this up into several queries to avoid that.
-     */
-    do {
-      // Get all matching thermostat groups.
-      $other_thermostat_groups = $this->database->read(
-        'thermostat_group',
-        $attributes,
-        [], // columns
-        [], // order_by
-        [$limit_start, $limit_count] // limit
-      );
-
-      // Get all the scores from the other thermostat groups
-      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'];
-        }
-      }
-
-      $limit_start += $limit_count;
-    } while (count($other_thermostat_groups) === $limit_count);
-
-    sort($scores);
-
-    return $scores;
-  }
-
-  /**
-   * Compare this thermostat_group to all other matching ones.
-   *
-   * @param array $attributes The attributes to compare to.
-   *
-   * @return array
-   */
-  public function get_metrics($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']);
-    }
-
-    $metric_codes = [
-      'setpoint_heat',
-      'setpoint_cool',
-      // 'runtime_per_heating_degree_day',
-      'runtime_percentage_heat_1',
-      'runtime_percentage_heat_2',
-      'runtime_percentage_auxiliary_heat_1',
-      'runtime_percentage_auxiliary_heat_2',
-      'runtime_percentage_cool_1',
-      'runtime_percentage_cool_2'
-    ];
-
-    $metrics = [];
-    foreach($metric_codes as $metric_code) {
-      $metrics[$metric_code] = [
-        'values' => [],
-        'histogram' => [],
-        'standard_deviation' => null,
-        'median' => null
-      ];
-    }
-
-    $limit_start = 0;
-    $limit_count = 100;
-
-    /**
-     * Selecting lots of rows can eventually run PHP out of memory, so chunk
-     * this up into several queries to avoid that.
-     */
-    do {
-      // Get all matching thermostat groups.
-      $other_thermostat_groups = $this->database->read(
-        'thermostat_group',
-        $attributes,
-        [], // columns
-        [], // order_by
-        [$limit_start, $limit_count] // limit
-      );
-
-      // Get all the scores from the other thermostat groups
-      foreach($other_thermostat_groups as $other_thermostat_group) {
-        // Only use profiles with at least a year of data
-        // Only use profiles generated in the past year
-        //
-        if(
-          $other_thermostat_group['profile']['metadata']['duration'] >= 365 &&
-          strtotime($other_thermostat_group['profile']['metadata']['generated_at']) > strtotime('-1 year')
-        ) {
-          // 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;
-          }
-
-          // setpoint_heat
-          if($other_thermostat_group['profile']['setpoint']['heat'] !== null) {
-            $setpoint_heat = round($other_thermostat_group['profile']['setpoint']['heat']);
-            if(isset($metrics['setpoint_heat']['histogram'][$setpoint_heat]) === false) {
-              $metrics['setpoint_heat']['histogram'][$setpoint_heat] = 0;
-            }
-            $metrics['setpoint_heat']['histogram'][$setpoint_heat]++;
-            $metrics['setpoint_heat']['values'][] = $setpoint_heat;
-          }
-
-          // setpoint_cool
-          if($other_thermostat_group['profile']['setpoint']['cool'] !== null) {
-            $setpoint_cool = round($other_thermostat_group['profile']['setpoint']['cool']);
-            if(isset($metrics['setpoint_cool']['histogram'][$setpoint_cool]) === false) {
-              $metrics['setpoint_cool']['histogram'][$setpoint_cool] = 0;
-            }
-            $metrics['setpoint_cool']['histogram'][$setpoint_cool]++;
-            $metrics['setpoint_cool']['values'][] = $setpoint_cool;
-          }
-
-
-          // runtime_per_heating_degree_day
-          // todo division by 0 error here. Also remove this?
-/*          if(
-            isset($other_thermostat_group['profile']) === true &&
-            isset($other_thermostat_group['profile']['runtime']) == true &&
-            $other_thermostat_group['profile']['runtime']['heat_1'] !== null &&
-            isset($other_thermostat_group['profile']['degree_days']) === true &&
-            $other_thermostat_group['profile']['degree_days']['heat'] !== null
-          ) {
-            $runtime_per_heating_degree_day = round(
-              $other_thermostat_group['profile']['runtime']['heat_1'] / $other_thermostat_group['profile']['degree_days']['heat'],
-              1
-            );
-            if(isset($metrics['runtime_per_heating_degree_day']['histogram'][(string)$runtime_per_heating_degree_day]) === false) {
-              $metrics['runtime_per_heating_degree_day']['histogram'][(string)$runtime_per_heating_degree_day] = 0;
-            }
-            $metrics['runtime_per_heating_degree_day']['histogram'][(string)$runtime_per_heating_degree_day]++;
-            $metrics['runtime_per_heating_degree_day']['values'][] = $runtime_per_heating_degree_day;
-          }*/
-
-
-
-
-          // runtime_percentage_heat_1
-/*          $total_runtime_heat =
-            $other_thermostat_group['profile']['runtime']['heat_1'] +
-            $other_thermostat_group['profile']['runtime']['heat_2'] +
-            $other_thermostat_group['profile']['runtime']['auxiliary_heat_1'] +
-            $other_thermostat_group['profile']['runtime']['auxiliary_heat_2'];
-
-          if($total_runtime_heat > 0) {
-            $runtime_percentage_heat_1 = $other_thermostat_group['profile']['runtime']['heat_1'] / $total_runtime_heat * 100;
-
-            if(isset($metrics['runtime_percentage_heat_1']['histogram'][$runtime_percentage_heat_1]) === false) {
-              $metrics['runtime_percentage_heat_1']['histogram'][$runtime_percentage_heat_1] = 0;
-            }
-            $metrics['runtime_percentage_heat_1']['histogram'][$runtime_percentage_heat_1]++;
-            $metrics['runtime_percentage_heat_1']['values'][] = $runtime_percentage_heat_1;
-          }*/
-        }
-      }
-
-      $limit_start += $limit_count;
-    } while (count($other_thermostat_groups) === $limit_count);
-
-    // setpoint_heat
-    $metrics['setpoint_heat']['standard_deviation'] = round($this->standard_deviation(
-      $metrics['setpoint_heat']['values']
-    ), 2);
-    $metrics['setpoint_heat']['median'] = array_median($metrics['setpoint_heat']['values']);
-    unset($metrics['setpoint_heat']['values']);
-
-    // setpoint_cool
-    $metrics['setpoint_cool']['standard_deviation'] = round($this->standard_deviation(
-      $metrics['setpoint_cool']['values']
-    ), 2);
-    $metrics['setpoint_cool']['median'] = array_median($metrics['setpoint_cool']['values']);
-    unset($metrics['setpoint_cool']['values']);
-
-    // runtime_per_heating_degree_day
-    // $metrics['runtime_per_heating_degree_day']['standard_deviation'] = round($this->standard_deviation(
-    //   $metrics['runtime_per_heating_degree_day']['values']
-    // ), 2);
-    // $metrics['runtime_per_heating_degree_day']['median'] = array_median($metrics['runtime_per_heating_degree_day']['values']);
-    // unset($metrics['runtime_per_heating_degree_day']['values']);
-
-    // runtime_percentage_heat_1
-    // $metrics['runtime_percentage_heat_1']['standard_deviation'] = round($this->standard_deviation(
-    //   $metrics['runtime_percentage_heat_1']['values']
-    // ), 2);
-    // $metrics['runtime_percentage_heat_1']['median'] = array_median($metrics['runtime_percentage_heat_1']['values']);
-    // unset($metrics['runtime_percentage_heat_1']['values']);
-
-    return $metrics;
-  }
-
-  private function standard_deviation($array) {
-    $count = count($array);
-
-    $mean = array_mean($array);
-
-    $variance = 0;
-    foreach($array as $i) {
-      $variance += pow(($i - $mean), 2);
-    }
-
-    return sqrt($variance / $count);
-  }
-
-  /**
-   * 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,
-          'inactive' => 0
-        ]
-      ]
-    );
-
-    $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);
-  }
-}
diff --git a/img/nest/connect.png b/img/nest/connect.png
deleted file mode 100644
index 0d7310a..0000000
Binary files a/img/nest/connect.png and /dev/null differ
diff --git a/img/nest/logo.png b/img/nest/logo.png
deleted file mode 100644
index 6dcff16..0000000
Binary files a/img/nest/logo.png and /dev/null differ
diff --git a/img/nest/logo.svg b/img/nest/logo.svg
deleted file mode 100644
index f1fcbaf..0000000
--- a/img/nest/logo.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/img/waveform.png b/img/waveform.png
deleted file mode 100644
index c0ae8e3..0000000
Binary files a/img/waveform.png and /dev/null differ
diff --git a/js/beestat.js b/js/beestat.js
index 9416b37..f1cf3f3 100644
--- a/js/beestat.js
+++ b/js/beestat.js
@@ -56,7 +56,7 @@ beestat.get_climate = function(climate_ref) {
 /**
  * Get the color a thermostat should be based on what equipment is running.
  *
- * @return {string}
+ * @return {string} The color string.
  */
 beestat.get_thermostat_color = function(thermostat_id) {
   var thermostat = beestat.cache.thermostat[thermostat_id];
@@ -131,7 +131,8 @@ beestat.width = window.innerWidth;
 window.addEventListener('resize', rocket.throttle(100, function() {
   var breakpoints = [
     600,
-    650
+    650,
+    1000
   ];
 
   breakpoints.forEach(function(breakpoint) {
diff --git a/js/beestat/comparisons.js b/js/beestat/comparisons.js
index 4c7573b..c9b1b83 100644
--- a/js/beestat/comparisons.js
+++ b/js/beestat/comparisons.js
@@ -1,98 +1,32 @@
 beestat.comparisons = {};
 
-/**
- * Fire off an API call to get the comparison scores using the currently
- * defined settings. Updates the cache with the response which fires off the
- * vent for anything bound to that data.
- *
- * Note that this fires off a batch API call for heat, cool, and resist
- * scores. So if you *only* had the resist card on the dashboard you would
- * still get all three. I think the most common use case is showing all three
- * scores, so the layer loader will be able to optimize away the duplicate
- * requests and do one multi API call instead of three distinct API calls.
- *
- * @param {Function} callback Optional callback to fire when the API call
- * completes.
- */
-beestat.comparisons.get_comparison_scores = function(callback) {
-  var types = [
-    'heat',
-    'cool',
-    'resist'
-  ];
-
-  var api = new beestat.api();
-  types.forEach(function(type) {
-    beestat.cache.delete('data.comparison_scores_' + type);
-    api.add_call(
-      'thermostat_group',
-      'get_scores',
-      {
-        'type': type,
-        'attributes': beestat.comparisons.get_comparison_attributes(type)
-      },
-      'score_' + type
-    );
-  });
-
-  if (beestat.user.has_early_access() === true) {
-    api.add_call(
-      'thermostat_group',
-      'get_metrics',
-      {
-        'attributes': beestat.comparisons.get_comparison_attributes('resist') // todo
-      },
-      'metrics'
-    );
-  }
-
-  api.set_callback(function(data) {
-    if (beestat.user.has_early_access() === true) {
-      beestat.cache.set('data.metrics', data.metrics);
-    }
-
-    types.forEach(function(type) {
-      beestat.cache.set('data.comparison_scores_' + type, data['score_' + type]);
-    });
-
-    if (callback !== undefined) {
-      callback();
-    }
-  });
-
-  api.send();
-};
-
 /**
  * Based on the comparison settings chosen in the GUI, get the proper broken
  * out comparison attributes needed to make an API call.
  *
- * @param {string} type heat|cool|resist
- *
- * @return {Object} The comparison attributes.
+ * @return {object} The comparison attributes.
  */
-beestat.comparisons.get_comparison_attributes = function(type) {
+beestat.comparisons.get_attributes = function() {
   var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')];
-  var thermostat_group =
-    beestat.cache.thermostat_group[thermostat.thermostat_group_id];
+  var address = beestat.cache.address[thermostat.address_id];
 
   var attributes = {};
 
   if (beestat.setting('comparison_property_type') === 'similar') {
     // Match structure type exactly.
-    if (thermostat_group.property_structure_type !== null) {
+    if (thermostat.property.structure_type !== null) {
       attributes.property_structure_type =
-        thermostat_group.property_structure_type;
+        thermostat.property.structure_type;
     }
 
     // Always a 10 year age delta on both sides.
-    if (thermostat_group.property_age !== null) {
+    if (thermostat.property.age !== null) {
       var property_age_delta = 10;
       var min_property_age = Math.max(
         0,
-        thermostat_group.property_age - property_age_delta
+        thermostat.property.age - property_age_delta
       );
-      var max_property_age = thermostat_group.property_age + property_age_delta;
+      var max_property_age = thermostat.property.age + property_age_delta;
       attributes.property_age = {
         'operator': 'between',
         'value': [
@@ -103,14 +37,14 @@ beestat.comparisons.get_comparison_attributes = function(type) {
     }
 
     // Always a 1000sqft size delta on both sides (total 2000 sqft).
-    if (thermostat_group.property_square_feet !== null) {
+    if (thermostat.property.square_feet !== null) {
       var property_square_feet_delta = 1000;
       var min_property_square_feet = Math.max(
         0,
-        thermostat_group.property_square_feet - property_square_feet_delta
+        thermostat.property.square_feet - property_square_feet_delta
       );
       var max_property_square_feet =
-        thermostat_group.property_square_feet +
+        thermostat.property.square_feet +
         property_square_feet_delta;
       attributes.property_square_feet = {
         'operator': 'between',
@@ -126,40 +60,39 @@ beestat.comparisons.get_comparison_attributes = function(type) {
      * Apartments ignore this.
      */
     if (
-      thermostat_group.property_stories !== null &&
-      thermostat_group.property_structure_type !== 'apartment'
+      thermostat.property.stories !== null &&
+      thermostat.property.structure_type !== 'apartment'
     ) {
-      if (thermostat_group.property_stories < 2) {
-        attributes.property_stories = thermostat_group.property_stories;
+      if (thermostat.property.stories < 2) {
+        attributes.property_stories = thermostat.property.stories;
       } else {
         attributes.property_stories = {
           'operator': '>=',
-          'value': thermostat_group.property_stories
+          'value': thermostat.property.stories
         };
       }
     }
   } else if (beestat.setting('comparison_property_type') === 'same_structure') {
     // Match structure type exactly.
-    if (thermostat_group.property_structure_type !== null) {
+    if (thermostat.property.structure_type !== null) {
       attributes.property_structure_type =
-        thermostat_group.property_structure_type;
+        thermostat.property.structure_type;
     }
   }
 
   if (
-    thermostat_group.address_latitude !== null &&
-    thermostat_group.address_longitude !== null &&
+    address.normalized !== null &&
+    address.normalized.metadata !== undefined &&
+    address.normalized.metadata.latitude !== undefined &&
+    address.normalized.metadata.latitude !== null &&
+    address.normalized.metadata.longitude !== undefined &&
+    address.normalized.metadata.longitude !== null &&
     beestat.setting('comparison_region') !== 'global'
   ) {
-    attributes.address_latitude = thermostat_group.address_latitude;
-    attributes.address_longitude = thermostat_group.address_longitude;
-    attributes.address_radius = 250;
-  }
-
-  if (type === 'heat') {
-    attributes.system_type_heat = thermostat_group.system_type_heat;
-  } else if (type === 'cool') {
-    attributes.system_type_cool = thermostat_group.system_type_cool;
+    attributes.radius = {
+      'operator': '<',
+      'value': 250
+    };
   }
 
   return attributes;
diff --git a/js/beestat/poll.js b/js/beestat/poll.js
index 6d8a388..b09ab1b 100644
--- a/js/beestat/poll.js
+++ b/js/beestat/poll.js
@@ -1,35 +1,13 @@
-beestat.add_poll_interval = function(poll_interval) {
-  beestat.poll_intervals.push(poll_interval);
-  beestat.enable_poll();
-};
-
-beestat.remove_poll_interval = function(poll_interval) {
-  var index = beestat.poll_intervals.indexOf(poll_interval);
-  if (index !== -1) {
-    beestat.poll_intervals.splice(index, 1);
-  }
-  beestat.enable_poll();
-};
-
-beestat.reset_poll_interval = function() {
-  beestat.poll_intervals = [beestat.default_poll_interval];
-  beestat.enable_poll();
-};
-
 beestat.enable_poll = function() {
-  clearTimeout(beestat.poll_timeout);
+  window.clearTimeout(beestat.poll_timeout);
   if (beestat.poll_intervals.length > 0) {
-    beestat.poll_timeout = setTimeout(
+    beestat.poll_timeout = window.setTimeout(
       beestat.poll,
       Math.min.apply(null, beestat.poll_intervals)
     );
   }
 };
 
-beestat.disable_poll = function() {
-  clearTimeout(beestat.poll_timeout);
-};
-
 /**
  * Poll the database for changes and update the cache.
  */
diff --git a/js/beestat/requestor.js b/js/beestat/requestor.js
index a8b1abe..64d0edf 100644
--- a/js/beestat/requestor.js
+++ b/js/beestat/requestor.js
@@ -110,14 +110,3 @@ beestat.requestor.callback = function(response, api) {
     beestat.requestor.timeout_ = window.setTimeout(beestat.requestor.send, 3000);
   }
 };
-
-/*
-
-
-beestat.requestor.request([{'resource': 'thermostat','method': 'read_id','arguments': {'attributes': {'thermostat_id': 1}}}]);
-beestat.requestor.request([{'resource': 'thermostat','method': 'read_id','arguments': {'attributes': {'thermostat_id': 1}}}]);
-beestat.requestor.request([{'resource': 'sensor','method': 'read_id','arguments': {'attributes': {'sensor_id': 1}}}]);
-beestat.requestor.request([{'resource': 'sensor','method': 'read_id','arguments': {'attributes': {'sensor_id': 2}}}]);
-
-
- */
diff --git a/js/beestat/runtime_thermostat.js b/js/beestat/runtime_thermostat.js
index bc98ef1..8ded684 100644
--- a/js/beestat/runtime_thermostat.js
+++ b/js/beestat/runtime_thermostat.js
@@ -6,10 +6,14 @@ beestat.runtime_thermostat = {};
  *
  * @param {number} thermostat_id The thermostat_id to get data for.
  * @param {object} range Range settings.
+ * @param {string} key The key to pull the data from inside
+ * beestat.cache.data. This exists because runtime_thermostat data exists in
+ * two spots: one for the Thermostat Detail chart, and once for the Sensor
+ * Detail chart.
  *
  * @return {object} The data.
  */
-beestat.runtime_thermostat.get_data = function(thermostat_id, range) {
+beestat.runtime_thermostat.get_data = function(thermostat_id, range, key) {
   var data = {
     'x': [],
     'series': {},
@@ -131,7 +135,7 @@ beestat.runtime_thermostat.get_data = function(thermostat_id, range) {
     .second(0)
     .millisecond(0);
 
-  var runtime_thermostats = beestat.runtime_thermostat.get_runtime_thermostats_by_date_();
+  var runtime_thermostats = beestat.runtime_thermostat.get_runtime_thermostats_by_date_(key);
 
   // Initialize moving average.
   var moving = [];
@@ -211,7 +215,6 @@ beestat.runtime_thermostat.get_data = function(thermostat_id, range) {
         data.metadata.series.setpoint_heat.data[current_m.valueOf()] = setpoint_heat;
 
         data.metadata.series.setpoint_heat.active = true;
-
       } else {
         data.series.setpoint_heat.push(null);
       }
@@ -227,7 +230,6 @@ beestat.runtime_thermostat.get_data = function(thermostat_id, range) {
         data.metadata.series.setpoint_cool.data[current_m.valueOf()] = setpoint_cool;
 
         data.metadata.series.setpoint_cool.active = true;
-
       } else {
         data.series.setpoint_cool.push(null);
       }
@@ -461,7 +463,6 @@ beestat.runtime_thermostat.get_data = function(thermostat_id, range) {
 
             data.metadata.series[series_code_1].data[current_m.valueOf()] =
               equipment_y[series_code_1];
-
           }
         } else {
           data.series[series_code].push(null);
@@ -538,12 +539,17 @@ beestat.runtime_thermostat.get_average_ = function(runtime_thermostats, series_c
 /**
  * Get all the runtime_thermostat rows indexed by date.
  *
+ * @param {string} key The key to pull the data from inside
+ * beestat.cache.data. This exists because runtime_thermostat data exists in
+ * two spots: one for the Thermostat Detail chart, and once for the Sensor
+ * Detail chart.
+ *
  * @return {array} The runtime_thermostat rows.
  */
-beestat.runtime_thermostat.get_runtime_thermostats_by_date_ = function() {
+beestat.runtime_thermostat.get_runtime_thermostats_by_date_ = function(key) {
   var runtime_thermostats = {};
-  if (beestat.cache.runtime_thermostat !== undefined) {
-    beestat.cache.runtime_thermostat.forEach(function(runtime_thermostat) {
+  if (beestat.cache.data[key] !== undefined) {
+    beestat.cache.data[key].forEach(function(runtime_thermostat) {
       runtime_thermostats[moment(runtime_thermostat.timestamp).valueOf()] = runtime_thermostat;
     });
   }
diff --git a/js/beestat/temperature.js b/js/beestat/temperature.js
index b74f033..588a255 100644
--- a/js/beestat/temperature.js
+++ b/js/beestat/temperature.js
@@ -13,7 +13,6 @@
  *
  * @return {string} The formatted temperature.
  */
-// beestat.temperature = function(temperature, convert, round, include_units) {
 beestat.temperature = function(args) {
   // Allow passing a single argument of temperature for convenience.
   if (typeof args !== 'object' || args === null) {
diff --git a/js/beestat/time.js b/js/beestat/time.js
index 1515a51..c37c75d 100644
--- a/js/beestat/time.js
+++ b/js/beestat/time.js
@@ -10,20 +10,6 @@
 beestat.time = function(seconds, opt_unit) {
   var duration = moment.duration(seconds, opt_unit || 'seconds');
 
-  /*
-   * // Used to work this way; switched this to return more consistent results.
-   *
-   * var days = duration.get('days');
-   * var hours = duration.get('hours');
-   * var minutes = duration.get('minutes');
-   *
-   * if (days >= 1) {
-   * return days + 'd ' + hours + 'h'
-   * } else {
-   * return hours + 'h ' + minutes + 'm'
-   * }
-   */
-
   var hours = Math.floor(duration.asHours());
   var minutes = duration.get('minutes');
 
diff --git a/js/component/card.js b/js/component/card.js
index 7aaf714..a5c4255 100644
--- a/js/component/card.js
+++ b/js/component/card.js
@@ -123,7 +123,7 @@ beestat.component.card.prototype.decorate_top_right_ = function(parent) {};
 /**
  * Get the title of the card.
  *
- * @return {string}
+ * @return {string} The title.
  */
 beestat.component.card.prototype.get_title_ = function() {
   return null;
@@ -132,7 +132,7 @@ beestat.component.card.prototype.get_title_ = function() {
 /**
  * Get the subtitle of the card.
  *
- * @return {string}
+ * @return {string} The subtitle.
  */
 beestat.component.card.prototype.get_subtitle_ = function() {
   return null;
diff --git a/js/component/card/alerts.js b/js/component/card/alerts.js
index bedf663..13b4371 100644
--- a/js/component/card/alerts.js
+++ b/js/component/card/alerts.js
@@ -114,7 +114,7 @@ beestat.component.card.alerts.prototype.unpin_ = function() {
 /**
  * Get the title of the card.
  *
- * @return {string}
+ * @return {string} The title.
  */
 beestat.component.card.alerts.prototype.get_title_ = function() {
   return 'Alerts';
@@ -130,7 +130,10 @@ beestat.component.card.alerts.prototype.decorate_top_right_ = function(parent) {
 
   var menu = (new beestat.component.menu()).render(parent);
 
-  var menu_item_show = new beestat.component.menu_item()
+  var menu_item_show;
+  var menu_item_hide;
+
+  menu_item_show = new beestat.component.menu_item()
     .set_text('Show dismissed')
     .set_icon('bell')
     .set_callback(function() {
@@ -144,7 +147,7 @@ beestat.component.card.alerts.prototype.decorate_top_right_ = function(parent) {
     });
   menu.add_menu_item(menu_item_show);
 
-  var menu_item_hide = new beestat.component.menu_item()
+  menu_item_hide = new beestat.component.menu_item()
     .set_text('Hide dismissed')
     .set_icon('bell_off')
     .set_callback(function() {
diff --git a/js/component/card/compare_notification.js b/js/component/card/compare_notification.js
new file mode 100644
index 0000000..2163f89
--- /dev/null
+++ b/js/component/card/compare_notification.js
@@ -0,0 +1,33 @@
+/**
+ * Notification at the top of the new compare page to help users along with
+ * the change.
+ */
+beestat.component.card.compare_notification = function() {
+  beestat.component.card.apply(this, arguments);
+};
+beestat.extend(beestat.component.card.compare_notification, beestat.component.card);
+
+beestat.component.card.compare_notification.prototype.decorate_contents_ = function(parent) {
+  parent.style('background', beestat.style.color.blue.light);
+
+  parent.appendChild($.createElement('p').innerText('The comparisons you\'ve become accustomed to have evolved into a new feature: Metrics! Please be patient over the next few weeks as they are refined.'));
+
+  new beestat.component.button()
+    .set_icon('information')
+    .set_text('Learn more and discuss this change')
+    .set_background_color(beestat.style.color.blue.dark)
+    .set_background_hover_color(beestat.style.color.blue.base)
+    .addEventListener('click', function() {
+      window.open('https://community.beestat.io/t/metrics-are-replacing-scores/347');
+    })
+    .render(parent);
+};
+
+/**
+ * Get the title of the card.
+ *
+ * @return {string} The title.
+ */
+beestat.component.card.compare_notification.prototype.get_title_ = function() {
+  return 'Things have changed...';
+};
diff --git a/js/component/card/comparison_settings.js b/js/component/card/comparison_settings.js
index 3a48190..ea444e8 100644
--- a/js/component/card/comparison_settings.js
+++ b/js/component/card/comparison_settings.js
@@ -1,16 +1,27 @@
 /**
  * Home comparison settings.
+ *
+ * @param {number} thermostat_id The thermostat_id this card is displaying
+ * data for.
  */
-beestat.component.card.comparison_settings = function() {
+beestat.component.card.comparison_settings = function(thermostat_id) {
   var self = this;
 
+  this.thermostat_id_ = thermostat_id;
+
   /*
-   * If the thermostat_group changes that means the property_type could change
-   * and thus need to rerender.
+   * If the thermostat changes that means the property_type could change and
+   * thus need to rerender.
    */
-  beestat.dispatcher.addEventListener('cache.thermostat_group', function() {
-    self.rerender();
-  });
+  beestat.dispatcher.addEventListener(
+    [
+      'cache.thermostat',
+      'cache.data.metrics'
+    ],
+    function() {
+      self.rerender();
+    }
+  );
 
   beestat.component.card.apply(this, arguments);
 };
@@ -22,10 +33,7 @@ beestat.extend(beestat.component.card.comparison_settings, beestat.component.car
  * @param {rocket.Elements} parent
  */
 beestat.component.card.comparison_settings.prototype.decorate_contents_ = function(parent) {
-  var self = this;
-
-  var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')];
-  var thermostat_group = beestat.cache.thermostat_group[thermostat.thermostat_group_id];
+  var thermostat = beestat.cache.thermostat[this.thermostat_id_];
 
   var row;
 
@@ -47,42 +55,57 @@ beestat.component.card.comparison_settings.prototype.decorate_contents_ = functi
   row.appendChild(column_property);
   this.decorate_property_(column_property);
 
-  /*
-   * If the data is available, then get the data if we don't already have it
-   * loaded. If the data is not available, poll until it becomes available.
-   */
-  if (thermostat_group.profile === null) {
-    // This will show the loading screen.
-    self.data_available_();
+  row = $.createElement('div').addClass('row');
+  parent.appendChild(row);
 
-    var poll_interval = 10000;
+  var column_detail = $.createElement('div').addClass([
+    'column',
+    'column_12'
+  ]);
+  row.appendChild(column_detail);
+  this.decorate_detail_(column_detail);
 
-    beestat.add_poll_interval(poll_interval);
-    beestat.dispatcher.addEventListener('poll.comparisons_load', function() {
-      if (self.data_available_() === true) {
-        beestat.remove_poll_interval(poll_interval);
-        beestat.dispatcher.removeEventListener('poll.comparisons_load');
+  const sync_progress = beestat.thermostat.get_sync_progress(this.thermostat_id_);
 
-        new beestat.api()
-          .add_call(
-            'thermostat_group',
-            'generate_profiles',
-            {},
-            'generate_profiles'
-          )
-          .add_call(
-            'thermostat_group',
-            'read_id',
-            {},
-            'thermostat_group'
-          )
-          .set_callback(function(response) {
-            beestat.cache.set('thermostat_group', response.thermostat_group);
-            (new beestat.layer.comparisons()).render();
-          })
-          .send();
-      }
-    });
+  if (sync_progress === null || sync_progress < 100) {
+    this.show_loading_('Fetching');
+    window.setTimeout(function() {
+      var api = new beestat.api();
+      api.add_call(
+        'thermostat',
+        'read_id',
+        {
+          'attributes': {
+            'inactive': 0
+          }
+        },
+        'thermostat'
+      );
+
+      api.set_callback(function(response) {
+        beestat.cache.set('thermostat', response.thermostat);
+      });
+
+      api.send();
+    }, 10000);
+  } else if (beestat.cache.data.metrics === undefined) {
+    this.show_loading_('Fetching');
+  } else {
+    if (thermostat.profile === null) {
+      new beestat.api()
+        .add_call(
+          'thermostat',
+          'generate_profile',
+          {
+            'thermostat_id': this.thermostat_id_
+          },
+          'thermostat'
+        )
+        .set_callback(function(response) {
+          beestat.cache.set('thermostat', response.thermostat);
+        })
+        .send();
+    }
   }
 };
 
@@ -118,6 +141,9 @@ beestat.component.card.comparison_settings.prototype.decorate_region_ = function
       button
         .set_background_color(beestat.style.color.bluegray.light)
         .addEventListener('click', function() {
+          // Delete from the cache to trigger the metrics loading screen
+          beestat.cache.delete('data.metrics');
+
           // Update the setting
           beestat.setting('comparison_region', region);
 
@@ -125,12 +151,21 @@ beestat.component.card.comparison_settings.prototype.decorate_region_ = function
           self.rerender();
 
           // Open up the loading window.
-          self.show_loading_('Calculating Score for ' + region + ' region');
+          self.show_loading_('Fetching');
 
-          beestat.comparisons.get_comparison_scores(function() {
-            // Rerender to get rid of the loader.
-            self.rerender();
-          });
+          new beestat.api()
+            .add_call(
+              'thermostat',
+              'get_metrics',
+              {
+                'thermostat_id': self.thermostat_id_,
+                'attributes': beestat.comparisons.get_attributes()
+              }
+            )
+            .set_callback(function(response) {
+              beestat.cache.set('data.metrics', response);
+            })
+            .send();
         });
     }
 
@@ -147,10 +182,7 @@ beestat.component.card.comparison_settings.prototype.decorate_region_ = function
 beestat.component.card.comparison_settings.prototype.decorate_property_ = function(parent) {
   var self = this;
 
-  var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')];
-  var thermostat_group = beestat.cache.thermostat_group[
-    thermostat.thermostat_group_id
-  ];
+  var thermostat = beestat.cache.thermostat[this.thermostat_id_];
 
   (new beestat.component.title('Property')).render(parent);
 
@@ -160,12 +192,12 @@ beestat.component.card.comparison_settings.prototype.decorate_property_ = functi
     'text': 'Very Similar'
   });
 
-  if (thermostat_group.property_structure_type !== null) {
+  if (thermostat.property.structure_type !== null) {
     property_types.push({
       'value': 'same_structure',
       'text': 'Type: ' +
-        thermostat_group.property_structure_type.charAt(0).toUpperCase() +
-        thermostat_group.property_structure_type.slice(1)
+        thermostat.property.structure_type.charAt(0).toUpperCase() +
+        thermostat.property.structure_type.slice(1)
     });
   }
 
@@ -191,6 +223,9 @@ beestat.component.card.comparison_settings.prototype.decorate_property_ = functi
       button
         .set_background_color(beestat.style.color.bluegray.light)
         .addEventListener('click', function() {
+          // Delete from the cache to trigger the metrics loading screen
+          beestat.cache.delete('data.metrics');
+
           // Update the setting
           beestat.setting('comparison_property_type', property_type.value);
 
@@ -198,12 +233,21 @@ beestat.component.card.comparison_settings.prototype.decorate_property_ = functi
           self.rerender();
 
           // Open up the loading window.
-          self.show_loading_('Calculating Score for ' + property_type.text);
+          self.show_loading_('Fetching');
 
-          beestat.comparisons.get_comparison_scores(function() {
-            // Rerender to get rid of the loader.
-            self.rerender();
-          });
+          new beestat.api()
+            .add_call(
+              'thermostat',
+              'get_metrics',
+              {
+                'thermostat_id': self.thermostat_id_,
+                'attributes': beestat.comparisons.get_attributes()
+              }
+            )
+            .set_callback(function(response) {
+              beestat.cache.set('data.metrics', response);
+            })
+            .send();
         });
     }
 
@@ -212,6 +256,55 @@ beestat.component.card.comparison_settings.prototype.decorate_property_ = functi
   button_group.render(parent);
 };
 
+beestat.component.card.comparison_settings.prototype.decorate_detail_ = function(parent) {
+  var strings = [];
+
+  strings.push('Matching system type and stages');
+
+  var comparison_attributes = beestat.comparisons.get_attributes();
+
+  if (comparison_attributes.property_structure_type !== undefined) {
+    strings.push('Property Type: ' + this.get_comparison_string_(comparison_attributes.property_structure_type));
+  } else {
+    strings.push('Any property type');
+  }
+
+  if (comparison_attributes.property_age !== undefined) {
+    strings.push(this.get_comparison_string_(comparison_attributes.property_age, 'years old'));
+  } else {
+    strings.push('Any property age');
+  }
+
+  if (comparison_attributes.property_square_feet !== undefined) {
+    strings.push(this.get_comparison_string_(comparison_attributes.property_square_feet, 'sqft'));
+  } else {
+    strings.push('Any square footage');
+  }
+
+  if (comparison_attributes.property_stories !== undefined) {
+    strings.push(this.get_comparison_string_(comparison_attributes.property_stories, 'stories'));
+  } else {
+    strings.push('Any number of stories');
+  }
+
+  if (comparison_attributes.radius !== undefined) {
+    strings.push('Within ' + comparison_attributes.radius.value + ' miles of your location');
+  } else {
+    strings.push('Any region');
+  }
+
+  (new beestat.component.title('Comparing to homes like...')).render(parent);
+
+  strings.forEach(function(string) {
+    var div = $.createElement('div');
+    div.innerText(string);
+    if (string.match('Any') !== null) {
+      div.style({'color': beestat.style.color.gray.base});
+    }
+    parent.appendChild(div);
+  });
+};
+
 /**
  * Get the title of the card.
  *
@@ -227,31 +320,19 @@ beestat.component.card.comparison_settings.prototype.get_title_ = function() {
  * @return {string} The subtitle of the card.
  */
 beestat.component.card.comparison_settings.prototype.get_subtitle_ = function() {
-  var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')];
-  var thermostat_group = beestat.cache.thermostat_group[
-    thermostat.thermostat_group_id
-  ];
-  var address = beestat.cache.address[thermostat_group.address_id];
+  const thermostat = beestat.cache.thermostat[this.thermostat_id_];
+  const address = beestat.cache.address[thermostat.address_id];
 
-  var string = '';
+  let string = 'Thermostat at ';
 
   if (address.normalized !== null && address.normalized.delivery_line_1 !== undefined) {
-    string = address.normalized.delivery_line_1;
+    string += address.normalized.delivery_line_1;
   } else if (address.normalized !== null && address.normalized.address1 !== undefined) {
-    string = address.normalized.address1;
+    string += address.normalized.address1;
   } else {
-    string = 'Unknown Address';
+    string += 'unknown address';
   }
 
-  var count = 0;
-  $.values(beestat.cache.thermostat).forEach(function(t) {
-    if (t.thermostat_group_id === thermostat_group.thermostat_group_id) {
-      count++;
-    }
-  });
-
-  string += ' (' + count + ' Thermostat' + (count > 1 ? 's' : '') + ')';
-
   return string;
 };
 
@@ -272,21 +353,32 @@ beestat.component.card.comparison_settings.prototype.decorate_top_right_ = funct
 };
 
 /**
- * Determine whether or not all of the data has been loaded so the scores can
- * be generated.
+ * Helper function to display various comparison strings in a human-readable
+ * way.
  *
- * @return {boolean} Whether or not all of the data has been loaded.
+ * @param {mixed} comparison_attribute The attribute
+ * @param {string} suffix If a suffix (ex: "years") should be placed on the
+ * end.
+ *
+ * @return {string} The human-readable string.
  */
-beestat.component.card.comparison_settings.prototype.data_available_ = function() {
-  var sync_progress = beestat.thermostat.get_sync_progress(beestat.setting('thermostat_id'));
-
-  if (sync_progress >= 95) {
-    this.show_loading_('Calculating Scores');
-  } else {
-    this.show_loading_('Syncing Data (' +
-      Math.round(sync_progress) +
-      '%)');
+beestat.component.card.comparison_settings.prototype.get_comparison_string_ = function(comparison_attribute, suffix) {
+  var s = (suffix !== undefined ? (' ' + suffix) : '');
+  if (comparison_attribute.operator !== undefined) {
+    if (comparison_attribute.operator === 'between') {
+      return 'Between ' + comparison_attribute.value[0] + ' and ' + comparison_attribute.value[1] + s;
+    } else if (comparison_attribute.operator === '>=') {
+      return 'At least ' + comparison_attribute.value + s;
+    } else if (comparison_attribute.operator === '<=') {
+      return 'Less than or equal than ' + comparison_attribute.value + s;
+    } else if (comparison_attribute.operator === '>') {
+      return 'Greater than ' + comparison_attribute.value + s;
+    } else if (comparison_attribute.operator === '<') {
+      return 'Less than' + comparison_attribute.value + s;
+    }
+    return comparison_attribute.operator + ' ' + comparison_attribute.value + s;
+  } else if (Array.isArray(comparison_attribute.value) === true) {
+    return 'One of ' + comparison_attribute.value.join(', ') + s;
   }
-
-  return sync_progress === 100;
+  return comparison_attribute + s;
 };
diff --git a/js/component/card/early_access.js b/js/component/card/early_access.js
index 80c2e85..6d2344e 100644
--- a/js/component/card/early_access.js
+++ b/js/component/card/early_access.js
@@ -15,13 +15,3 @@ beestat.component.card.early_access.prototype.decorate_contents_ = function(pare
   parent.style('background', beestat.style.color.green.base);
   parent.appendChild($.createElement('p').innerText('Experimental early access features below! ⤵'));
 };
-
-/**
- * Get the title of the card.
- *
- * @return {string} The title of the card.
- */
-// beestat.component.card.early_access.prototype.get_title_ = function() {
-//   return 'Possible issue with your temperature profiles!';
-// };
-
diff --git a/js/component/card/metrics.js b/js/component/card/metrics.js
index 0db362f..2ea78f8 100644
--- a/js/component/card/metrics.js
+++ b/js/component/card/metrics.js
@@ -1,8 +1,11 @@
 /**
  * Metrics card.
+ *
+ * @param {number} thermostat_id The thermostat_id this card is displaying
+ * data for.
  */
-beestat.component.card.metrics = function(thermostat_group_id) {
-  this.thermostat_group_id_ = thermostat_group_id;
+beestat.component.card.metrics = function(thermostat_id) {
+  this.thermostat_id_ = thermostat_id;
 
   var self = this;
 
@@ -11,58 +14,153 @@ beestat.component.card.metrics = function(thermostat_group_id) {
    * event. This fires on the trailing edge so that all changes are accounted
    * for when rerendering.
    */
-  var data_change_function = beestat.debounce(function() {
+  var change_function = beestat.debounce(function() {
     self.rerender();
   }, 10);
 
   beestat.dispatcher.addEventListener(
-    'cache.data.metrics',
-    data_change_function
+    [
+      'cache.data.metrics',
+      'cache.thermostat'
+    ],
+    change_function
   );
 
   beestat.component.card.apply(this, arguments);
 
-  // this.layer_.register_loader(beestat.comparisons.get_comparison_metricss);
+  new beestat.api()
+    .add_call(
+      'thermostat',
+      'get_metrics',
+      {
+        'thermostat_id': this.thermostat_id_,
+        'attributes': beestat.comparisons.get_attributes()
+      }
+    )
+    .set_callback(function(response) {
+      beestat.cache.set('data.metrics', response);
+    })
+    .send();
 };
 beestat.extend(beestat.component.card.metrics, beestat.component.card);
 
+beestat.component.card.metrics.prototype.rerender_on_breakpoint_ = true;
+
 /**
  * Decorate
  *
  * @param {rocket.Elements} parent
  */
 beestat.component.card.metrics.prototype.decorate_contents_ = function(parent) {
-  var self = this;
+  if (beestat.cache.data.metrics === undefined) {
+    parent.appendChild($.createElement('div').style('height', '100px'));
+    this.show_loading_('Fetching');
+  } else {
+    /**
+     * An entry for every possible metric is always returned for clarity.
+     * Remove the children with no data and then the parents with no children.
+     */
+    this.filtered_metrics_ = {};
+    let metric_count = 0;
+    for (const parent_metric_name in beestat.cache.data.metrics) {
+      for (const child_metric_name in beestat.cache.data.metrics[parent_metric_name]) {
+        if (beestat.cache.data.metrics[parent_metric_name][child_metric_name] !== null) {
+          if (this.filtered_metrics_[parent_metric_name] === undefined) {
+            this.filtered_metrics_[parent_metric_name] = {};
+          }
+          this.filtered_metrics_[parent_metric_name][child_metric_name] = beestat.cache.data.metrics[parent_metric_name][child_metric_name];
+          metric_count++;
+        }
+      }
+    }
 
-  var metrics = [
-    'setpoint_heat',
-    'setpoint_cool',
-    // 'runtime_per_heating_degree_day'
-  ];
+    if (metric_count === 0) {
+      this.decorate_empty_(parent);
+    } else {
+      let column_count = 1;
+      if (beestat.width > 1000) {
+        column_count = 3;
+      } else if (beestat.width > 800) {
+        column_count = 2;
+      }
+      const column_span = 12 / column_count;
 
-  // Decorate the metrics
-  var metric_container = $.createElement('div')
-    .style({
-      'display': 'grid',
-      // 'grid-template-columns': 'repeat(auto-fit, minmax(160px, 1fr))',
-      'grid-template-columns': '1fr 1fr 1fr',
-      'margin': '0 0 ' + beestat.style.size.gutter + 'px -' + beestat.style.size.gutter + 'px'
+      const columns = [];
+      const row = $.createElement('div').addClass('row');
+      parent.appendChild(row);
+
+      for (let i = 0; i < column_count; i++) {
+        const column = $.createElement('div')
+          .addClass([
+            'column',
+            'column_' + column_span
+          ]);
+        row.appendChild(column);
+        columns.push({
+          'size': 0,
+          'element': column
+        });
+      }
+
+      const get_smallest_column = function() {
+        let smallest_column = columns[0];
+        columns.forEach(function(column) {
+          if (column.size < smallest_column.size) {
+            smallest_column = column;
+          }
+        });
+
+        return smallest_column;
+      };
+
+      for (const parent_metric_name in this.filtered_metrics_) {
+        const group_size = Object.keys(this.filtered_metrics_[parent_metric_name]).length;
+        const smallest_column = get_smallest_column();
+        this.decorate_group_(smallest_column.element, parent_metric_name);
+        smallest_column.size += group_size;
+      }
+    }
+  }
+};
+
+/**
+ * Put a message in there if no data is present.
+ *
+ * @param {rocket.Elements} parent Parent
+ */
+beestat.component.card.metrics.prototype.decorate_empty_ = function(parent) {
+  parent.appendChild($.createElement('p').innerText('We couldn\'t generate any metrics for your system. Try broadening your comparison settings and ensuring your system type and thermostat address are properly set.'));
+};
+
+/**
+ * Decorate a group of metrics.
+ *
+ * @param {rocket.Elements} parent Parent
+ * @param {string} parent_metric_name The name of the group.
+ */
+beestat.component.card.metrics.prototype.decorate_group_ = function(parent, parent_metric_name) {
+  const parent_metric = this.filtered_metrics_[parent_metric_name];
+
+  const title = parent_metric_name
+    .replace(/_/g, ' ')
+    .replace(/^(.)|\s+(.)/g, function($1) {
+      return $1.toUpperCase();
     });
+
+  parent.appendChild($.createElement('p').innerText(title));
+
+  const metric_container = $.createElement('div');
   parent.appendChild(metric_container);
 
-  metrics.forEach(function(metric) {
-    var div = $.createElement('div')
+  for (const child_metric_name in parent_metric) {
+    const div = $.createElement('div')
       .style({
-        'padding': beestat.style.size.gutter + 'px 0 0 ' + beestat.style.size.gutter + 'px'
+        'background': beestat.style.color.bluegray.dark,
+        'margin-bottom': beestat.style.size.gutter / 4
       });
     metric_container.appendChild(div);
-
-    (new beestat.component.metric[metric](self.thermostat_group_id_)).render(div);
-  });
-
-
-
-
+    (new beestat.component.metric[parent_metric_name][child_metric_name](this.thermostat_id_)).render(div);
+  }
 };
 
 /**
@@ -79,6 +177,44 @@ beestat.component.card.metrics.prototype.get_title_ = function() {
  *
  * @param {rocket.Elements} parent
  */
-/*beestat.component.card.my_home.prototype.decorate_top_right_ = function(parent) {
+beestat.component.card.metrics.prototype.decorate_top_right_ = function(parent) {
+  const menu = (new beestat.component.menu()).render(parent);
 
-};*/
+  menu.add_menu_item(new beestat.component.menu_item()
+    .set_text('Help')
+    .set_icon('help_circle')
+    .set_callback(function() {
+      window.open('https://doc.beestat.io/ebfdf00f7f34436c980cd6344a767a12');
+    }));
+};
+
+/**
+ * Get the subtitle of the card.
+ *
+ * @return {string} The subtitle.
+ */
+beestat.component.card.metrics.prototype.get_subtitle_ = function() {
+  const thermostat = beestat.cache.thermostat[this.thermostat_id_];
+
+  const generated_at_m = moment(
+    thermostat.profile.metadata.generated_at
+  );
+
+  let duration_text = '';
+
+  // How much data was used to generate this.
+  const duration_weeks = Math.round(thermostat.profile.metadata.duration / 7);
+  duration_text += ' from the past ';
+  if (duration_weeks === 0) {
+    duration_text += ' few days';
+  } else if (duration_weeks === 1) {
+    duration_text += ' week';
+  } else if (duration_weeks >= 52) {
+    duration_text += ' year';
+  } else {
+    duration_text += duration_weeks + ' weeks';
+  }
+  duration_text += ' of data.';
+
+  return 'Generated ' + generated_at_m.format('MMM Do @ h a') + ' (updated weekly) ' + duration_text;
+};
diff --git a/js/component/card/my_home.js b/js/component/card/my_home.js
index c73e573..8d21f10 100644
--- a/js/component/card/my_home.js
+++ b/js/component/card/my_home.js
@@ -1,9 +1,15 @@
 /**
  * Home properties.
+ *
+ * @param {number} thermostat_id The thermostat_id this card is displaying
+ * data for.
  */
-beestat.component.card.my_home = function() {
+beestat.component.card.my_home = function(thermostat_id) {
   var self = this;
-  beestat.dispatcher.addEventListener('cache.thermostat_group', function() {
+
+  this.thermostat_id_ = thermostat_id;
+
+  beestat.dispatcher.addEventListener('cache.thermostat', function() {
     self.rerender();
   });
 
@@ -23,10 +29,7 @@ beestat.component.card.my_home.prototype.decorate_contents_ = function(parent) {
  * @param {rocket.Elements} parent
  */
 beestat.component.card.my_home.prototype.decorate_system_type_ = function(parent) {
-  var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')];
-  var thermostat_group = beestat.cache.thermostat_group[
-    thermostat.thermostat_group_id
-  ];
+  var thermostat = beestat.cache.thermostat[this.thermostat_id_];
 
   (new beestat.component.title('System')).render(parent);
 
@@ -72,9 +75,8 @@ beestat.component.card.my_home.prototype.decorate_system_type_ = function(parent
  * @param {rocket.Elements} parent
  */
 beestat.component.card.my_home.prototype.decorate_region_ = function(parent) {
-  var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')];
-  var thermostat_group = beestat.cache.thermostat_group[thermostat.thermostat_group_id];
-  var address = beestat.cache.address[thermostat_group.address_id];
+  var thermostat = beestat.cache.thermostat[this.thermostat_id_];
+  var address = beestat.cache.address[thermostat.address_id];
 
   (new beestat.component.title('Region')).render(parent);
 
@@ -123,29 +125,28 @@ beestat.component.card.my_home.prototype.decorate_region_ = function(parent) {
  * @param {rocket.Elements} parent
  */
 beestat.component.card.my_home.prototype.decorate_property_ = function(parent) {
-  var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')];
-  var thermostat_group = beestat.cache.thermostat_group[thermostat.thermostat_group_id];
+  var thermostat = beestat.cache.thermostat[this.thermostat_id_];
 
   (new beestat.component.title('Property')).render(parent);
 
   var button_group = new beestat.component.button_group();
 
-  if (thermostat_group.property_structure_type !== null) {
+  if (thermostat.property.structure_type !== null) {
     button_group.add_button(new beestat.component.button()
       .set_type('pill')
       .set_background_color(beestat.style.color.purple.base)
       .set_text_color('#fff')
       .set_icon('home_floor_a')
-      .set_text(thermostat_group.property_structure_type.charAt(0).toUpperCase() +
-        thermostat_group.property_structure_type.slice(1)));
+      .set_text(thermostat.property.structure_type.charAt(0).toUpperCase() +
+        thermostat.property.structure_type.slice(1)));
   }
 
   if (
-    thermostat_group.property_stories !== null &&
+    thermostat.property.stories !== null &&
     (
-      thermostat_group.property_structure_type === 'detached' ||
-      thermostat_group.property_structure_type === 'townhouse' ||
-      thermostat_group.property_structure_type === 'semi-detached'
+      thermostat.property.structure_type === 'detached' ||
+      thermostat.property.structure_type === 'townhouse' ||
+      thermostat.property.structure_type === 'semi-detached'
     )
   ) {
     button_group.add_button(new beestat.component.button()
@@ -153,26 +154,26 @@ beestat.component.card.my_home.prototype.decorate_property_ = function(parent) {
       .set_background_color(beestat.style.color.purple.base)
       .set_text_color('#fff')
       .set_icon('layers')
-      .set_text(thermostat_group.property_stories +
-        (thermostat_group.property_stories === 1 ? ' Story' : ' Stories')));
+      .set_text(thermostat.property.stories +
+        (thermostat.property.stories === 1 ? ' Story' : ' Stories')));
   }
 
-  if (thermostat_group.property_square_feet !== null) {
+  if (thermostat.property.square_feet !== null) {
     button_group.add_button(new beestat.component.button()
       .set_type('pill')
       .set_background_color(beestat.style.color.purple.base)
       .set_text_color('#fff')
       .set_icon('view_quilt')
-      .set_text(Number(thermostat_group.property_square_feet).toLocaleString() + ' sqft'));
+      .set_text(Number(thermostat.property.square_feet).toLocaleString() + ' sqft'));
   }
 
-  if (thermostat_group.property_age !== null) {
+  if (thermostat.property.age !== null) {
     button_group.add_button(new beestat.component.button()
       .set_type('pill')
       .set_background_color(beestat.style.color.purple.base)
       .set_text_color('#fff')
       .set_icon('clock_outline')
-      .set_text(thermostat_group.property_age + ' Years'));
+      .set_text(thermostat.property.age + ' Years'));
   }
 
   if (button_group.get_buttons().length === 0) {
@@ -202,13 +203,15 @@ beestat.component.card.my_home.prototype.get_title_ = function() {
  * @param {rocket.Elements} parent
  */
 beestat.component.card.my_home.prototype.decorate_top_right_ = function(parent) {
+  var self = this;
+
   var menu = (new beestat.component.menu()).render(parent);
 
   menu.add_menu_item(new beestat.component.menu_item()
     .set_text('Change System Type')
     .set_icon('tune')
     .set_callback(function() {
-      (new beestat.component.modal.change_system_type()).render();
+      (new beestat.component.modal.change_system_type(self.thermostat_id_)).render();
     }));
 
   menu.add_menu_item(new beestat.component.menu_item()
diff --git a/js/component/card/runtime_sensor_detail.js b/js/component/card/runtime_sensor_detail.js
index 08ce90b..f24417e 100644
--- a/js/component/card/runtime_sensor_detail.js
+++ b/js/component/card/runtime_sensor_detail.js
@@ -27,8 +27,8 @@ beestat.component.card.runtime_sensor_detail = function(thermostat_id) {
     [
       'setting.runtime_sensor_detail_range_type',
       'setting.runtime_sensor_detail_range_dynamic',
-      'cache.runtime_thermostat',
-      'cache.runtime_sensor'
+      'cache.data.runtime_thermostat_sensor_detail',
+      'cache.data.runtime_sensor'
     ],
     change_function
   );
@@ -131,11 +131,8 @@ beestat.component.card.runtime_sensor_detail.prototype.decorate_contents_ = func
    * the database, check every 2 seconds until it does.
    */
   if (beestat.thermostat.data_synced(this.thermostat_id_, required_begin, required_end) === true) {
-    if (
-      beestat.cache.runtime_sensor === undefined ||
-      beestat.cache.data.runtime_thermostat_last !== 'runtime_sensor_detail'
-    ) {
-      this.show_loading_('Loading');
+    if (beestat.cache.runtime_sensor === undefined) {
+      this.show_loading_('Fetching');
 
       var value;
       var operator;
@@ -191,8 +188,7 @@ beestat.component.card.runtime_sensor_detail.prototype.decorate_contents_ = func
         for (var alias in response) {
           var r = response[alias];
           if (alias === 'runtime_thermostat') {
-            beestat.cache.set('data.runtime_thermostat_last', 'runtime_sensor_detail');
-            beestat.cache.set('runtime_thermostat', r);
+            beestat.cache.set('data.runtime_thermostat_sensor_detail', r);
           } else {
             runtime_sensors = runtime_sensors.concat(r);
           }
@@ -299,12 +295,12 @@ beestat.component.card.runtime_sensor_detail.prototype.decorate_top_right_ = fun
       }
     }));
 
-    menu.add_menu_item(new beestat.component.menu_item()
-      .set_text('Custom')
-      .set_icon('calendar_edit')
-      .set_callback(function() {
-        (new beestat.component.modal.runtime_sensor_detail_custom()).render();
-      }));
+  menu.add_menu_item(new beestat.component.menu_item()
+    .set_text('Custom')
+    .set_icon('calendar_edit')
+    .set_callback(function() {
+      (new beestat.component.modal.runtime_sensor_detail_custom()).render();
+    }));
 
   if (this.has_data_() === true) {
     menu.add_menu_item(new beestat.component.menu_item()
@@ -359,7 +355,6 @@ beestat.component.card.runtime_sensor_detail.prototype.has_data_ = function() {
  */
 beestat.component.card.runtime_sensor_detail.prototype.get_data_ = function(force) {
   if (this.data_ === undefined || force === true) {
-
     var range = {
       'type': beestat.setting('runtime_sensor_detail_range_type'),
       'dynamic': beestat.setting('runtime_sensor_detail_range_dynamic'),
@@ -368,7 +363,11 @@ beestat.component.card.runtime_sensor_detail.prototype.get_data_ = function(forc
     };
 
     var sensor_data = beestat.runtime_sensor.get_data(this.thermostat_id_, range);
-    var thermostat_data = beestat.runtime_thermostat.get_data(this.thermostat_id_, range);
+    var thermostat_data = beestat.runtime_thermostat.get_data(
+      this.thermostat_id_,
+      range,
+      'runtime_thermostat_sensor_detail'
+    );
 
     this.data_ = sensor_data;
 
diff --git a/js/component/card/runtime_thermostat_detail.js b/js/component/card/runtime_thermostat_detail.js
index 20003b3..a491aa7 100644
--- a/js/component/card/runtime_thermostat_detail.js
+++ b/js/component/card/runtime_thermostat_detail.js
@@ -27,8 +27,8 @@ beestat.component.card.runtime_thermostat_detail = function(thermostat_id) {
     [
       'setting.runtime_thermostat_detail_range_type',
       'setting.runtime_thermostat_detail_range_dynamic',
-      'cache.runtime_thermostat',
-      'cache.thermostat'
+      'cache.data.runtime_thermostat_thermostat_detail',
+      'cache.data.thermostat'
     ],
     change_function
   );
@@ -150,11 +150,8 @@ beestat.component.card.runtime_thermostat_detail.prototype.decorate_contents_ =
    * the database, check every 2 seconds until it does.
    */
   if (beestat.thermostat.data_synced(this.thermostat_id_, required_begin, required_end) === true) {
-    if (
-      beestat.cache.runtime_thermostat === undefined ||
-      beestat.cache.data.runtime_thermostat_last !== 'runtime_thermostat_detail'
-    ) {
-      this.show_loading_('Loading');
+    if (beestat.cache.data.runtime_thermostat_thermostat_detail === undefined) {
+      this.show_loading_('Fetching');
 
       var value;
       var operator;
@@ -185,8 +182,7 @@ beestat.component.card.runtime_thermostat_detail.prototype.decorate_contents_ =
           }
         )
         .set_callback(function(response) {
-          beestat.cache.set('data.runtime_thermostat_last', 'runtime_thermostat_detail');
-          beestat.cache.set('runtime_thermostat', response);
+          beestat.cache.set('data.runtime_thermostat_thermostat_detail', response);
         })
         .send();
     } else if (this.has_data_() === false) {
@@ -246,7 +242,7 @@ beestat.component.card.runtime_thermostat_detail.prototype.decorate_top_right_ =
         beestat.setting('runtime_thermostat_detail_range_dynamic') !== 1 ||
         beestat.setting('runtime_thermostat_detail_range_type') !== 'dynamic'
       ) {
-        beestat.cache.delete('runtime_thermostat');
+        beestat.cache.delete('data.runtime_thermostat_thermostat_detail');
         beestat.setting({
           'runtime_thermostat_detail_range_dynamic': 1,
           'runtime_thermostat_detail_range_type': 'dynamic'
@@ -262,7 +258,7 @@ beestat.component.card.runtime_thermostat_detail.prototype.decorate_top_right_ =
         beestat.setting('runtime_thermostat_detail_range_dynamic') !== 3 ||
         beestat.setting('runtime_thermostat_detail_range_type') !== 'dynamic'
       ) {
-        beestat.cache.delete('runtime_thermostat');
+        beestat.cache.delete('data.runtime_thermostat_thermostat_detail');
         beestat.setting({
           'runtime_thermostat_detail_range_dynamic': 3,
           'runtime_thermostat_detail_range_type': 'dynamic'
@@ -278,7 +274,7 @@ beestat.component.card.runtime_thermostat_detail.prototype.decorate_top_right_ =
         beestat.setting('runtime_thermostat_detail_range_dynamic') !== 7 ||
         beestat.setting('runtime_thermostat_detail_range_type') !== 'dynamic'
       ) {
-        beestat.cache.delete('runtime_thermostat');
+        beestat.cache.delete('data.runtime_thermostat_thermostat_detail');
         beestat.setting({
           'runtime_thermostat_detail_range_dynamic': 7,
           'runtime_thermostat_detail_range_type': 'dynamic'
@@ -353,7 +349,11 @@ beestat.component.card.runtime_thermostat_detail.prototype.get_data_ = function(
       'static_end': beestat.setting('runtime_thermostat_detail_range_static_end')
     };
 
-    this.data_ = beestat.runtime_thermostat.get_data(this.thermostat_id_, range);
+    this.data_ = beestat.runtime_thermostat.get_data(
+      this.thermostat_id_,
+      range,
+      'runtime_thermostat_thermostat_detail'
+    );
 
     this.data_.metadata.chart.title = this.get_title_();
     this.data_.metadata.chart.subtitle = this.get_subtitle_();
diff --git a/js/component/card/score.js b/js/component/card/score.js
deleted file mode 100644
index 9ed45ca..0000000
--- a/js/component/card/score.js
+++ /dev/null
@@ -1,233 +0,0 @@
-/**
- * Parent score card.
- */
-beestat.component.card.score = function() {
-  var self = this;
-
-  /*
-   * Debounce so that multiple setting changes don't re-trigger the same
-   * event. This fires on the trailing edge so that all changes are accounted
-   * for when rerendering.
-   */
-  var data_change_function = beestat.debounce(function() {
-    self.rerender();
-  }, 10);
-
-  beestat.dispatcher.addEventListener(
-    'cache.data.comparison_scores_' + this.type_,
-    data_change_function
-  );
-
-  beestat.component.card.apply(this, arguments);
-
-  this.layer_.register_loader(beestat.comparisons.get_comparison_scores);
-};
-beestat.extend(beestat.component.card.score, beestat.component.card);
-
-/**
- * Decorate
- *
- * @param {rocket.Elements} parent
- */
-beestat.component.card.score.prototype.decorate_contents_ = function(parent) {
-  var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')];
-  var thermostat_group = beestat.cache.thermostat_group[
-    thermostat.thermostat_group_id
-  ];
-
-  if (
-    beestat.cache.data['comparison_scores_' + this.type_] === undefined
-  ) {
-    // Height buffer so the cards don't resize after they load.
-    parent.appendChild($.createElement('div')
-      .style('height', '166px')
-      .innerHTML(' '));
-    this.show_loading_('Calculating');
-  } else {
-    var percentile;
-    if (
-      thermostat_group.temperature_profile[this.type_] !== undefined &&
-      beestat.cache.data['comparison_scores_' + this.type_].length > 2
-    ) {
-      percentile = this.get_percentile_(
-        thermostat_group.temperature_profile[this.type_].score,
-        beestat.cache.data['comparison_scores_' + this.type_]
-      );
-    } else {
-      percentile = null;
-    }
-
-    var color;
-    if (percentile > 70) {
-      color = beestat.style.color.green.base;
-    } else if (percentile > 50) {
-      color = beestat.style.color.yellow.base;
-    } else if (percentile > 25) {
-      color = beestat.style.color.orange.base;
-    } else if (percentile !== null) {
-      color = beestat.style.color.red.base;
-    } else {
-      color = '#fff';
-    }
-
-    var container = $.createElement('div')
-      .style({
-        'text-align': 'center',
-        'position': 'relative',
-        'margin-top': beestat.style.size.gutter
-      });
-    parent.appendChild(container);
-
-    var percentile_text;
-    var percentile_font_size;
-    var percentile_color;
-    if (percentile !== null) {
-      percentile_text = '';
-      percentile_font_size = 48;
-      percentile_color = '#fff';
-    } else if (
-      thermostat_group['system_type_' + this.type_] === null ||
-      thermostat_group['system_type_' + this.type_] === 'none'
-    ) {
-      percentile_text = 'None';
-      percentile_font_size = 16;
-      percentile_color = beestat.style.color.gray.base;
-    } else {
-      percentile_text = 'Insufficient data';
-      percentile_font_size = 16;
-      percentile_color = beestat.style.color.yellow.base;
-    }
-
-    var percentile_div = $.createElement('div')
-      .innerText(percentile_text)
-      .style({
-        'position': 'absolute',
-        'top': '50%',
-        'left': '50%',
-        'transform': 'translate(-50%, -50%)',
-        'font-size': percentile_font_size,
-        'font-weight': beestat.style.font_weight.light,
-        'color': percentile_color
-      });
-    container.appendChild(percentile_div);
-
-    var stroke = 3;
-    var size = 150;
-    var diameter = size - stroke;
-    var radius = diameter / 2;
-    var circumference = Math.PI * diameter;
-
-    var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
-    svg.setAttribute('height', size);
-    svg.setAttribute('width', size);
-    svg.style.transform = 'rotate(-90deg)';
-    container.appendChild(svg);
-
-    var background = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
-    background.setAttribute('cx', (size / 2));
-    background.setAttribute('cy', (size / 2));
-    background.setAttribute('r', radius);
-    background.setAttribute('stroke', beestat.style.color.bluegray.dark);
-    background.setAttribute('stroke-width', stroke);
-    background.setAttribute('fill', 'none');
-    svg.appendChild(background);
-
-    var stroke_dasharray = circumference;
-    var stroke_dashoffset_initial = stroke_dasharray;
-    var stroke_dashoffset_final = stroke_dasharray * (1 - (percentile / 100));
-
-    var foreground = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
-    foreground.style.transition = 'stroke-dashoffset 1s ease';
-    foreground.setAttribute('cx', (size / 2));
-    foreground.setAttribute('cy', (size / 2));
-    foreground.setAttribute('r', radius);
-    foreground.setAttribute('stroke', color);
-    foreground.setAttribute('stroke-width', stroke);
-    foreground.setAttribute('stroke-linecap', 'round');
-    foreground.setAttribute('stroke-dasharray', stroke_dasharray);
-    foreground.setAttribute('stroke-dashoffset', stroke_dashoffset_initial);
-    foreground.setAttribute('fill', 'none');
-    svg.appendChild(foreground);
-
-    /*
-     * For some reason the render event (which is timeout 0) doesn't work well
-     * here.
-     */
-    setTimeout(function() {
-      foreground.setAttribute('stroke-dashoffset', stroke_dashoffset_final);
-
-      if (percentile !== null) {
-        $.step(
-          function(percentage, sine) {
-            var calculated_percentile = Math.round(percentile * sine);
-            percentile_div.innerText(calculated_percentile);
-          },
-          1000,
-          null,
-          30
-        );
-      }
-    }, 100);
-  }
-};
-
-/**
- * Get the percentile rank of a score in a set of scores.
- *
- * @param {number} score
- * @param {array} scores
- *
- * @return {number} The percentile rank.
- */
-beestat.component.card.score.prototype.get_percentile_ = function(score, scores) {
-  var n = scores.length;
-  var below = 0;
-  scores.forEach(function(s) {
-    if (s < score) {
-      below++;
-    }
-  });
-
-  return Math.round(below / n * 100);
-};
-
-/**
- * Decorate the menu.
- *
- * @param {rocket.Elements} parent
- */
-beestat.component.card.score.prototype.decorate_top_right_ = function(parent) {
-  var self = this;
-
-  var menu = (new beestat.component.menu()).render(parent);
-
-  menu.add_menu_item(new beestat.component.menu_item()
-    .set_text('Help')
-    .set_icon('help_circle')
-    .set_callback(function() {
-      window.open('https://doc.beestat.io/144d5dafbc6c43f7bc72341120717d8a');
-    }));
-};
-
-/**
- * Get subtitle.
- *
- * @return {string} The subtitle.
- */
-beestat.component.card.score.prototype.get_subtitle_ = function() {
-  var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')];
-  var thermostat_group = beestat.cache.thermostat_group[
-    thermostat.thermostat_group_id
-  ];
-
-  if (
-    beestat.cache.data['comparison_scores_' + this.type_] !== undefined &&
-    beestat.cache.data['comparison_scores_' + this.type_].length > 2 &&
-    thermostat_group.temperature_profile[this.type_] !== null
-  ) {
-    return 'Comparing to ' + Number(beestat.cache.data['comparison_scores_' + this.type_].length).toLocaleString() + ' Homes';
-  }
-
-  return 'N/A';
-
-};
diff --git a/js/component/card/score/cool.js b/js/component/card/score/cool.js
deleted file mode 100644
index 19130b8..0000000
--- a/js/component/card/score/cool.js
+++ /dev/null
@@ -1,26 +0,0 @@
-/**
- * Cool score card.
- */
-beestat.component.card.score.cool = function() {
-  this.type_ = 'cool';
-  beestat.component.card.score.apply(this, arguments);
-};
-beestat.extend(beestat.component.card.score.cool, beestat.component.card.score);
-
-/**
- * Get the title of the card.
- *
- * @return {string} The title of the card.
- */
-beestat.component.card.score.cool.prototype.get_title_ = function() {
-  return 'Cool Score';
-};
-
-/**
- * Get the subtitle of the card.
- *
- * @return {string} The title of the card.
- */
-// beestat.component.card.score.cool.prototype.get_subtitle_ = function() {
-  // return '#hype';
-// };
diff --git a/js/component/card/score/heat.js b/js/component/card/score/heat.js
deleted file mode 100644
index 6a1043f..0000000
--- a/js/component/card/score/heat.js
+++ /dev/null
@@ -1,26 +0,0 @@
-/**
- * Cool score card.
- */
-beestat.component.card.score.heat = function() {
-  this.type_ = 'heat';
-  beestat.component.card.score.apply(this, arguments);
-};
-beestat.extend(beestat.component.card.score.heat, beestat.component.card.score);
-
-/**
- * Get the title of the card.
- *
- * @return {string} The title of the card.
- */
-beestat.component.card.score.heat.prototype.get_title_ = function() {
-  return 'Heat Score';
-};
-
-/**
- * Get the subtitle of the card.
- *
- * @return {string} The title of the card.
- */
-// beestat.component.card.score.heat.prototype.get_subtitle_ = function() {
-  // return '#hype';
-// };
diff --git a/js/component/card/score/resist.js b/js/component/card/score/resist.js
deleted file mode 100644
index bac740d..0000000
--- a/js/component/card/score/resist.js
+++ /dev/null
@@ -1,28 +0,0 @@
-/**
- * Resist score card.
- */
-beestat.component.card.score.resist = function() {
-  this.type_ = 'resist';
-  beestat.component.card.score.apply(this, arguments);
-};
-beestat.extend(beestat.component.card.score.resist, beestat.component.card.score);
-
-/**
- * Get the title of the card.
- *
- * @return {string} The title of the card.
- */
-beestat.component.card.score.resist.prototype.get_title_ = function() {
-  return 'Resist Score';
-};
-
-/**
- * Get the subtitle of the card.
- *
- * @return {string} The title of the card.
- */
-/*
- * beestat.component.card.score.resist.prototype.get_subtitle_ = function() {
- * return '#hype';
- * };
- */
diff --git a/js/component/card/sensors.js b/js/component/card/sensors.js
index 663216d..1760d34 100644
--- a/js/component/card/sensors.js
+++ b/js/component/card/sensors.js
@@ -162,7 +162,7 @@ beestat.component.card.sensors.prototype.decorate_sensor_ = function(parent, sen
 /**
  * Get the title of the card.
  *
- * @return {string}
+ * @return {string} The title.
  */
 beestat.component.card.sensors.prototype.get_title_ = function() {
   return 'Sensors';
diff --git a/js/component/card/system.js b/js/component/card/system.js
index 6e370ac..191630d 100644
--- a/js/component/card/system.js
+++ b/js/component/card/system.js
@@ -87,6 +87,11 @@ beestat.component.card.system.prototype.decorate_circle_ = function(parent) {
   );
 };
 
+/**
+ * Decorate the weather
+ *
+ * @param {rocket.Elements} parent Parent
+ */
 beestat.component.card.system.prototype.decorate_weather_ = function(parent) {
   var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')];
 
diff --git a/js/component/card/temperature_profiles.js b/js/component/card/temperature_profiles.js
index b8beaac..0a58daa 100644
--- a/js/component/card/temperature_profiles.js
+++ b/js/component/card/temperature_profiles.js
@@ -1,11 +1,11 @@
 /**
  * Temperature profiles.
  *
- * @param {number} thermostat_group_id The thermostat_group_id this card is
- * displaying data for.
+ * @param {number} thermostat_id The thermostat_id this card is displaying
+ * data for.
  */
-beestat.component.card.temperature_profiles = function(thermostat_group_id) {
-  this.thermostat_group_id_ = thermostat_group_id;
+beestat.component.card.temperature_profiles = function(thermostat_id) {
+  this.thermostat_id_ = thermostat_id;
 
   beestat.component.card.apply(this, arguments);
 };
@@ -28,9 +28,7 @@ beestat.component.card.temperature_profiles.prototype.decorate_contents_ = funct
  * @return {object} The series data.
  */
 beestat.component.card.temperature_profiles.prototype.get_data_ = function() {
-  var thermostat_group = beestat.cache.thermostat_group[
-    this.thermostat_group_id_
-  ];
+  var thermostat = beestat.cache.thermostat[this.thermostat_id_];
 
   var data = {
     'x': [],
@@ -41,7 +39,7 @@ beestat.component.card.temperature_profiles.prototype.get_data_ = function() {
         'title': this.get_title_(),
         'subtitle': this.get_subtitle_(),
         'outdoor_temperature': beestat.temperature({
-          'temperature': (thermostat_group.weather.temperature / 10),
+          'temperature': (thermostat.weather.temperature / 10),
           'round': 0
         })
       }
@@ -49,10 +47,10 @@ beestat.component.card.temperature_profiles.prototype.get_data_ = function() {
   };
 
   if (
-    thermostat_group.temperature_profile === null
+    thermostat.profile === null
   ) {
     this.chart_.render(parent);
-    this.show_loading_('Calculating');
+    this.show_loading_('Fetching');
   } else {
     // Global x range.
     var x_min = Infinity;
@@ -60,19 +58,19 @@ beestat.component.card.temperature_profiles.prototype.get_data_ = function() {
 
     var y_min = Infinity;
     var y_max = -Infinity;
-    for (var type in thermostat_group.temperature_profile) {
+    for (var type in thermostat.profile.temperature) {
       // Cloned because I mutate this data for temperature conversions.
       var profile = beestat.clone(
-        thermostat_group.temperature_profile[type]
+        thermostat.profile.temperature[type]
       );
 
       if (profile !== null) {
         // Convert the data to Celsius if necessary
         var deltas_converted = {};
         for (var key in profile.deltas) {
-          deltas_converted[beestat.temperature({'temperature': (key / 10)})] =
+          deltas_converted[beestat.temperature({'temperature': key})] =
             beestat.temperature({
-              'temperature': (profile.deltas[key] / 10),
+              'temperature': (profile.deltas[key]),
               'delta': true,
               'round': 3
             });
@@ -199,24 +197,29 @@ beestat.component.card.temperature_profiles.prototype.get_title_ = function() {
  * @return {string} The subtitle.
  */
 beestat.component.card.temperature_profiles.prototype.get_subtitle_ = function() {
-  var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')];
-  var thermostat_group = beestat.cache.thermostat_group[
-    thermostat.thermostat_group_id
-  ];
+  const thermostat = beestat.cache.thermostat[this.thermostat_id_];
 
-  /*
-   * All profiles are calculated at the same time, and resist is the most
-   * guaranteed one to have.
-   */
-  if (thermostat_group.temperature_profile.resist !== undefined) {
-    var generated_at_m = moment(
-      thermostat_group.temperature_profile.resist.metadata.generated_at
-    );
+  const generated_at_m = moment(
+    thermostat.profile.metadata.generated_at
+  );
 
-    return 'Generated ' + generated_at_m.format('MMM Do @ h a') + ' (updated weekly)';
+  let duration_text = '';
+
+  // How much data was used to generate this.
+  const duration_weeks = Math.round(thermostat.profile.metadata.duration / 7);
+  duration_text += ' from the past ';
+  if (duration_weeks === 0) {
+    duration_text += ' few days';
+  } else if (duration_weeks === 1) {
+    duration_text += ' week';
+  } else if (duration_weeks >= 52) {
+    duration_text += ' year';
+  } else {
+    duration_text += duration_weeks + ' weeks';
   }
+  duration_text += ' of data.';
 
-  return null;
+  return 'Generated ' + generated_at_m.format('MMM Do @ h a') + ' (updated weekly) ' + duration_text;
 };
 
 /**
diff --git a/js/component/card/temperature_profiles_new.js b/js/component/card/temperature_profiles_new.js
deleted file mode 100644
index 66c44df..0000000
--- a/js/component/card/temperature_profiles_new.js
+++ /dev/null
@@ -1,237 +0,0 @@
-/**
- * Temperature profiles.
- *
- * @param {number} thermostat_group_id The thermostat_group_id this card is
- * displaying data for.
- */
-beestat.component.card.temperature_profiles_new = function(thermostat_group_id) {
-  this.thermostat_group_id_ = thermostat_group_id;
-
-  beestat.component.card.apply(this, arguments);
-};
-beestat.extend(beestat.component.card.temperature_profiles_new, beestat.component.card);
-
-/**
- * Decorate card.
- *
- * @param {rocket.Elements} parent
- */
-beestat.component.card.temperature_profiles_new.prototype.decorate_contents_ = function(parent) {
-  var data = this.get_data_();
-  this.chart_ = new beestat.component.chart.temperature_profiles_new(data);
-  this.chart_.render(parent);
-};
-
-/**
- * Get all of the series data.
- *
- * @return {object} The series data.
- */
-beestat.component.card.temperature_profiles_new.prototype.get_data_ = function() {
-  var thermostat_group = beestat.cache.thermostat_group[
-    this.thermostat_group_id_
-  ];
-
-  var data = {
-    'x': [],
-    'series': {},
-    'metadata': {
-      'series': {},
-      'chart': {
-        'title': this.get_title_(),
-        'subtitle': this.get_subtitle_(),
-        'outdoor_temperature': beestat.temperature({
-          'temperature': (thermostat_group.weather.temperature / 10),
-          'round': 0
-        })
-      }
-    }
-  };
-
-  if (
-    thermostat_group.profile === null
-  ) {
-    this.chart_.render(parent);
-    this.show_loading_('Calculating');
-  } else {
-    // Global x range.
-    var x_min = Infinity;
-    var x_max = -Infinity;
-
-    var y_min = Infinity;
-    var y_max = -Infinity;
-    for (var type in thermostat_group.profile.temperature) {
-      // Cloned because I mutate this data for temperature conversions.
-      var profile = beestat.clone(
-        thermostat_group.profile.temperature[type]
-      );
-
-      if (profile !== null) {
-        // Convert the data to Celsius if necessary
-        var deltas_converted = {};
-        for (var key in profile.deltas) {
-          deltas_converted[beestat.temperature({'temperature': key})] =
-            beestat.temperature({
-              'temperature': (profile.deltas[key]),
-              'delta': true,
-              'round': 3
-            });
-        }
-
-        profile.deltas = deltas_converted;
-        var linear_trendline = this.get_linear_trendline_(profile.deltas);
-
-        var min_max_keys = Object.keys(profile.deltas);
-
-        // This specific trendline x range.
-        var this_x_min = Math.min.apply(null, min_max_keys);
-        var this_x_max = Math.max.apply(null, min_max_keys);
-
-        // Global x range.
-        x_min = Math.min(x_min, this_x_min);
-        x_max = Math.max(x_max, this_x_max);
-
-        data.series['trendline_' + type] = [];
-        data.series['raw_' + type] = [];
-
-        /**
-         * Data is stored internally as °F with 1 value per degree. That data
-         * gets converted to °C which then requires additional precision
-         * (increment).
-         *
-         * The additional precision introduces floating point error, so
-         * convert the x value to a fixed string.
-         *
-         * The string then needs converted to a number for highcharts, so
-         * later on use parseFloat to get back to that.
-         *
-         * Stupid Celsius.
-         */
-        var increment;
-        var fixed;
-        if (beestat.setting('temperature_unit') === '°F') {
-          increment = 1;
-          fixed = 0;
-        } else {
-          increment = 0.1;
-          fixed = 1;
-        }
-        for (var x = this_x_min; x <= this_x_max; x += increment) {
-          var x_fixed = x.toFixed(fixed);
-          var y = (linear_trendline.slope * x_fixed) +
-            linear_trendline.intercept;
-
-          data.series['trendline_' + type].push([
-            parseFloat(x_fixed),
-            y
-          ]);
-          if (profile.deltas[x_fixed] !== undefined) {
-            data.series['raw_' + type].push([
-              parseFloat(x_fixed),
-              profile.deltas[x_fixed]
-            ]);
-            y_min = Math.min(y_min, profile.deltas[x_fixed]);
-            y_max = Math.max(y_max, profile.deltas[x_fixed]);
-          }
-
-          data.metadata.chart.y_min = y_min;
-          data.metadata.chart.y_max = y_max;
-        }
-      }
-    }
-  }
-
-  return data;
-};
-
-/**
- * Get a linear trendline from a set of data.
- *
- * @param {Object} data The data; at least two points required.
- *
- * @return {Object} The slope and intercept of the trendline.
- */
-beestat.component.card.temperature_profiles_new.prototype.get_linear_trendline_ = function(data) {
-  // Requires at least two points.
-  if (Object.keys(data).length < 2) {
-    return null;
-  }
-
-  var sum_x = 0;
-  var sum_y = 0;
-  var sum_xy = 0;
-  var sum_x_squared = 0;
-  var n = 0;
-
-  for (var x in data) {
-    x = parseFloat(x);
-    var y = parseFloat(data[x]);
-
-    sum_x += x;
-    sum_y += y;
-    sum_xy += (x * y);
-    sum_x_squared += Math.pow(x, 2);
-    n++;
-  }
-
-  var slope = ((n * sum_xy) - (sum_x * sum_y)) /
-    ((n * sum_x_squared) - (Math.pow(sum_x, 2)));
-  var intercept = ((sum_y) - (slope * sum_x)) / (n);
-
-  return {
-    'slope': slope,
-    'intercept': intercept
-  };
-};
-
-/**
- * Get the title of the card.
- *
- * @return {string} The title.
- */
-beestat.component.card.temperature_profiles_new.prototype.get_title_ = function() {
-  return 'Temperature Profiles';
-};
-
-/**
- * Get the subtitle of the card.
- *
- * @return {string} The subtitle.
- */
-beestat.component.card.temperature_profiles_new.prototype.get_subtitle_ = function() {
-  var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')];
-  var thermostat_group = beestat.cache.thermostat_group[
-    thermostat.thermostat_group_id
-  ];
-
-  var generated_at_m = moment(
-    thermostat_group.profile.metadata.generated_at
-  );
-
-  return 'Generated ' + generated_at_m.format('MMM Do @ h a') + ' (updated weekly)';
-};
-
-/**
- * Decorate the menu.
- *
- * @param {rocket.Elements} parent
- */
-beestat.component.card.temperature_profiles_new.prototype.decorate_top_right_ = function(parent) {
-  var self = this;
-
-  var menu = (new beestat.component.menu()).render(parent);
-
-  menu.add_menu_item(new beestat.component.menu_item()
-    .set_text('Download Chart')
-    .set_icon('download')
-    .set_callback(function() {
-      self.chart_.export();
-    }));
-
-  menu.add_menu_item(new beestat.component.menu_item()
-    .set_text('Help')
-    .set_icon('help_circle')
-    .set_callback(function() {
-      window.open('https://doc.beestat.io/9c0fba6793dd4bc68f798c1516f0ea25');
-    }));
-};
diff --git a/js/component/chart/runtime_sensor_detail_temperature.js b/js/component/chart/runtime_sensor_detail_temperature.js
index eed4e10..8212555 100644
--- a/js/component/chart/runtime_sensor_detail_temperature.js
+++ b/js/component/chart/runtime_sensor_detail_temperature.js
@@ -229,7 +229,7 @@ beestat.component.chart.runtime_sensor_detail_temperature.prototype.get_options_
         });
         var occupancy_key = series.name.replace('temperature', 'occupancy');
         occupancy[occupancy_key] =
-          (self.data_.metadata.series[occupancy_key].data[x.valueOf()] !== undefined)
+          (self.data_.metadata.series[occupancy_key].data[x.valueOf()] !== undefined);
       }
     });
 
diff --git a/js/component/chart/temperature_profiles.js b/js/component/chart/temperature_profiles.js
index 35e6f84..669cdbc 100644
--- a/js/component/chart/temperature_profiles.js
+++ b/js/component/chart/temperature_profiles.js
@@ -31,9 +31,9 @@ beestat.component.chart.temperature_profiles.prototype.get_options_series_ = fun
 
   // Trendline data
   series.push({
-    'data': this.data_.series.trendline_heat,
-    'name': 'indoor_heat_delta',
-    'color': beestat.series.compressor_heat_1.color,
+    'data': this.data_.series.trendline_heat_1,
+    'name': 'indoor_heat_1_delta',
+    'color': beestat.series.indoor_heat_1_delta.color,
     'marker': {
       'enabled': false,
       'states': {'hover': {'enabled': false}}
@@ -45,9 +45,37 @@ beestat.component.chart.temperature_profiles.prototype.get_options_series_ = fun
 
   // Trendline data
   series.push({
-    'data': this.data_.series.trendline_cool,
-    'name': 'indoor_cool_delta',
-    'color': beestat.series.compressor_cool_1.color,
+    'data': this.data_.series.trendline_heat_2,
+    'name': 'indoor_heat_2_delta',
+    'color': beestat.series.indoor_heat_2_delta.color,
+    'marker': {
+      'enabled': false,
+      'states': {'hover': {'enabled': false}}
+    },
+    'type': 'line',
+    'lineWidth': 2,
+    'states': {'hover': {'lineWidthPlus': 0}}
+  });
+
+  // Trendline data
+  series.push({
+    'data': this.data_.series.trendline_cool_1,
+    'name': 'indoor_cool_1_delta',
+    'color': beestat.series.indoor_cool_1_delta.color,
+    'marker': {
+      'enabled': false,
+      'states': {'hover': {'enabled': false}}
+    },
+    'type': 'line',
+    'lineWidth': 2,
+    'states': {'hover': {'lineWidthPlus': 0}}
+  });
+
+  // Trendline data
+  series.push({
+    'data': this.data_.series.trendline_cool_2,
+    'name': 'indoor_cool_2_delta',
+    'color': beestat.series.indoor_cool_2_delta.color,
     'marker': {
       'enabled': false,
       'states': {'hover': {'enabled': false}}
@@ -61,7 +89,7 @@ beestat.component.chart.temperature_profiles.prototype.get_options_series_ = fun
   series.push({
     'data': this.data_.series.trendline_resist,
     'name': 'indoor_resist_delta',
-    'color': beestat.style.color.gray.dark,
+    'color': beestat.series.indoor_resist_delta.color,
     'marker': {
       'enabled': false,
       'states': {'hover': {'enabled': false}}
@@ -73,9 +101,9 @@ beestat.component.chart.temperature_profiles.prototype.get_options_series_ = fun
 
   // Raw data
   series.push({
-    'data': this.data_.series.raw_heat,
-    'name': 'indoor_heat_delta_raw',
-    'color': beestat.series.compressor_heat_1.color,
+    'data': this.data_.series.raw_heat_1,
+    'name': 'indoor_heat_1_delta_raw',
+    'color': beestat.series.indoor_heat_1_delta_raw.color,
     'dashStyle': 'ShortDot',
     'marker': {
       'enabled': false,
@@ -88,9 +116,39 @@ beestat.component.chart.temperature_profiles.prototype.get_options_series_ = fun
 
   // Raw data
   series.push({
-    'data': this.data_.series.raw_cool,
-    'name': 'indoor_cool_delta_raw',
-    'color': beestat.series.compressor_cool_1.color,
+    'data': this.data_.series.raw_heat_2,
+    'name': 'indoor_heat_2_delta_raw',
+    'color': beestat.series.indoor_heat_2_delta_raw.color,
+    'dashStyle': 'ShortDot',
+    'marker': {
+      'enabled': false,
+      'states': {'hover': {'enabled': false}}
+    },
+    'type': 'spline',
+    'lineWidth': 1,
+    'states': {'hover': {'lineWidthPlus': 0}}
+  });
+
+  // Raw data
+  series.push({
+    'data': this.data_.series.raw_cool_1,
+    'name': 'indoor_cool_1_delta_raw',
+    'color': beestat.series.indoor_cool_1_delta_raw.color,
+    'dashStyle': 'ShortDot',
+    'marker': {
+      'enabled': false,
+      'states': {'hover': {'enabled': false}}
+    },
+    'type': 'spline',
+    'lineWidth': 1,
+    'states': {'hover': {'lineWidthPlus': 0}}
+  });
+
+  // Raw data
+  series.push({
+    'data': this.data_.series.raw_cool_2,
+    'name': 'indoor_cool_2_delta_raw',
+    'color': beestat.series.indoor_cool_2_delta_raw.color,
     'dashStyle': 'ShortDot',
     'marker': {
       'enabled': false,
@@ -105,7 +163,7 @@ beestat.component.chart.temperature_profiles.prototype.get_options_series_ = fun
   series.push({
     'data': this.data_.series.raw_resist,
     'name': 'indoor_resist_delta_raw',
-    'color': beestat.style.color.gray.dark,
+    'color': beestat.series.indoor_resist_delta_raw.color,
     'dashStyle': 'ShortDot',
     'marker': {
       'enabled': false,
diff --git a/js/component/chart/temperature_profiles_new.js b/js/component/chart/temperature_profiles_new.js
deleted file mode 100644
index 5adb56e..0000000
--- a/js/component/chart/temperature_profiles_new.js
+++ /dev/null
@@ -1,346 +0,0 @@
-/**
- * Temperature profiles chart.
- *
- * @param {object} data The chart data.
- */
-beestat.component.chart.temperature_profiles_new = function(data) {
-  this.data_ = data;
-
-  beestat.component.chart.apply(this, arguments);
-};
-beestat.extend(beestat.component.chart.temperature_profiles_new, beestat.component.chart);
-
-/**
- * Override for get_options_xAxis_labels_formatter_.
- *
- * @return {Function} xAxis labels formatter.
- */
-beestat.component.chart.temperature_profiles_new.prototype.get_options_xAxis_labels_formatter_ = function() {
-  return function() {
-    return this.value + beestat.setting('temperature_unit');
-  };
-};
-
-/**
- * Override for get_options_series_.
- *
- * @return {Array} All of the series to display on the chart.
- */
-beestat.component.chart.temperature_profiles_new.prototype.get_options_series_ = function() {
-  var series = [];
-
-  // Trendline data
-  series.push({
-    'data': this.data_.series.trendline_heat_1,
-    'name': 'indoor_heat_1_delta',
-    'color': beestat.series.indoor_heat_1_delta.color,
-    'marker': {
-      'enabled': false,
-      'states': {'hover': {'enabled': false}}
-    },
-    'type': 'line',
-    'lineWidth': 2,
-    'states': {'hover': {'lineWidthPlus': 0}}
-  });
-
-  // Trendline data
-  series.push({
-    'data': this.data_.series.trendline_heat_2,
-    'name': 'indoor_heat_2_delta',
-    'color': beestat.series.indoor_heat_2_delta.color,
-    'marker': {
-      'enabled': false,
-      'states': {'hover': {'enabled': false}}
-    },
-    'type': 'line',
-    'lineWidth': 2,
-    'states': {'hover': {'lineWidthPlus': 0}}
-  });
-
-  // Trendline data
-  series.push({
-    'data': this.data_.series.trendline_cool_1,
-    'name': 'indoor_cool_1_delta',
-    'color': beestat.series.indoor_cool_1_delta.color,
-    'marker': {
-      'enabled': false,
-      'states': {'hover': {'enabled': false}}
-    },
-    'type': 'line',
-    'lineWidth': 2,
-    'states': {'hover': {'lineWidthPlus': 0}}
-  });
-
-  // Trendline data
-  series.push({
-    'data': this.data_.series.trendline_cool_2,
-    'name': 'indoor_cool_2_delta',
-    'color': beestat.series.indoor_cool_2_delta.color,
-    'marker': {
-      'enabled': false,
-      'states': {'hover': {'enabled': false}}
-    },
-    'type': 'line',
-    'lineWidth': 2,
-    'states': {'hover': {'lineWidthPlus': 0}}
-  });
-
-  // Trendline data
-  series.push({
-    'data': this.data_.series.trendline_resist,
-    'name': 'indoor_resist_delta',
-    'color': beestat.series.indoor_resist_delta.color,
-    'marker': {
-      'enabled': false,
-      'states': {'hover': {'enabled': false}}
-    },
-    'type': 'line',
-    'lineWidth': 2,
-    'states': {'hover': {'lineWidthPlus': 0}}
-  });
-
-  // Raw data
-  series.push({
-    'data': this.data_.series.raw_heat_1,
-    'name': 'indoor_heat_1_delta_raw',
-    'color': beestat.series.indoor_heat_1_delta_raw.color,
-    'dashStyle': 'ShortDot',
-    'marker': {
-      'enabled': false,
-      'states': {'hover': {'enabled': false}}
-    },
-    'type': 'spline',
-    'lineWidth': 1,
-    'states': {'hover': {'lineWidthPlus': 0}}
-  });
-
-  // Raw data
-  series.push({
-    'data': this.data_.series.raw_heat_2,
-    'name': 'indoor_heat_2_delta_raw',
-    'color': beestat.series.indoor_heat_2_delta_raw.color,
-    'dashStyle': 'ShortDot',
-    'marker': {
-      'enabled': false,
-      'states': {'hover': {'enabled': false}}
-    },
-    'type': 'spline',
-    'lineWidth': 1,
-    'states': {'hover': {'lineWidthPlus': 0}}
-  });
-
-  // Raw data
-  series.push({
-    'data': this.data_.series.raw_cool_1,
-    'name': 'indoor_cool_1_delta_raw',
-    'color': beestat.series.indoor_cool_1_delta_raw.color,
-    'dashStyle': 'ShortDot',
-    'marker': {
-      'enabled': false,
-      'states': {'hover': {'enabled': false}}
-    },
-    'type': 'spline',
-    'lineWidth': 1,
-    'states': {'hover': {'lineWidthPlus': 0}}
-  });
-
-  // Raw data
-  series.push({
-    'data': this.data_.series.raw_cool_2,
-    'name': 'indoor_cool_2_delta_raw',
-    'color': beestat.series.indoor_cool_2_delta_raw.color,
-    'dashStyle': 'ShortDot',
-    'marker': {
-      'enabled': false,
-      'states': {'hover': {'enabled': false}}
-    },
-    'type': 'spline',
-    'lineWidth': 1,
-    'states': {'hover': {'lineWidthPlus': 0}}
-  });
-
-  // Raw data
-  series.push({
-    'data': this.data_.series.raw_resist,
-    'name': 'indoor_resist_delta_raw',
-    'color': beestat.series.indoor_resist_delta_raw.color,
-    'dashStyle': 'ShortDot',
-    'marker': {
-      'enabled': false,
-      'states': {'hover': {'enabled': false}}
-    },
-    'type': 'spline',
-    'lineWidth': 1,
-    'states': {'hover': {'lineWidthPlus': 0}}
-  });
-
-  return series;
-};
-
-/**
- * Override for get_options_yAxis_.
- *
- * @return {Array} The y-axis options.
- */
-beestat.component.chart.temperature_profiles_new.prototype.get_options_yAxis_ = function() {
-  var absolute_y_max = Math.max(
-    Math.abs(this.data_.metadata.chart.y_min),
-    Math.abs(this.data_.metadata.chart.y_max)
-  );
-
-  var y_min = absolute_y_max * -1;
-  var y_max = absolute_y_max;
-
-  return [
-    {
-      'alignTicks': false,
-      'gridLineColor': beestat.style.color.bluegray.light,
-      'gridLineDashStyle': 'longdash',
-      'title': {'text': null},
-      'labels': {
-        'style': {'color': beestat.style.color.gray.base},
-        'formatter': function() {
-          return this.value + beestat.setting('temperature_unit');
-        }
-      },
-      'min': y_min,
-      'max': y_max,
-      'plotLines': [
-        {
-          'color': beestat.style.color.bluegray.light,
-          'dashStyle': 'solid',
-          'width': 3,
-          'value': 0,
-          'zIndex': 1
-        }
-      ]
-    }
-  ];
-};
-
-/**
- * Override for get_options_tooltip_formatter_.
- *
- * @return {Function} The tooltip formatter.
- */
-beestat.component.chart.temperature_profiles_new.prototype.get_options_tooltip_formatter_ = function() {
-  var self = this;
-
-  return function() {
-    var sections = [];
-    var section = [];
-    this.points.forEach(function(point) {
-      var series = point.series;
-
-      var value = beestat.temperature({
-        'temperature': point.y,
-        'units': true,
-        'convert': false,
-        'delta': true,
-        'type': 'string'
-      }) + ' / h';
-
-      if (series.name.indexOf('raw') === -1) {
-        section.push({
-          'label': beestat.series[series.name].name,
-          'value': value,
-          'color': series.color
-        });
-      }
-    });
-    sections.push(section);
-
-    return self.tooltip_formatter_helper_(
-      'Outdoor Temp: ' +
-      beestat.temperature({
-        'temperature': this.x,
-        'round': 0,
-        'units': true,
-        'convert': false
-      }),
-      sections
-    );
-  };
-};
-
-/**
- * Override for get_options_chart_zoomType_.
- *
- * @return {string} The zoom type.
- */
-beestat.component.chart.temperature_profiles_new.prototype.get_options_chart_zoomType_ = function() {
-  return null;
-};
-
-/**
- * Override for get_options_legend_.
- *
- * @return {object} The legend options.
- */
-beestat.component.chart.temperature_profiles_new.prototype.get_options_legend_ = function() {
-  return {
-    'enabled': false
-  };
-};
-
-/**
- * Override for get_options_xAxis_.
- *
- * @return {object} The xAxis options.
- */
-beestat.component.chart.temperature_profiles_new.prototype.get_options_xAxis_ = function() {
-  return {
-    'lineWidth': 0,
-    'tickLength': 0,
-    'tickInterval': 5,
-    'gridLineWidth': 1,
-    'gridLineColor': beestat.style.color.bluegray.light,
-    'gridLineDashStyle': 'longdash',
-    'labels': {
-      'style': {
-        'color': beestat.style.color.gray.base
-      },
-      'formatter': this.get_options_xAxis_labels_formatter_()
-    },
-    'crosshair': this.get_options_xAxis_crosshair_(),
-    'plotLines': [
-      {
-        'color': beestat.series.outdoor_temperature.color,
-        'dashStyle': 'ShortDash',
-        'width': 1,
-        'label': {
-          'style': {
-            'color': beestat.series.outdoor_temperature.color
-          },
-          'useHTML': true,
-          'text': 'Now: ' + beestat.temperature({
-            'temperature': this.data_.metadata.chart.outdoor_temperature,
-            'convert': false,
-            'units': true,
-            'round': 0
-          })
-        },
-        'value': this.data_.metadata.chart.outdoor_temperature,
-        'zIndex': 2
-      }
-    ]
-  };
-};
-
-/**
- * Override for get_options_chart_height_.
- *
- * @return {number} The height of the chart.
- */
-beestat.component.chart.temperature_profiles_new.prototype.get_options_chart_height_ = function() {
-  return 300;
-};
-
-/**
- * Override for get_options_plotOptions_series_connectNulls_.
- *
- * @return {boolean} Whether or not to connect nulls.
- */
-beestat.component.chart.temperature_profiles_new.prototype.get_options_plotOptions_series_connectNulls_ = function() {
-  return true;
-};
diff --git a/js/component/metric.js b/js/component/metric.js
index a762cf6..13f2944 100644
--- a/js/component/metric.js
+++ b/js/component/metric.js
@@ -6,130 +6,415 @@ beestat.component.metric = function() {
 };
 beestat.extend(beestat.component.metric, beestat.component);
 
+/**
+ * Whether or not this is a temperature value. If so, do the appropriate
+ * conversion on display.
+ *
+ * @type {boolean}
+ */
+beestat.component.metric.prototype.is_temperature_ = false;
+
+/**
+ * Whether or not this temperature value is a delta instead of an absolute
+ * value. If so, do the appropriate conversion on display.
+ *
+ * @type {boolean}
+ */
+beestat.component.metric.prototype.is_temperature_delta_ = false;
+
 /**
  * Decorate
  *
  * @param {rocket.Elements} parent
  */
 beestat.component.metric.prototype.decorate_ = function(parent) {
-  if (beestat.cache.data.metrics === undefined) { // todo
-    parent.appendChild($.createElement('div').innerText('Loading...'));
-    return;
-  }
+  const self = this;
+  const metric = this.get_metric_();
 
-  var outer_container = $.createElement('div').style({
-    'background': beestat.style.color.bluegray.dark,
-    'padding': (beestat.style.size.gutter / 2)
+  // Construct the table
+  var table = $.createElement('table').style('width', '100%');
+  table.setAttribute({
+    'cellpadding': '0',
+    'cellspacing': '0'
   });
+  parent.appendChild(table);
 
-  outer_container.appendChild(
-    $.createElement('div').innerText(this.get_title_())
+  var tr = $.createElement('tr');
+  table.appendChild(tr);
+  var td_icon = $.createElement('td')
+    .style({
+      'width': '36px',
+      'text-align': 'center',
+      'background': this.get_color_()
+    });
+  tr.appendChild(td_icon);
+
+  var td_title = $.createElement('td')
+    .style({
+      'width': '100px',
+      'background': this.get_color_(),
+      'color': '#fff'
+    });
+  tr.appendChild(td_title);
+
+  var td_chart = $.createElement('td')
+    .style({
+      'text-align': 'right'
+    });
+  tr.appendChild(td_chart);
+
+  // Fill in the content.
+  (new beestat.component.icon(this.get_icon_()))
+    .set_color('#fff')
+    .render(td_icon);
+
+  td_title.appendChild($.createElement('div').innerText(this.get_title_() + ' '));
+
+  td_title.appendChild(
+    $.createElement('div')
+      .innerText(this.get_histogram_sum_().toLocaleString() + ' others')
   );
 
-  var inner_container = $.createElement('div').style({
+  var chart_container = $.createElement('div').style({
     'position': 'relative',
-    'margin-top': '50px',
-    'margin-bottom': '20px',
-    'margin-left': '25px'
+    'height': '60px'
   });
+  td_chart.appendChild(chart_container);
 
-  var icon = $.createElement('div').style({
-    'position': 'absolute',
-    'top': '-12px',
-    'left': '-28px'
-  });
-
-  (new beestat.component.icon(this.get_icon_()))
-    .set_color(this.get_color_())
-    .render(icon);
-
-  var line = $.createElement('div').style({
-    'background': this.get_color_(),
-    'height': '5px',
-    'border-radius': '5px'
-  });
-
-  var min = $.createElement('div')
-    .innerText(this.get_min_(true))
-    .style({
-      'position': 'absolute',
-      'top': '10px',
-      'left': '0px'
-    });
-
-  var max = $.createElement('div')
-    .innerText(this.get_max_(true))
-    .style({
-      'position': 'absolute',
-      'top': '10px',
-      'right': '0px'
-    });
-
-  var label = $.createElement('div')
-    .innerText(this.get_value_())
-    .style({
-      'position': 'absolute',
-      'top': '-25px',
-      'left': this.get_marker_position_() + '%',
-      'width': '100px',
-      'text-align': 'center',
-      'margin-left': '-50px',
-      'font-weight': beestat.style.font_weight.bold
-    });
-
-  var circle = $.createElement('div').style({
-    'background': this.get_color_(),
-    'position': 'absolute',
-    'top': '-4px',
-    'left': this.get_marker_position_() + '%',
-    'margin-left': '-7px',
-    'width': '14px',
-    'height': '14px',
-    'border-radius': '50%'
-  });
-
-  var chart = $.createElement('div').style({
-    'position': 'absolute',
-    'top': '-40px',
-    'left': '0px',
-    'width': '100%',
-    'height': '40px'
+  var formatter = this.get_formatter_();
+
+  const chart_height = 60;
+  const chart_padding = 20;
+  const chart = $.createElement('div').style({
+    'height': chart_height + 'px',
+    'padding-top': chart_padding + 'px',
+    'position': 'relative'
   });
+  chart_container.appendChild(chart);
 
   var histogram = this.get_histogram_();
-  var histogram_max = this.get_histogram_max_();
-  var column_width = (100 / histogram.length) + '%';
-  histogram.forEach(function(data) {
-    var column = $.createElement('div').style({
+  var histogram_mode = this.get_histogram_mode_();
+  var column_width = (100 / histogram.length);
+
+  let my_column_index;
+  let my_column_height;
+  let sum_less = 0;
+  let sum_more = 0;
+  histogram.forEach(function(data, i) {
+    const height = (data.count / histogram_mode * 100);
+    const column = $.createElement('div').style({
       'display': 'inline-block',
-      'background': 'rgba(255, 255, 255, 0.1)',
-      'width': column_width,
-      'height': (data.count / histogram_max * 100) + '%'
+      'width': column_width + '%',
+      'height': height + '%'
     });
+
+    const value = self.get_value_();
+    if (
+      value >= data.value &&
+      value < data.value + metric.interval
+    ) {
+      column.style({
+        'background': '#516169',
+        'position': 'relative',
+
+        // Makes it visible even if it's 0px high.
+        'border-bottom': '2px solid #516169'
+      });
+      my_column_index = i;
+      my_column_height = height;
+    } else {
+      if (my_column_index === undefined) {
+        sum_less += data.count;
+      } else {
+        sum_more += data.count;
+      }
+      column.style({
+        'background': beestat.style.color.bluegray.light,
+        'border-bottom': '2px solid ' + beestat.style.color.bluegray.light
+      });
+    }
+
     chart.appendChild(column);
   });
 
-  inner_container.appendChild(icon);
-  inner_container.appendChild(line);
-  inner_container.appendChild(min);
-  inner_container.appendChild(max);
-  inner_container.appendChild(label);
-  inner_container.appendChild(circle);
-  inner_container.appendChild(chart);
+  const label_height = 16;
+  const label_bottom = Math.max(20, ((chart_height - chart_padding) * my_column_height / 100));
+  var label = $.createElement('div')
+    .innerText(formatter(this.get_value_(), this.get_precision_()))
+    .style({
+      'position': 'absolute',
+      'bottom': label_bottom + 'px',
+      'left': Math.min(85, ((my_column_index * column_width) + (column_width / 2))) + '%',
+      'width': '60px',
+      'height': label_height + 'px',
+      'line-height': label_height + 'px',
+      'margin-left': '-30px',
+      'text-align': 'center',
+      'font-weight': beestat.style.font_weight.bold,
+      'text-shadow': '1px 1px 1px rgba(0, 0, 0, 0.5)'
+    });
+  chart.appendChild(label);
 
-  outer_container.appendChild(inner_container);
+  // Min & Max
+  chart.appendChild(
+    $.createElement('div')
+      .style({
+        'position': 'absolute',
+        'left': '4px',
+        'bottom': '2px',
+        'color': 'rgba(255, 255, 255, 0.5)',
+        'font-size': '11px'
+      })
+      .innerText(formatter(this.get_min_(), this.get_precision_()))
+  );
+  chart.appendChild(
+    $.createElement('div')
+      .style({
+        'position': 'absolute',
+        'right': '4px',
+        'bottom': '2px',
+        'color': 'rgba(255, 255, 255, 0.5)',
+        'font-size': '11px'
+      })
+      .innerText(formatter(this.get_max_(), this.get_precision_()))
+  );
 
-  parent.appendChild(outer_container);
+  // Greater or less than % label
+  const percentage_label = $.createElement('div');
+
+  let percentage;
+  let symbol;
+  if (sum_less >= sum_more) {
+    symbol = '>';
+    percentage = sum_less / this.get_histogram_sum_();
+  } else {
+    symbol = '<';
+    percentage = sum_more / this.get_histogram_sum_();
+  }
+
+  percentage_label.innerText(
+    symbol + ' ' + (percentage * 100).toFixed(0) + '% homes'
+  );
+  td_title.appendChild(percentage_label);
 };
 
-beestat.component.metric.prototype.get_marker_position_ = function() {
-  return 100 * (this.get_value_() - this.get_min_()) / (this.get_max_() - this.get_min_());
-};
-
-beestat.component.metric.prototype.get_histogram_max_ = function() {
-  var max = -Infinity;
+/**
+ * Get the largest histogram count.
+ *
+ * @return {number} The largest histogram count.
+ */
+beestat.component.metric.prototype.get_histogram_mode_ = function() {
+  let mode = -Infinity;
   this.get_histogram_().forEach(function(data) {
-    max = Math.max(max, data.count);
+    mode = Math.max(mode, data.count);
   });
+  return mode;
+};
+
+/**
+ * Get the sum of the histogram counts.
+ *
+ * @return {number} The sum of the histogram counts.
+ */
+beestat.component.metric.prototype.get_histogram_sum_ = function() {
+  let sum = 0;
+  this.get_histogram_().forEach(function(data) {
+    sum += data.count;
+  });
+  return sum;
+};
+
+/**
+ * Get the unit string to append to the end of the value.
+ *
+ * @return {mixed} The unit string.
+ */
+beestat.component.metric.prototype.get_units_ = function() {
+  return '';
+};
+
+/**
+ * Get the a formatter function that applies a transformation to the value.
+ *
+ * @return {mixed} A function that formats the string.
+ */
+beestat.component.metric.prototype.get_formatter_ = function() {
+  var self = this;
+
+  return function(value) {
+    let return_value = value;
+    if (self.is_temperature_ === true) {
+      return_value = beestat.temperature({
+        'temperature': value,
+        'delta': self.is_temperature_delta_
+      });
+    }
+    return return_value.toFixed(self.get_precision_()) + self.get_units_();
+  };
+};
+
+/**
+ * Get the minimum value of this metric (within two standard deviations). Then
+ * make it go to the min of that and the actual value.
+ *
+ * @return {mixed} The minimum value of this metric.
+ */
+beestat.component.metric.prototype.get_min_ = function() {
+  const metric = this.get_metric_();
+  const cutoff_min = this.get_cutoff_min_();
+
+  // Median minus 2 * standard deviation
+  let min = (metric.median - (metric.standard_deviation * 2));
+
+  // If lower than the cutoff, place at the cutoff
+  if (cutoff_min !== null) {
+    min = Math.max(min, cutoff_min);
+  }
+
+  // Unless the thermostat value is lower than the cutoff, then go there
+  min = Math.min(min, this.get_value_());
+
+  // Round down to the nearest interval
+  min = this.round_(min, 'floor');
+
+  return min;
+};
+
+/**
+ * Get the maximum value of this metric (within two standard deviations). Then
+ * make it go to the max of that and the actual value.
+ *
+ * @return {mixed} The maximum value of this metric.
+ */
+beestat.component.metric.prototype.get_max_ = function() {
+  const metric = this.get_metric_();
+  const cutoff_max = this.get_cutoff_max_();
+
+  // Median plus 2 * standard deviation
+  let max = (metric.median + (metric.standard_deviation * 2));
+
+  // If higher than the cutoff, place at the cutoff
+  if (cutoff_max !== null) {
+    max = Math.min(max, cutoff_max);
+  }
+
+  // Unless the thermostat value is higher than the cutoff, then go there
+  max = Math.max(max, this.get_value_());
+
+  // Round up to the nearest interval
+  max = this.round_(max, 'ceil');
+
   return max;
 };
+
+/**
+ * Get max cutoff. This is used to set the chart min to max(median - 2 *
+ * stddev, max cutoff).
+ *
+ * @return {object} The cutoff value.
+ */
+beestat.component.metric.prototype.get_cutoff_min_ = function() {
+  return null;
+};
+
+/**
+ * Get max cutoff. This is used to set the chart max to min(median + 2 *
+ * stddev, max cutoff).
+ *
+ * @return {object} The cutoff value.
+ */
+beestat.component.metric.prototype.get_cutoff_max_ = function() {
+  return null;
+};
+
+/**
+ * Get the value of this metric.
+ *
+ * @return {mixed} The value of this metric.
+ */
+beestat.component.metric.prototype.get_value_ = function() {
+  const thermostat = beestat.cache.thermostat[this.thermostat_id_];
+
+  if (this.child_metric_name_ !== undefined) {
+    return this.round_(
+      thermostat.profile[this.parent_metric_name_][this.child_metric_name_]
+    );
+  }
+
+  return this.round_(thermostat.profile[this.parent_metric_name_]);
+};
+
+/**
+ * Get the actual metric object as returned from thermostat->get_metrics().
+ *
+ * @return {array} THe metric object.
+ */
+beestat.component.metric.prototype.get_metric_ = function() {
+  if (this.child_metric_name_ !== undefined) {
+    return beestat.cache.data.metrics[this.parent_metric_name_][this.child_metric_name_];
+  }
+
+  return beestat.cache.data.metrics[this.parent_metric_name_];
+};
+
+/**
+ * Take the histogram returned from the API, fill in missing values, and
+ * remove anything outside the min and max.
+ *
+ * @return {array} Histogram data.
+ */
+beestat.component.metric.prototype.get_histogram_ = function() {
+  const metric = this.get_metric_();
+
+  const min = this.get_min_();
+  const max = this.get_max_();
+
+  const my_value = this.get_value_();
+
+  var histogram = [];
+  for (let value = min; value <= max; value += metric.interval) {
+    let count = metric.histogram[value.toFixed(this.get_precision_())] || 0;
+
+    // The API call does not include me in the histogram; add it here.
+    if (value.toFixed(this.get_precision_()) === my_value.toFixed(this.get_precision_())) {
+      count++;
+    }
+
+    histogram.push({
+      'value': value,
+      'count': count
+    });
+  }
+
+  return histogram;
+};
+
+/**
+ * Based on the interval, get the precision.
+ *
+ * @return {number} The precision.
+ */
+beestat.component.metric.prototype.get_precision_ = function() {
+  const metric = this.get_metric_();
+  if (Math.floor(metric.interval) === metric.interval) {
+    return 0;
+  }
+  return metric.interval.toString().split('.')[1].length || 0;
+};
+
+/**
+ * Round a number to the precision that this metric supports. Useful, for
+ * example, because the profile is sometimes a higher precision than the
+ * metric uses for display purposes.
+ *
+ * @param {number} value The value to round.
+ * @param {string} mode The math function to use when rounding. Default round,
+ * can also choose floor or ceil.
+ *
+ * @return {number} The rounded value.
+ */
+beestat.component.metric.prototype.round_ = function(value, mode) {
+  const metric = this.get_metric_();
+  const math_function = (mode === undefined) ? 'round' : mode;
+  return Math[math_function](value / metric.interval) * metric.interval;
+};
diff --git a/js/component/metric/balance_point.js b/js/component/metric/balance_point.js
new file mode 100644
index 0000000..50ac463
--- /dev/null
+++ b/js/component/metric/balance_point.js
@@ -0,0 +1,43 @@
+/**
+ * Balance point metric.
+ *
+ * @param {number} thermostat_id The thermostat.
+ */
+beestat.component.metric.balance_point = function(thermostat_id) {
+  this.thermostat_id_ = thermostat_id;
+
+  beestat.component.metric.apply(this, arguments);
+};
+beestat.extend(beestat.component.metric.balance_point, beestat.component.metric);
+
+beestat.component.metric.balance_point.prototype.parent_metric_name_ = 'balance_point';
+
+beestat.component.metric.balance_point.prototype.is_temperature_ = true;
+
+/**
+ * Get the units for this metric.
+ *
+ * @return {string} The units for this metric.
+ */
+beestat.component.metric.balance_point.prototype.get_units_ = function() {
+  return beestat.setting('temperature_unit');
+};
+
+/**
+ * Get the title of this metric.
+ *
+ * @return {string} The title of this metric.
+ */
+beestat.component.metric.balance_point.prototype.get_title_ = function() {
+  return beestat.series['compressor_' + this.child_metric_name_].name;
+};
+
+/**
+ * Get the color of this metric.
+ *
+ * @return {string} The color of this metric.
+ */
+beestat.component.metric.balance_point.prototype.get_color_ = function() {
+  return beestat.series['compressor_' + this.child_metric_name_].color;
+};
+
diff --git a/js/component/metric/balance_point/heat_1.js b/js/component/metric/balance_point/heat_1.js
new file mode 100644
index 0000000..3be24d4
--- /dev/null
+++ b/js/component/metric/balance_point/heat_1.js
@@ -0,0 +1,22 @@
+/**
+ * Balance Point for Heat Stage 1
+ *
+ * @param {number} thermostat_id The thermostat ID.
+ */
+beestat.component.metric.balance_point.heat_1 = function(thermostat_id) {
+  this.thermostat_id_ = thermostat_id;
+
+  beestat.component.metric.balance_point.apply(this, arguments);
+};
+beestat.extend(beestat.component.metric.balance_point.heat_1, beestat.component.metric.balance_point);
+
+beestat.component.metric.balance_point.heat_1.prototype.child_metric_name_ = 'heat_1';
+
+/**
+ * Get the icon of this metric.
+ *
+ * @return {string} The icon of this metric.
+ */
+beestat.component.metric.balance_point.heat_1.prototype.get_icon_ = function() {
+  return 'fire';
+};
diff --git a/js/component/metric/balance_point/heat_2.js b/js/component/metric/balance_point/heat_2.js
new file mode 100644
index 0000000..b35ccc2
--- /dev/null
+++ b/js/component/metric/balance_point/heat_2.js
@@ -0,0 +1,22 @@
+/**
+ * Balance Point for Heat Stage 2
+ *
+ * @param {number} thermostat_id The thermostat ID.
+ */
+beestat.component.metric.balance_point.heat_2 = function(thermostat_id) {
+  this.thermostat_group_id_ = thermostat_id;
+
+  beestat.component.metric.balance_point.apply(this, arguments);
+};
+beestat.extend(beestat.component.metric.balance_point.heat_2, beestat.component.metric.balance_point);
+
+beestat.component.metric.balance_point.heat_2.prototype.child_metric_name_ = 'heat_2';
+
+/**
+ * Get the icon of this metric.
+ *
+ * @return {string} The icon of this metric.
+ */
+beestat.component.metric.balance_point.heat_2.prototype.get_icon_ = function() {
+  return 'fire';
+};
diff --git a/js/component/metric/balance_point/resist.js b/js/component/metric/balance_point/resist.js
new file mode 100644
index 0000000..fe5199f
--- /dev/null
+++ b/js/component/metric/balance_point/resist.js
@@ -0,0 +1,40 @@
+/**
+ * Balance Point for Resist
+ *
+ * @param {number} thermostat_id The thermostat ID.
+ */
+beestat.component.metric.balance_point.resist = function(thermostat_id) {
+  this.thermostat_group_id_ = thermostat_id;
+
+  beestat.component.metric.balance_point.apply(this, arguments);
+};
+beestat.extend(beestat.component.metric.balance_point.resist, beestat.component.metric.balance_point);
+
+beestat.component.metric.balance_point.resist.prototype.child_metric_name_ = 'resist';
+
+/**
+ * Get the icon of this metric.
+ *
+ * @return {string} The icon of this metric.
+ */
+beestat.component.metric.balance_point.resist.prototype.get_icon_ = function() {
+  return 'resistor';
+};
+
+/**
+ * Get the title of this metric.
+ *
+ * @return {string} The title of this metric.
+ */
+beestat.component.metric.balance_point.resist.prototype.get_title_ = function() {
+  return 'Resist';
+};
+
+/**
+ * Get the color of this metric.
+ *
+ * @return {string} The color of this metric.
+ */
+beestat.component.metric.balance_point.resist.prototype.get_color_ = function() {
+  return beestat.style.color.gray.dark;
+};
diff --git a/js/component/metric/property.js b/js/component/metric/property.js
new file mode 100644
index 0000000..c49c249
--- /dev/null
+++ b/js/component/metric/property.js
@@ -0,0 +1,32 @@
+/**
+ * Property metric.
+ *
+ * @param {number} thermostat_id The thermostat.
+ */
+beestat.component.metric.property = function(thermostat_id) {
+  this.thermostat_id_ = thermostat_id;
+
+  beestat.component.metric.apply(this, arguments);
+};
+beestat.extend(beestat.component.metric.property, beestat.component.metric);
+
+beestat.component.metric.property.prototype.parent_metric_name_ = 'property';
+
+/**
+ * Get the title of this metric.
+ *
+ * @return {string} The title of this metric.
+ */
+beestat.component.metric.property.prototype.get_title_ = function() {
+  return this.child_metric_name_.charAt(0).toUpperCase() + this.child_metric_name_.slice(1);
+};
+
+/**
+ * Get the color of this metric.
+ *
+ * @return {string} The color of this metric.
+ */
+beestat.component.metric.property.prototype.get_color_ = function() {
+  return beestat.style.color.purple.base;
+};
+
diff --git a/js/component/metric/property/age.js b/js/component/metric/property/age.js
new file mode 100644
index 0000000..cea78c7
--- /dev/null
+++ b/js/component/metric/property/age.js
@@ -0,0 +1,50 @@
+/**
+ * Property age metric.
+ *
+ * @param {number} thermostat_id The thermostat.
+ */
+beestat.component.metric.property.age = function(thermostat_id) {
+  this.thermostat_id_ = thermostat_id;
+
+  beestat.component.metric.apply(this, arguments);
+};
+beestat.extend(beestat.component.metric.property.age, beestat.component.metric.property);
+
+beestat.component.metric.property.age.prototype.child_metric_name_ = 'age';
+
+/**
+ * Get the units for this metric.
+ *
+ * @return {string} The units for this metric.
+ */
+beestat.component.metric.property.age.prototype.get_units_ = function() {
+  return 'y';
+};
+
+/**
+ * Get the title of this metric.
+ *
+ * @return {string} The title of this metric.
+ */
+beestat.component.metric.property.age.prototype.get_title_ = function() {
+  return 'Age';
+};
+
+/**
+ * Get the icon of this metric.
+ *
+ * @return {string} The icon of this metric.
+ */
+beestat.component.metric.property.age.prototype.get_icon_ = function() {
+  return 'clock_outline';
+};
+
+/**
+ * Get max cutoff. This is used to set the chart min to max(median - 2 *
+ * stddev, max cutoff).
+ *
+ * @return {object} The cutoff value.
+ */
+beestat.component.metric.property.age.prototype.get_cutoff_min_ = function() {
+  return 0;
+};
diff --git a/js/component/metric/property/square_feet.js b/js/component/metric/property/square_feet.js
new file mode 100644
index 0000000..73a40dd
--- /dev/null
+++ b/js/component/metric/property/square_feet.js
@@ -0,0 +1,72 @@
+/**
+ * Property square feet metric.
+ *
+ * @param {number} thermostat_id The thermostat.
+ */
+beestat.component.metric.property.square_feet = function(thermostat_id) {
+  this.thermostat_id_ = thermostat_id;
+
+  beestat.component.metric.apply(this, arguments);
+};
+beestat.extend(beestat.component.metric.property.square_feet, beestat.component.metric.property);
+
+beestat.component.metric.property.square_feet.prototype.child_metric_name_ = 'square_feet';
+
+/**
+ * Get the units for this metric.
+ *
+ * @return {string} The units for this metric.
+ */
+beestat.component.metric.property.square_feet.prototype.get_units_ = function() {
+  return 'ft²';
+};
+
+/**
+ * Get the a formatter function that applies a transformation to the value.
+ *
+ * @return {mixed} A function that formats the string.
+ */
+beestat.component.metric.property.square_feet.prototype.get_formatter_ = function() {
+  var self = this;
+
+  return function(value, precision) {
+    return value.toLocaleString() + self.get_units_();
+  };
+};
+
+/**
+ * Get the title of this metric.
+ *
+ * @return {string} The title of this metric.
+ */
+beestat.component.metric.property.square_feet.prototype.get_title_ = function() {
+  return 'Square Feet';
+};
+
+/**
+ * Get the icon of this metric.
+ *
+ * @return {string} The icon of this metric.
+ */
+beestat.component.metric.property.square_feet.prototype.get_icon_ = function() {
+  return 'view_quilt';
+};
+
+/**
+ * Get max cutoff. This is used to set the chart min to max(median - 2 *
+ * stddev, max cutoff).
+ *
+ * @return {object} The cutoff value.
+ */
+beestat.component.metric.property.square_feet.prototype.get_cutoff_min_ = function() {
+  return 500;
+};
+
+/**
+ * Get the counting interval for the histogram.
+ *
+ * @return {number} The interval.
+ */
+beestat.component.metric.property.square_feet.prototype.get_interval_ = function() {
+  return 500;
+};
diff --git a/js/component/metric/runtime_per_degree_day.js b/js/component/metric/runtime_per_degree_day.js
new file mode 100644
index 0000000..02f0fd5
--- /dev/null
+++ b/js/component/metric/runtime_per_degree_day.js
@@ -0,0 +1,50 @@
+/**
+ * Runtime per heating degree day metric.
+ *
+ * @param {number} thermostat_id The thermostat group.
+ */
+beestat.component.metric.runtime_per_degree_day = function(thermostat_id) {
+  this.thermostat_id_ = thermostat_id;
+
+  beestat.component.metric.apply(this, arguments);
+};
+beestat.extend(beestat.component.metric.runtime_per_degree_day, beestat.component.metric);
+
+beestat.component.metric.runtime_per_degree_day.prototype.parent_metric_name_ = 'runtime_per_degree_day';
+
+/**
+ * Get the units for this metric.
+ *
+ * @return {string} The units for this metric.
+ */
+beestat.component.metric.runtime_per_degree_day.prototype.get_units_ = function() {
+  return 'm';
+};
+
+/**
+ * Get the title of this metric.
+ *
+ * @return {string} The title of this metric.
+ */
+beestat.component.metric.runtime_per_degree_day.prototype.get_title_ = function() {
+  return beestat.series['compressor_' + this.child_metric_name_].name;
+};
+
+/**
+ * Get the color of this metric.
+ *
+ * @return {string} The color of this metric.
+ */
+beestat.component.metric.runtime_per_degree_day.prototype.get_color_ = function() {
+  return beestat.series['compressor_' + this.child_metric_name_].color;
+};
+
+/**
+ * Get max cutoff. This is used to set the chart min to max(median - 2 *
+ * stddev, max cutoff).
+ *
+ * @return {object} The cutoff value.
+ */
+beestat.component.metric.runtime_per_degree_day.prototype.get_cutoff_min_ = function() {
+  return 0;
+};
diff --git a/js/component/metric/runtime_per_degree_day/cool_1.js b/js/component/metric/runtime_per_degree_day/cool_1.js
new file mode 100644
index 0000000..fc8367b
--- /dev/null
+++ b/js/component/metric/runtime_per_degree_day/cool_1.js
@@ -0,0 +1,22 @@
+/**
+ * Runtime / CDD for Cool Stage 1
+ *
+ * @param {number} thermostat_id The thermostat ID.
+ */
+beestat.component.metric.runtime_per_degree_day.cool_1 = function(thermostat_id) {
+  this.thermostat_group_id_ = thermostat_id;
+
+  beestat.component.metric.runtime_per_degree_day.apply(this, arguments);
+};
+beestat.extend(beestat.component.metric.runtime_per_degree_day.cool_1, beestat.component.metric.runtime_per_degree_day);
+
+beestat.component.metric.runtime_per_degree_day.cool_1.prototype.child_metric_name_ = 'cool_1';
+
+/**
+ * Get the icon of this metric.
+ *
+ * @return {string} The icon of this metric.
+ */
+beestat.component.metric.runtime_per_degree_day.cool_1.prototype.get_icon_ = function() {
+  return 'snowflake';
+};
diff --git a/js/component/metric/runtime_per_degree_day/cool_2.js b/js/component/metric/runtime_per_degree_day/cool_2.js
new file mode 100644
index 0000000..8c8e298
--- /dev/null
+++ b/js/component/metric/runtime_per_degree_day/cool_2.js
@@ -0,0 +1,22 @@
+/**
+ * Runtime / CDD for Cool Stage 2
+ *
+ * @param {number} thermostat_id The thermostat ID.
+ */
+beestat.component.metric.runtime_per_degree_day.cool_2 = function(thermostat_id) {
+  this.thermostat_group_id_ = thermostat_id;
+
+  beestat.component.metric.runtime_per_degree_day.apply(this, arguments);
+};
+beestat.extend(beestat.component.metric.runtime_per_degree_day.cool_2, beestat.component.metric.runtime_per_degree_day);
+
+beestat.component.metric.runtime_per_degree_day.cool_2.prototype.child_metric_name_ = 'cool_2';
+
+/**
+ * Get the icon of this metric.
+ *
+ * @return {string} The icon of this metric.
+ */
+beestat.component.metric.runtime_per_degree_day.cool_2.prototype.get_icon_ = function() {
+  return 'snowflake';
+};
diff --git a/js/component/metric/runtime_per_degree_day/heat_1.js b/js/component/metric/runtime_per_degree_day/heat_1.js
new file mode 100644
index 0000000..2159795
--- /dev/null
+++ b/js/component/metric/runtime_per_degree_day/heat_1.js
@@ -0,0 +1,22 @@
+/**
+ * Runtime / HDD for Heat Stage 1
+ *
+ * @param {number} thermostat_id The thermostat ID.
+ */
+beestat.component.metric.runtime_per_degree_day.heat_1 = function(thermostat_id) {
+  this.thermostat_id_ = thermostat_id;
+
+  beestat.component.metric.runtime_per_degree_day.apply(this, arguments);
+};
+beestat.extend(beestat.component.metric.runtime_per_degree_day.heat_1, beestat.component.metric.runtime_per_degree_day);
+
+beestat.component.metric.runtime_per_degree_day.heat_1.prototype.child_metric_name_ = 'heat_1';
+
+/**
+ * Get the icon of this metric.
+ *
+ * @return {string} The icon of this metric.
+ */
+beestat.component.metric.runtime_per_degree_day.heat_1.prototype.get_icon_ = function() {
+  return 'fire';
+};
diff --git a/js/component/metric/runtime_per_degree_day/heat_2.js b/js/component/metric/runtime_per_degree_day/heat_2.js
new file mode 100644
index 0000000..a616089
--- /dev/null
+++ b/js/component/metric/runtime_per_degree_day/heat_2.js
@@ -0,0 +1,22 @@
+/**
+ * Runtime / HDD for Heat Stage 2
+ *
+ * @param {number} thermostat_id The thermostat ID.
+ */
+beestat.component.metric.runtime_per_degree_day.heat_2 = function(thermostat_id) {
+  this.thermostat_group_id_ = thermostat_id;
+
+  beestat.component.metric.runtime_per_degree_day.apply(this, arguments);
+};
+beestat.extend(beestat.component.metric.runtime_per_degree_day.heat_2, beestat.component.metric.runtime_per_degree_day);
+
+beestat.component.metric.runtime_per_degree_day.heat_2.prototype.child_metric_name_ = 'heat_2';
+
+/**
+ * Get the icon of this metric.
+ *
+ * @return {string} The icon of this metric.
+ */
+beestat.component.metric.runtime_per_degree_day.heat_2.prototype.get_icon_ = function() {
+  return 'fire';
+};
diff --git a/js/component/metric/runtime_per_heating_degree_day.js b/js/component/metric/runtime_per_heating_degree_day.js
deleted file mode 100644
index 09635e1..0000000
--- a/js/component/metric/runtime_per_heating_degree_day.js
+++ /dev/null
@@ -1,98 +0,0 @@
-/**
- * Runtime per heating degree day metric.
- *
- * @param {number} thermostat_group_id The thermostat group.
- */
-beestat.component.metric.runtime_per_heating_degree_day = function(thermostat_group_id) {
-  this.thermostat_group_id_ = thermostat_group_id;
-
-  beestat.component.metric.apply(this, arguments);
-};
-beestat.extend(beestat.component.metric.runtime_per_heating_degree_day, beestat.component.metric);
-
-beestat.component.metric.runtime_per_heating_degree_day.prototype.rerender_on_breakpoint_ = false;
-
-/**
- * Get the title of this metric.
- *
- * @return {string} The title of this metric.
- */
-beestat.component.metric.runtime_per_heating_degree_day.prototype.get_title_ = function() {
-  return 'Runtime / HDD';
-};
-
-/**
- * Get the icon of this metric.
- *
- * @return {string} The icon of this metric.
- */
-beestat.component.metric.runtime_per_heating_degree_day.prototype.get_icon_ = function() {
-  return 'fire';
-};
-
-/**
- * Get the color of this metric.
- *
- * @return {string} The color of this metric.
- */
-beestat.component.metric.runtime_per_heating_degree_day.prototype.get_color_ = function() {
-  return beestat.series.compressor_heat_1.color;
-};
-
-/**
- * Get the minimum value of this metric (within two standard deviations).
- *
- * @return {mixed} The minimum value of this metric.
- */
-beestat.component.metric.runtime_per_heating_degree_day.prototype.get_min_ = function() {
-  var standard_deviation =
-    beestat.cache.data.metrics.runtime_per_heating_degree_day.standard_deviation;
-  return (beestat.cache.data.metrics.runtime_per_heating_degree_day.median - (standard_deviation * 2)).toFixed(1);
-};
-
-/**
- * Get the maximum value of this metric (within two standard deviations).
- *
- * @return {mixed} The maximum value of this metric.
- */
-beestat.component.metric.runtime_per_heating_degree_day.prototype.get_max_ = function() {
-  var standard_deviation =
-    beestat.cache.data.metrics.runtime_per_heating_degree_day.standard_deviation;
-  return (beestat.cache.data.metrics.runtime_per_heating_degree_day.median + (standard_deviation * 2)).toFixed(1);
-};
-
-/**
- * Get the value of this metric.
- *
- * @return {mixed} The value of this metric.
- */
-beestat.component.metric.runtime_per_heating_degree_day.prototype.get_value_ = function() {
-  var thermostat_group = beestat.cache.thermostat_group[
-    this.thermostat_group_id_
-  ];
-  // todo: store this explicitly on the profile so it doesn't have to be calculated in JS?
-  return (thermostat_group.profile.runtime.heat_1 /
-    thermostat_group.profile.degree_days.heat).toFixed(1);
-};
-
-/**
- * Get a histogram between the min and max values of this metric.
- *
- * @return {array} The histogram.
- */
-beestat.component.metric.runtime_per_heating_degree_day.prototype.get_histogram_ = function() {
-  var histogram = [];
-  for (var value in beestat.cache.data.metrics.runtime_per_heating_degree_day.histogram) {
-    if (
-      value >= this.get_min_() &&
-      value <= this.get_max_()
-    ) {
-      var count = beestat.cache.data.metrics.runtime_per_heating_degree_day.histogram[value];
-      histogram.push({
-        'value': value,
-        'count': count
-      });
-    }
-  }
-  return histogram;
-};
diff --git a/js/component/metric/setback.js b/js/component/metric/setback.js
new file mode 100644
index 0000000..8be0c99
--- /dev/null
+++ b/js/component/metric/setback.js
@@ -0,0 +1,54 @@
+/**
+ * Setback metric.
+ *
+ * @param {number} thermostat_id The thermostat.
+ */
+beestat.component.metric.setback = function(thermostat_id) {
+  this.thermostat_id_ = thermostat_id;
+
+  beestat.component.metric.apply(this, arguments);
+};
+beestat.extend(beestat.component.metric.setback, beestat.component.metric);
+
+beestat.component.metric.setback.prototype.parent_metric_name_ = 'setback';
+
+beestat.component.metric.setback.prototype.is_temperature_ = true;
+
+beestat.component.metric.setback.prototype.is_temperature_delta_ = true;
+
+/**
+ * Get the units for this metric.
+ *
+ * @return {string} The units for this metric.
+ */
+beestat.component.metric.setback.prototype.get_units_ = function() {
+  return beestat.setting('temperature_unit');
+};
+
+/**
+ * Get the title of this metric.
+ *
+ * @return {string} The title of this metric.
+ */
+beestat.component.metric.setback.prototype.get_title_ = function() {
+  return this.child_metric_name_.charAt(0).toUpperCase() + this.child_metric_name_.slice(1);
+};
+
+/**
+ * Get the color of this metric.
+ *
+ * @return {string} The color of this metric.
+ */
+beestat.component.metric.setback.prototype.get_color_ = function() {
+  return beestat.series['compressor_' + this.child_metric_name_ + '_1'].color;
+};
+
+/**
+ * Get max cutoff. This is used to set the chart min to max(median - 2 *
+ * stddev, max cutoff).
+ *
+ * @return {object} The cutoff value.
+ */
+beestat.component.metric.setback.prototype.get_cutoff_min_ = function() {
+  return 0;
+};
diff --git a/js/component/metric/setback/cool.js b/js/component/metric/setback/cool.js
new file mode 100644
index 0000000..67b718e
--- /dev/null
+++ b/js/component/metric/setback/cool.js
@@ -0,0 +1,22 @@
+/**
+ * Cool setback metric.
+ *
+ * @param {number} thermostat_id The thermostat.
+ */
+beestat.component.metric.setback.cool = function(thermostat_id) {
+  this.thermostat_id_ = thermostat_id;
+
+  beestat.component.metric.setback.apply(this, arguments);
+};
+beestat.extend(beestat.component.metric.setback.cool, beestat.component.metric.setback);
+
+beestat.component.metric.setback.cool.prototype.child_metric_name_ = 'cool';
+
+/**
+ * Get the icon of this metric.
+ *
+ * @return {string} The icon of this metric.
+ */
+beestat.component.metric.setback.cool.prototype.get_icon_ = function() {
+  return 'snowflake';
+};
diff --git a/js/component/metric/setback/heat.js b/js/component/metric/setback/heat.js
new file mode 100644
index 0000000..78ce612
--- /dev/null
+++ b/js/component/metric/setback/heat.js
@@ -0,0 +1,22 @@
+/**
+ * Heat setback metric.
+ *
+ * @param {number} thermostat_id The thermostat.
+ */
+beestat.component.metric.setback.heat = function(thermostat_id) {
+  this.thermostat_id_ = thermostat_id;
+
+  beestat.component.metric.setback.apply(this, arguments);
+};
+beestat.extend(beestat.component.metric.setback.heat, beestat.component.metric.setback);
+
+beestat.component.metric.setback.heat.prototype.child_metric_name_ = 'heat';
+
+/**
+ * Get the icon of this metric.
+ *
+ * @return {string} The icon of this metric.
+ */
+beestat.component.metric.setback.heat.prototype.get_icon_ = function() {
+  return 'fire';
+};
diff --git a/js/component/metric/setpoint.js b/js/component/metric/setpoint.js
new file mode 100644
index 0000000..e9ec191
--- /dev/null
+++ b/js/component/metric/setpoint.js
@@ -0,0 +1,43 @@
+/**
+ * Setpoint metric.
+ *
+ * @param {number} thermostat_id The thermostat.
+ */
+beestat.component.metric.setpoint = function(thermostat_id) {
+  this.thermostat_id_ = thermostat_id;
+
+  beestat.component.metric.apply(this, arguments);
+};
+beestat.extend(beestat.component.metric.setpoint, beestat.component.metric);
+
+beestat.component.metric.setpoint.prototype.parent_metric_name_ = 'setpoint';
+
+beestat.component.metric.setpoint.prototype.is_temperature_ = true;
+
+/**
+ * Get the units for this metric.
+ *
+ * @return {string} The units for this metric.
+ */
+beestat.component.metric.setpoint.prototype.get_units_ = function() {
+  return beestat.setting('temperature_unit');
+};
+
+/**
+ * Get the title of this metric.
+ *
+ * @return {string} The title of this metric.
+ */
+beestat.component.metric.setpoint.prototype.get_title_ = function() {
+  return this.child_metric_name_.charAt(0).toUpperCase() + this.child_metric_name_.slice(1);
+};
+
+/**
+ * Get the color of this metric.
+ *
+ * @return {string} The color of this metric.
+ */
+beestat.component.metric.setpoint.prototype.get_color_ = function() {
+  return beestat.series['compressor_' + this.child_metric_name_ + '_1'].color;
+};
+
diff --git a/js/component/metric/setpoint/cool.js b/js/component/metric/setpoint/cool.js
new file mode 100644
index 0000000..eede304
--- /dev/null
+++ b/js/component/metric/setpoint/cool.js
@@ -0,0 +1,22 @@
+/**
+ * Cool setpoint metric.
+ *
+ * @param {number} thermostat_id The thermostat.
+ */
+beestat.component.metric.setpoint.cool = function(thermostat_id) {
+  this.thermostat_id_ = thermostat_id;
+
+  beestat.component.metric.setpoint.apply(this, arguments);
+};
+beestat.extend(beestat.component.metric.setpoint.cool, beestat.component.metric.setpoint);
+
+beestat.component.metric.setpoint.cool.prototype.child_metric_name_ = 'cool';
+
+/**
+ * Get the icon of this metric.
+ *
+ * @return {string} The icon of this metric.
+ */
+beestat.component.metric.setpoint.cool.prototype.get_icon_ = function() {
+  return 'snowflake';
+};
diff --git a/js/component/metric/setpoint/heat.js b/js/component/metric/setpoint/heat.js
new file mode 100644
index 0000000..f3e87f0
--- /dev/null
+++ b/js/component/metric/setpoint/heat.js
@@ -0,0 +1,22 @@
+/**
+ * Heat setpoint metric.
+ *
+ * @param {number} thermostat_id The thermostat.
+ */
+beestat.component.metric.setpoint.heat = function(thermostat_id) {
+  this.thermostat_id_ = thermostat_id;
+
+  beestat.component.metric.setpoint.apply(this, arguments);
+};
+beestat.extend(beestat.component.metric.setpoint.heat, beestat.component.metric.setpoint);
+
+beestat.component.metric.setpoint.heat.prototype.child_metric_name_ = 'heat';
+
+/**
+ * Get the icon of this metric.
+ *
+ * @return {string} The icon of this metric.
+ */
+beestat.component.metric.setpoint.heat.prototype.get_icon_ = function() {
+  return 'fire';
+};
diff --git a/js/component/metric/setpoint_cool.js b/js/component/metric/setpoint_cool.js
deleted file mode 100644
index 7495739..0000000
--- a/js/component/metric/setpoint_cool.js
+++ /dev/null
@@ -1,119 +0,0 @@
-/**
- * Cool setpoint metric.
- *
- * @param {number} thermostat_group_id The thermostat group.
- */
-beestat.component.metric.setpoint_cool = function(thermostat_group_id) {
-  this.thermostat_group_id_ = thermostat_group_id;
-
-  beestat.component.metric.apply(this, arguments);
-};
-beestat.extend(beestat.component.metric.setpoint_cool, beestat.component.metric);
-
-beestat.component.metric.setpoint_cool.prototype.rerender_on_breakpoint_ = false;
-
-/**
- * Get the title of this metric.
- *
- * @return {string} The title of this metric.
- */
-beestat.component.metric.setpoint_cool.prototype.get_title_ = function() {
-  return 'Cool Setpoint';
-};
-
-/**
- * Get the icon of this metric.
- *
- * @return {string} The icon of this metric.
- */
-beestat.component.metric.setpoint_cool.prototype.get_icon_ = function() {
-  return 'snowflake';
-};
-
-/**
- * Get the color of this metric.
- *
- * @return {string} The color of this metric.
- */
-beestat.component.metric.setpoint_cool.prototype.get_color_ = function() {
-  return beestat.series.compressor_cool_1.color;
-};
-
-/**
- * Get the minimum value of this metric (within two standard deviations).
- *
- * @param {boolean} units Whether or not to return a numerical value or a
- * string with units.
- *
- * @return {mixed} The minimum value of this metric.
- */
-beestat.component.metric.setpoint_cool.prototype.get_min_ = function(units) {
-  var standard_deviation =
-    beestat.cache.data.metrics.setpoint_cool.standard_deviation;
-  return beestat.temperature({
-    'temperature': beestat.cache.data.metrics.setpoint_cool.median - (standard_deviation * 2),
-    'round': 0,
-    'units': units
-  });
-};
-
-/**
- * Get the maximum value of this metric (within two standard deviations).
- *
- * @param {boolean} units Whether or not to return a numerical value or a
- * string with units.
- *
- * @return {mixed} The maximum value of this metric.
- */
-beestat.component.metric.setpoint_cool.prototype.get_max_ = function(units) {
-  var standard_deviation =
-    beestat.cache.data.metrics.setpoint_cool.standard_deviation;
-  return beestat.temperature({
-    'temperature': beestat.cache.data.metrics.setpoint_cool.median + (standard_deviation * 2),
-    'round': 0,
-    'units': units
-  });
-};
-
-/**
- * Get the value of this metric.
- *
- * @param {boolean} units Whether or not to return a numerical value or a
- * string with units.
- *
- * @return {mixed} The value of this metric.
- */
-beestat.component.metric.setpoint_cool.prototype.get_value_ = function(units) {
-  var thermostat_group = beestat.cache.thermostat_group[
-    this.thermostat_group_id_
-  ];
-  return beestat.temperature({
-    'temperature': thermostat_group.profile.setpoint.cool,
-    'units': units
-  });
-};
-
-/**
- * Get a histogram between the min and max values of this metric.
- *
- * @param {boolean} units Whether or not to return a numerical value or a
- * string with units.
- *
- * @return {array} The histogram.
- */
-beestat.component.metric.setpoint_cool.prototype.get_histogram_ = function(units) {
-  var histogram = [];
-  for (var temperature in beestat.cache.data.metrics.setpoint_cool.histogram) {
-    if (
-      temperature >= this.get_min_(units) &&
-      temperature <= this.get_max_(units)
-    ) {
-      var count = beestat.cache.data.metrics.setpoint_cool.histogram[temperature];
-      histogram.push({
-        'value': beestat.temperature(temperature),
-        'count': count
-      });
-    }
-  }
-  return histogram;
-};
diff --git a/js/component/metric/setpoint_heat.js b/js/component/metric/setpoint_heat.js
deleted file mode 100644
index 442f199..0000000
--- a/js/component/metric/setpoint_heat.js
+++ /dev/null
@@ -1,119 +0,0 @@
-/**
- * Heat setpoint metric.
- *
- * @param {number} thermostat_group_id The thermostat group.
- */
-beestat.component.metric.setpoint_heat = function(thermostat_group_id) {
-  this.thermostat_group_id_ = thermostat_group_id;
-
-  beestat.component.metric.apply(this, arguments);
-};
-beestat.extend(beestat.component.metric.setpoint_heat, beestat.component.metric);
-
-beestat.component.metric.setpoint_heat.prototype.rerender_on_breakpoint_ = false;
-
-/**
- * Get the title of this metric.
- *
- * @return {string} The title of this metric.
- */
-beestat.component.metric.setpoint_heat.prototype.get_title_ = function() {
-  return 'Heat Setpoint';
-};
-
-/**
- * Get the icon of this metric.
- *
- * @return {string} The icon of this metric.
- */
-beestat.component.metric.setpoint_heat.prototype.get_icon_ = function() {
-  return 'fire';
-};
-
-/**
- * Get the color of this metric.
- *
- * @return {string} The color of this metric.
- */
-beestat.component.metric.setpoint_heat.prototype.get_color_ = function() {
-  return beestat.series.compressor_heat_1.color;
-};
-
-/**
- * Get the minimum value of this metric (within two standard deviations).
- *
- * @param {boolean} units Whether or not to return a numerical value or a
- * string with units.
- *
- * @return {mixed} The minimum value of this metric.
- */
-beestat.component.metric.setpoint_heat.prototype.get_min_ = function(units) {
-  var standard_deviation =
-    beestat.cache.data.metrics.setpoint_heat.standard_deviation;
-  return beestat.temperature({
-    'temperature': beestat.cache.data.metrics.setpoint_heat.median - (standard_deviation * 2),
-    'round': 0,
-    'units': units
-  });
-};
-
-/**
- * Get the maximum value of this metric (within two standard deviations).
- *
- * @param {boolean} units Whether or not to return a numerical value or a
- * string with units.
- *
- * @return {mixed} The maximum value of this metric.
- */
-beestat.component.metric.setpoint_heat.prototype.get_max_ = function(units) {
-  var standard_deviation =
-    beestat.cache.data.metrics.setpoint_heat.standard_deviation;
-  return beestat.temperature({
-    'temperature': beestat.cache.data.metrics.setpoint_heat.median + (standard_deviation * 2),
-    'round': 0,
-    'units': units
-  });
-};
-
-/**
- * Get the value of this metric.
- *
- * @param {boolean} units Whether or not to return a numerical value or a
- * string with units.
- *
- * @return {mixed} The value of this metric.
- */
-beestat.component.metric.setpoint_heat.prototype.get_value_ = function(units) {
-  var thermostat_group = beestat.cache.thermostat_group[
-    this.thermostat_group_id_
-  ];
-  return beestat.temperature({
-    'temperature': thermostat_group.profile.setpoint.heat,
-    'units': units
-  });
-};
-
-/**
- * Get a histogram between the min and max values of this metric.
- *
- * @param {boolean} units Whether or not to return a numerical value or a
- * string with units.
- *
- * @return {array} The histogram.
- */
-beestat.component.metric.setpoint_heat.prototype.get_histogram_ = function(units) {
-  var histogram = [];
-  for (var temperature in beestat.cache.data.metrics.setpoint_heat.histogram) {
-    if (
-      temperature >= this.get_min_(units) &&
-      temperature <= this.get_max_(units)
-    ) {
-      var count = beestat.cache.data.metrics.setpoint_heat.histogram[temperature];
-      histogram.push({
-        'value': beestat.temperature(temperature),
-        'count': count
-      });
-    }
-  }
-  return histogram;
-};
diff --git a/js/component/modal.js b/js/component/modal.js
index f155481..44e1adf 100644
--- a/js/component/modal.js
+++ b/js/component/modal.js
@@ -59,7 +59,7 @@ beestat.component.modal.prototype.decorate_ = function() {
     },
     {
       '(max-width: 900px)': {
-        'max-height': 'calc(100vh - ' + (beestat.style.size.gutter * 2) + 'px)',
+        'max-height': 'calc(100vh - ' + (beestat.style.size.gutter * 2) + 'px)'
       }
     }
   );
diff --git a/js/component/modal/change_system_type.js b/js/component/modal/change_system_type.js
index 7fd3c9a..014e74b 100644
--- a/js/component/modal/change_system_type.js
+++ b/js/component/modal/change_system_type.js
@@ -1,7 +1,11 @@
 /**
  * Change system type.
+ *
+ * @param {number} thermostat_id The thermostat_id this card is displaying
+ * data for.
  */
-beestat.component.modal.change_system_type = function() {
+beestat.component.modal.change_system_type = function(thermostat_id) {
+  this.thermostat_id_ = thermostat_id;
   beestat.component.modal.apply(this, arguments);
 };
 beestat.extend(beestat.component.modal.change_system_type, beestat.component.modal);
@@ -9,12 +13,9 @@ beestat.extend(beestat.component.modal.change_system_type, beestat.component.mod
 beestat.component.modal.change_system_type.prototype.decorate_contents_ = function(parent) {
   var self = this;
 
-  var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')];
-  var thermostat_group = beestat.cache.thermostat_group[
-    thermostat.thermostat_group_id
-  ];
+  var thermostat = beestat.cache.thermostat[this.thermostat_id_];
 
-  parent.appendChild($.createElement('p').innerHTML('What type of HVAC system do you have? This applies to all thermostats in this group. System types that beestat detected are indicated.'));
+  parent.appendChild($.createElement('p').innerHTML('What type of HVAC system do you have? System types that beestat detected are indicated.'));
 
   var options = {
     'heat': [
@@ -108,7 +109,7 @@ beestat.component.modal.change_system_type.prototype.get_title_ = function() {
  * @return {[beestat.component.button]} The buttons.
  */
 beestat.component.modal.change_system_type.prototype.get_buttons_ = function() {
-  var thermostat = beestat.cache.thermostat[beestat.setting('thermostat_id')];
+  var thermostat = beestat.cache.thermostat[this.thermostat_id_];
 
   var self = this;
 
@@ -132,26 +133,42 @@ beestat.component.modal.change_system_type.prototype.get_buttons_ = function() {
         .set_background_hover_color()
         .removeEventListener('click');
 
+      // Delete from the cache to trigger the metrics loading screen
+      beestat.cache.delete('data.metrics');
+
       new beestat.api()
         .add_call(
-          'thermostat_group',
-          'update_system_types',
+          'thermostat',
+          'set_reported_system_types',
           {
-            'thermostat_group_id': thermostat.thermostat_group_id,
+            'thermostat_id': thermostat.thermostat_id,
             'system_types': self.selected_types_
           },
-          'update_system_types'
+          'set_reported_system_types'
+        )
+        .add_call(
+          'thermostat',
+          'read_id',
+          {
+            'attributes': {
+              'inactive': 0
+            }
+          },
+          'thermostat'
+        )
+        .add_call(
+          'thermostat',
+          'get_metrics',
+          {
+            'thermostat_id': self.thermostat_id_,
+            'attributes': beestat.comparisons.get_attributes()
+          },
+          'metrics'
         )
-        .add_call('thermostat', 'read_id', {}, 'thermostat')
-        .add_call('thermostat_group', 'read_id', {}, 'thermostat_group')
         .set_callback(function(response) {
           // Update the cache.
           beestat.cache.set('thermostat', response.thermostat);
-          beestat.cache.set('thermostat_group', response.thermostat_group);
-
-          // Re-run comparison scores as they are invalid for the new system
-          // type.
-          beestat.comparisons.get_comparison_scores();
+          beestat.cache.set('data.metrics', response.metrics);
 
           // Close the modal.
           self.dispose();
diff --git a/js/component/modal/change_thermostat.js b/js/component/modal/change_thermostat.js
index eed39e2..6587e41 100644
--- a/js/component/modal/change_thermostat.js
+++ b/js/component/modal/change_thermostat.js
@@ -35,13 +35,10 @@ beestat.component.modal.change_thermostat.prototype.decorate_contents_ = functio
 
 beestat.component.modal.change_thermostat.prototype.decorate_thermostat_ = function(parent, thermostat_id) {
   var thermostat = beestat.cache.thermostat[thermostat_id];
-  var ecobee_thermostat = beestat.cache.ecobee_thermostat[
-    thermostat.ecobee_thermostat_id
-  ];
 
   var container_height = 60;
   var gutter = beestat.style.size.gutter / 2;
-  var thermostat_height = container_height - (gutter * 2)
+  var thermostat_height = container_height - (gutter * 2);
 
   var container = $.createElement('div')
     .style({
@@ -52,7 +49,7 @@ beestat.component.modal.change_thermostat.prototype.decorate_thermostat_ = funct
       'user-select': 'none'
     });
 
-  if(thermostat_id == beestat.cache.thermostat[beestat.setting('thermostat_id')].thermostat_id) {
+  if (thermostat_id == beestat.cache.thermostat[beestat.setting('thermostat_id')].thermostat_id) {
     container.style({
       'background': '#4b6584',
       'color': '#fff'
diff --git a/js/js.php b/js/js.php
index 4827853..7f2ef15 100755
--- a/js/js.php
+++ b/js/js.php
@@ -36,13 +36,14 @@ if($setting->get('environment') === 'dev' || $setting->get('environment') === 'd
   echo '' . PHP_EOL;
   echo '' . PHP_EOL;
   echo '' . PHP_EOL;
+  echo '' . PHP_EOL;
 
   // Layer
   echo '' . PHP_EOL;
   echo '' . PHP_EOL;
-  echo '' . PHP_EOL;
-  echo '' . PHP_EOL;
-  echo '' . PHP_EOL;
+  echo '' . PHP_EOL;
+  echo '' . PHP_EOL;
+  echo '' . PHP_EOL;
 
   // Component
   echo '' . PHP_EOL;
@@ -51,6 +52,7 @@ if($setting->get('environment') === 'dev' || $setting->get('environment') === 'd
   echo '' . PHP_EOL;
   echo '' . PHP_EOL;
   echo '' . PHP_EOL;
+  echo '' . PHP_EOL;
   echo '' . PHP_EOL;
   echo '' . PHP_EOL;
   echo '' . PHP_EOL;
@@ -59,19 +61,13 @@ if($setting->get('environment') === 'dev' || $setting->get('environment') === 'd
   echo '' . PHP_EOL;
   echo '' . PHP_EOL;
   echo '' . PHP_EOL;
-  echo '' . PHP_EOL;
-  echo '' . PHP_EOL;
-  echo '' . PHP_EOL;
-  echo '' . PHP_EOL;
   echo '' . PHP_EOL;
   echo '' . PHP_EOL;
   echo '' . PHP_EOL;
-  echo '' . PHP_EOL;
   echo '' . PHP_EOL;
   echo '' . PHP_EOL;
   echo '' . PHP_EOL;
   echo '' . PHP_EOL;
-  echo '' . PHP_EOL;
   echo '' . PHP_EOL;
   echo '' . PHP_EOL;
   echo '' . PHP_EOL;
@@ -104,9 +100,24 @@ if($setting->get('environment') === 'dev' || $setting->get('environment') === 'd
   echo '' . PHP_EOL;
   echo '' . PHP_EOL;
   echo '' . PHP_EOL;
-  echo '' . PHP_EOL;
-  echo '' . PHP_EOL;
-  echo '' . PHP_EOL;
+  echo '' . PHP_EOL;
+  echo '' . PHP_EOL;
+  echo '' . PHP_EOL;
+  echo '' . PHP_EOL;
+  echo '' . PHP_EOL;
+  echo '' . PHP_EOL;
+  echo '' . PHP_EOL;
+  echo '' . PHP_EOL;
+  echo '' . PHP_EOL;
+  echo '' . PHP_EOL;
+  echo '' . PHP_EOL;
+  echo '' . PHP_EOL;
+  echo '' . PHP_EOL;
+  echo '' . PHP_EOL;
+  echo '' . PHP_EOL;
+  echo '' . PHP_EOL;
+  echo '' . PHP_EOL;
+  echo '' . PHP_EOL;
 }
 else {
   echo '' . PHP_EOL;
diff --git a/js/layer/load.js b/js/layer/load.js
index b2c1972..df506f3 100644
--- a/js/layer/load.js
+++ b/js/layer/load.js
@@ -66,13 +66,6 @@ beestat.layer.load.prototype.decorate_ = function(parent) {
     'thermostat'
   );
 
-  api.add_call(
-    'thermostat_group',
-    'read_id',
-    {},
-    'thermostat_group'
-  );
-
   api.add_call(
     'sensor',
     'read_id',
@@ -137,7 +130,6 @@ beestat.layer.load.prototype.decorate_ = function(parent) {
     });
 
     beestat.cache.set('thermostat', response.thermostat);
-    beestat.cache.set('thermostat_group', response.thermostat_group);
     beestat.cache.set('sensor', response.sensor);
 
     beestat.cache.set('ecobee_thermostat', response.ecobee_thermostat);