From 7f4e46f9a71760002cdfd4e782e09fd20b2ee9c6 Mon Sep 17 00:00:00 2001 From: Tom Lawson Date: Sun, 20 Nov 2016 14:49:36 -0800 Subject: [PATCH] MSA-1596: This app implements night and day venting using whole house fans and/or other ventilation equipment. It can supplement or in some climates take the place of conventional air-conditioners or heaters. It makes use of inside sensors along with current and forecast conditions from Weather Underground to accurately predict and fully automate when and how much to ventilate. Configurations typically include an indoor temperature sensor, a whole house fan, and motorized windows/skylights (though manually controlled windows with contact sensors are also an option). This app also automatically sets your thermostat to heating or cooling and turns it off during venting. A simple learning algorithm allows the app to better regulate inside temperatures over time. --- .../ventilation-guru.groovy | 1005 +++++++++++++++++ 1 file changed, 1005 insertions(+) create mode 100644 smartapps/lawsonautomation/ventilation-guru.src/ventilation-guru.groovy diff --git a/smartapps/lawsonautomation/ventilation-guru.src/ventilation-guru.groovy b/smartapps/lawsonautomation/ventilation-guru.src/ventilation-guru.groovy new file mode 100644 index 0000000..d40fe3b --- /dev/null +++ b/smartapps/lawsonautomation/ventilation-guru.src/ventilation-guru.groovy @@ -0,0 +1,1005 @@ + +/** + * Ventilation Guru + * + * Copyright 2016 Tom Lawson + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License + * for the specific language governing permissions and limitations under the License. + * + + To DO: + add air quality + fix blank activity feed entries + fix double messages on init + + */ + +definition( + name: "Ventilation Guru", + namespace: "LawsonAutomation", + author: "Tom Lawson", + description: "This app implements night and day venting using whole house fans and/or other ventilation equipment. " + + "It can supplement or in some climates take the place of conventional air-conditioners or heaters. " + + "It makes use of inside sensors along with current and forecast conditions from Weather Underground to " + + "accurately predict and fully automate when and how much to ventilate. " + + "Configurations typically include an indoor temperature sensor, a whole house fan, and motorized windows/skylights " + + "(though manually controlled windows with contact sensors are also an option). " + + "This app also automatically sets your thermostat to heating or cooling and turns it off during venting. " + + "A simple learning algorithm allows the app to better regulate inside temperatures over time.", + category: "Green Living", + iconUrl: "https://raw.githubusercontent.com/lawsonautomation/icons/master/guru-60.png", + iconX2Url: "https://raw.githubusercontent.com/lawsonautomation/icons/master/guru-120.png", + iconX3Url: "https://raw.githubusercontent.com/lawsonautomation/icons/master/guru-120.png") + +preferences { + page(name: "mainPage") +} + +def mainPage() { + state.debugMode = false + + dynamicPage(name: "mainPage", install: true, uninstall: true) { + section("Inside Sensors") { + input "myTempSensor", "capability.temperatureMeasurement", required: true, title: "Temperature Sensor" + input "myHumiditySensor", "capability.relativeHumidityMeasurement", required: false, title: "Humidity Sensor" + input "mySmokeDetectors", "capability.smokeDetector", required: false, multiple: true, title: "Smoke Detectors" + input "myThermostats", "capability.thermostat", required: false, multiple: true, title: "Thermostats", submitOnChange: true + } + section("Daytime Comfort Zone (at least 5 F or 3 C range)") { + if (state.debugMode) { + // these two lines are needed as a workaround for a simulator bug + input "myDaytimeMin", "decimal", title: "Minimum", required: true + input "myDaytimeMax", "decimal", title: "Maximum", required: true + } else if (myDaytimeMin == null && myDaytimeMax == null && myThermostats) { + // use thermostat values if you have them + def minTemp = cAdj(70) + def maxTemp = cAdj(78) + minTemp = myThermostats[0]?.currentValue("heatingSetpoint") + maxTemp = myThermostats[0]?.currentValue("coolingSetpoint") + // verify that the comfort zone is at least 5 F + if (maxTemp < minTemp + cAdj(5)) { + maxTemp = minTemp + cAdj(5) + } + input "myDaytimeMin", "decimal", title: "Minimum", required: true, submitOnChange: true, defaultValue: "${minTemp}" + input "myDaytimeMax", "decimal", title: "Maximum", required: true, submitOnChange: true, defaultValue: "${maxTemp}" + } else { + if (myDaytimeMax == null) { + input "myDaytimeMin", "decimal", title: "Minimum", required: true, submitOnChange: true + } else { + def minTemp = myDaytimeMax - cAdj(5) + input "myDaytimeMin", "decimal", title: "Minimum", required: true, submitOnChange: true, range: "-50..${minTemp}" + } + if (myDaytimeMin == null) { + input "myDaytimeMax", "decimal", title: "Maximum", required: true, submitOnChange: true + } else { + def maxTemp = myDaytimeMin + cAdj(5) + input "myDaytimeMax", "decimal", title: "Maximum", required: true, submitOnChange: true, range: "${maxTemp}..100" + } + } + } + section("Ventilation Fans") { + input "myWholeHouseFans", "capability.switch", required: false, multiple: true, title: "Whole House Fans", submitOnChange: true + input "myAtticFans", "capability.switch", required: false, multiple: true, title: "Attic Fans" + input "myGarageFans", "capability.switch", required: false, multiple: true, title: "Garage Fans" + input "myBasementFans", "capability.switch", required: false, multiple: true, title: "Basement/Crawlspace Fans" + } + section("Windows/Skylights") { + input "myWindowsSwitch", "capability.switch", required: false, multiple: true, title: "Motorized Windows/Skylights", submitOnChange: true + input "myContactSensors", "capability.contactSensor", required: false, multiple: true, title: "Window Contact Sensors" + input "myCoverings", "capability.windowShade", required: false, multiple: true, title: "Window Coverings" + } + if (myWholeHouseFans || myWindowsSwitch) { + section("Whole House Fan and Window/Skylight Operation") { + if (myWholeHouseFans && myWindowsSwitch) { + input "myWindowsNeeded", "number", title: "Number of Open Windows Needed for Whole House Fan (default is 1)", required: false + } + input "myModes", "mode", title: "Whole House Fan/Window Operating Modes (default is All)", multiple: true, required: false + input "mySuspendOperation", "enum", title: "Suspend Whole House Fan/Window Operation", options: ["Temporarily Suspend"], required: false + } + } + section([mobileOnly:true]) { + label title: "Assign a name", required: false + } + section("Users Guide") { + href(name: "href", + title: "Descriptions, Tips and Tricks", + required: false, + image: "https://raw.githubusercontent.com/lawsonautomation/icons/master/info.png", + page: "UsersGuide") + } + section("Version 1.2.1 - Copyright © 2016 Thomas Lawson. " + + "If you like this app and would like to contribute to its development (and the development of similar apps), " + + "tap the link below to make a donation.") { + href(name: "LawsonAutomation", + title: "Donate via PayPal", + description: "Tap to Donate", + required: false, + style: "external", + image: "https://raw.githubusercontent.com/lawsonautomation/icons/master/guru-60.png", + url: "http://PayPal.Me/LawsonAutomation") + } + } +} + +def UsersGuide() { + dynamicPage(name: "UsersGuide", title: "User's Guide", nextPage: "mainPage") { + section("Overview") { + paragraph "This app implements night and day venting using whole house fans and/or other ventilation equipment. " + + "It can supplement or in some climates take the place of conventional air-conditioners or heaters. " + + "It makes use of inside sensors along with current and forecast conditions from Weather Underground to " + + "accurately predict and fully automate when and how much to ventilate. " + + "Configurations typically include a whole house fan, motorized windows/skylights or window's with contact sensors, and an indoor temperature sensor. " + + "It also automatically sets your thermostat to heating or cooling and turns it off during venting. " + + "A simple learning algorithm allows the app to better regulate inside temperatures over time." + } + section("Daytime Comfort Zone Minimum and Maximum") { + paragraph "The comfort zone boundaries should be the same as the daytime settings for your thermostat, " + + "the minimum being the heating set point and the maximum being the cooling set point." + } + section("Temperature Sensor") { + paragraph "The temperature sensor should be placed in a central part of the house away from windows, doors, vents, or any other sources of heat or cold. " + + "When in doubt, place it near your thermostat. Best case is for your temperature sensor to also be your thermostat." + } + section("Humidity Sensor") { + paragraph "This sensor allows inside and outside air to be compared when outside air has moderate to high levels of humidity. " + + "In drier climates such as California this sensor is not necessary. " + } + section("Smoke Detectors") { + paragraph "If any of these sensors detect smoke, whole house fans are turned off and will remain off until no smoke is detected. " + + "Windows are not shut when smoke is present for egress purposes. This is an important, recommended safety feature." + } + section("Thermostats") { + paragraph "These thermostats will be turned off when windows are open or whole house fans are on. " + + "Additionally, thermostats will be set to cooling or heating mode automatically when not venting." + } + section("Whole House Fans") { + paragraph "Whole house fans ventilate inside air into the attic, drawing air from windows or skylights. " + + "These fans will be utilized at appropriate times during the day to raise or lower inside temperatures. " + + "When and for how long they run is calculated using forecast and current temperatures for your area." + + paragraph "WARNING: Whole house fans, automated or not, create a strong negative pressure inside the home. " + + "There should be no circumstances in which flame, ash, or smoke can be drawn into the home from a fireplace. " + + "A glass insert or other like measure should always be installed along with a whole house fan to prevent " + + "fire hazard or smoke/ash related damage." + } + section("Attic Fans") { + paragraph "When the house is in cooling mode, attic fans will run continually to lower attic temperatures. " + + "Attic fans are turned off during whole house fan operation to save wear and tear on attic fans. " + + "No attic temperature sensor is required." + } + section("Garage Fans") { + paragraph "When in cooling mode, the garage fan will turn on in the late afternoon and off in the late morning. " + + "These are the times when outside temperatures are lower than garage temperatures. " + + "No garage temperature sensor is required." + } + section("Basement/Crawlspace Fans") { + paragraph "When heating is needed, the basement/crawlspace fans will run when outside temperatures exceed temperatures inside the house. " + + "No basement/crawlspace temperature sensor is required." + } + section("Motorized Windows/Skylights") { + paragraph "Windows/skylights are automatically opened to accommodate heating or cooling when conditions are right." + } + section("Contact Sensors") { + paragraph "Contact sensors can be used either as an extra check that a motorized window is open or to indicate that a manual window has been opened." + } + section("Window Coverings") { + paragraph "These coverings will be opened along with windows when ventilating. " + + "Otherwise, window coverings are opened at sunrise and closed at sunset." + } + section("Whole House Fan/Window Operating Modes") { + paragraph "By default, whole house fans and windows/skylights will operate in all modes. " + + "However, you can specify with this option under which modes you want windows and whole house fans to automatically open/turn on." + } + section("Number of Open Windows Needed") { + paragraph "Whole house fans will not turn on automatically until the total number of window/skylights and contact sensors equals or exceeds this number. " + + "Note: If an open window is automated and has a contact sensor installed, this will count as two open windows." + } + section("Suspend Operation") { + paragraph "The automated operation of Whole House Fans and windows/skylights can be suspended temporarily by clicking the checkbox. " + + "To resume operation, deselect the checkbox." + } + section("Tips and Tricks") { + paragraph "Look in 'Notifications' of the SmartThings app to see what Ventilation Guru is doing." + paragraph "Whole house fan automation can be achieved (for cooling) without motorized windows by using contact sensors and manually opening the windows in the evening. " + + "It is not recommended that whole house fans be automated without motorized windows, contact sensors, or some other means of obtaining outside air, such as louvers." + paragraph "The rule of thumb regarding the open window area required for a whole house fan is to take the square of the diameter of the fan and then double it." + paragraph "If your thermostat is not also controlled by this app, the comfort zone minimum and maximum might need to be within rather than equal to the daytime thermostat setting." + paragraph "WARNING: Without proper ventilation, whole-house fans can create a backdraft in your furnace or water heater, potentially causing carbon monoxide to be pulled into your home. " + + "Operating the fan without open windows may also cause the fan to overheat." + paragraph "WARNING: Whole house fans, automated or not, create a strong negative pressure inside the home. " + + "There should be no circumstances in which flame, ash, or smoke can be drawn into the home from a fireplace. " + + "A glass insert or other like measure should always be installed along with a whole house fan to prevent " + + "fire hazard or smoke/ash related damage." + } + } +} + +def installed() { + LOG "Installed with settings: ${settings}" + initialize() +} + +def updated() { + LOG "Updated with settings: ${settings}" + unschedule() + unsubscribe() + initialize() +} + +// initiallization methods + +def initialize() { + initGlobals() + subscriptions() + def nowString = initDaytimeFlag() + initInsideTempAndHumidity() + + // change the ventilation algorithm at dawn + subscribe(location, "sunrise", sunriseHandler) + subscribe(location, "sunset", sunsetHandler) + + // turn on the garage fan if conditions are right and after 7 PM + state.oldOutsideTemp = nowString > "19:00" ? 999 : state.outsideTemp + + // init window coverings + if (myCoverings) { + if (state.daytime) { + openUp(myCoverings, "coverings", "Coverings") + } else { + closeUp(myCoverings, "coverings", "Coverings") + } + } + + getCurrentConditions() + runEvery10Minutes(getCurrentConditions) +} + +def initGlobals() { + state.morningAdjConst = 5 + state.morningAdjustment = 0 + state.currentConditionsFailCntr = 99 // start off as if we've never talked to weather underground + state.forecastFailCntr = 99 + state.callCntr = 99 // used to get forecast hourly + state.rain = false + state.curPrecip = 0.0 + state.curPrecipCntr = 0 + + // inside mean temp and skew calculations + state.insideTempIndex = 1 // used to update array of inside temperatures + state.insideMeanTempReady = false // true when we have 24 inside temperatures, one per hour + state.greenhouseEffectCntr = 0 // increments when skew is < 1 && > -1 + if (state.greenhouseEffect == null) { // keep the old greenhouse effect if we have it + state.greenhouseEffect = cAdj(3.0) // accounts for greenhouse effect and inside sources of heat + } else { + LOG("Saved Greenhouse Effect is ${state.greenhouseEffect}") + sanityCheckGreenhouseEffect() + } + + state.skew = 0 + state.meanRange = (myDaytimeMax + myDaytimeMin) / 2 + state.outsideTemp = state.meanRange + state.meanOutside = state.meanRange + state.outsideHumidity = 20 + state.outsideHeatIndex = state.meanRange + state.windowsNeeded = myWindowsNeeded ?: (myWindowsSwitch || myContactSensors) ? 1 : 0 + state.newOutsideTemp = state.meanRange + state.oldOutsideTemp = state.meanRange + + initDeviceStates() +} + +def initDeviceStates() { + // these device state enumerations lock-in manual changes and prevent thrashing + state.Unknown = 0 + state.On = 1 + state.Off = 2 + state.Open = 3 + state.Closed = 4 + state.Opening = 5 + state.Closing = 6 + state.Heating = 7 + state.Cooling = 8 + + state.wholeHouseFans = state.Unknown + state.atticFans = state.Unknown + state.basementFans = state.Unknown + state.windowsSwitch = state.Unknown + state.coverings = state.Unknown + state.garageFans = state.Unknown + state.thermostats = state.Unknown + state.modes = state.Unknown +} + +def subscriptions() { + subscribe(myTempSensor, "temperature", temperatureChangedHandler) + if (myWindowsSwitch) { + subscribe(myWindowsSwitch, "switch.on", windowOpenedHandler) + subscribe(myWindowsSwitch, "switch.off", windowClosedHandler) + } + if (myWholeHouseFans) { + subscribe(myWholeHouseFans, "switch.on", whfOnHandler) + subscribe(myWholeHouseFans, "switch.off", whfOffHandler) + } + if (myModes) { + subscribe(location, "mode", modeChangedHandler) + } + if (myHumiditySensor) { + subscribe(myHumiditySensor, "humidity", humidityChangedHandler) + } + if (myContactSensors) { + subscribe(myContactSensors, "contact.open", windowOpenedHandler) + subscribe(myContactSensors, "contact.closed", windowClosedHandler) + } + if (mySmokeDetectors) { + subscribe(mySmokeDetectors, "smoke", smokeChangedHandler) + } +} + +// returns a string of current time in HH:mm +def initDaytimeFlag() { + def myDate = new Date(now()) + def nowString = myDate?.format("HH:mm", location?.timeZone) + def sunriseDate = Date?.parse("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", location?.currentValue("sunriseTime")) + def sunsetDate = Date?.parse("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", location?.currentValue("sunsetTime")) + def sunriseTime = sunriseDate?.format("HH:mm", location?.timeZone) + def sunsetTime = sunsetDate?.format("HH:mm", location?.timeZone) + state.daytime = (nowString > sunriseTime && nowString < sunsetTime) + LOG("Sunrise: ${sunriseTime}, Now: ${nowString}, Sunset: ${sunsetTime}, Daytime: ${state.daytime}") + return nowString +} + +def initInsideTempAndHumidity() { + // init inside temp + def currentState = myTempSensor.temperatureState + state.insideTemp = 72.0 + if (currentState) { + state.insideTemp = currentState.integerValue + LOG "Initial Inside Temp: ${state.insideTemp}" + } else { + sendNotificationEvent("Ventilation Guru Error: Unable to obtain initial inside temperature!") + } + state.insideHeatIndex = state.insideTemp + state.insideHumidity = 20 + + //state.highWaterMark = state.insideTemp + + // init the inside humidity and heatIndex if we have a humidity sensor + if (myHumiditySensor) { + def response = myHumiditySensor.currentState("humidity") + if (response) { + state.insideHumidity = response?.integerValue + state.insideHeatIndex = getHeatIndex(state.insideTemp, state.insideHumidity) + LOG "Initial Inside Humidity: ${state.insideHumidity} Temp: ${state.insideTemp} HeatIndex: ${state.insideHeatIndex}" + } + } +} + +// event handlers + +def smokeChangedHandler(evt) { + LOG "Smoke: ${evt?.stringValue}" + makeComparisons() +} + +def whfOnHandler(evt) { + if (state.wholeHouseFans != state.On) { + // WHF was manually turned on, so set other devices accordingly + myWindowsSwitch?.on() + myThermostats?.off() + myAtticFans?.off() + } +} + +def whfOffHandler(evt) { + if (state.wholeHouseFans != state.Off) { + // reset devices that we changed when WHF was manually turn on + state.windowsSwitch = state.Unknown + state.thermostats = state.Unknown + state.atticFans = state.Unknown + LOG("whfOffHandler") + makeComparisons() + } +} + +def windowOpenedHandler(evt) { + LOG("Window opened") + // disable thermostats + turnOff(myThermostats, "thermostats", "Thermostats") + def windowCnt = openWindowCnt() + if (windowCnt >= state.windowsNeeded) { + makeComparisons() + } +} + +def windowClosedHandler(evt) { + def windowCnt = openWindowCnt() + if (windowCnt < state.windowsNeeded) { + LOG("Windows closed") + // make sure the WHF is off + turnOff(myWholeHouseFans, "wholeHouseFans", "Whole House Fans") + } + if (windowCnt == 0) { + setThermostatMode() + } +} + +def setThermostatMode() { + if (myThermostats) { + if (state.wholeHouseFans == state.On || openWindowCnt() != 0) { + // disable thermostats + turnOff(myThermostats, "thermostats", "Thermostats") + } else if (state.skew > 0) { + if (state.thermostats != state.Heating) { + state.thermostats = state.Heating + myThermostats.heat() + sendNotificationEvent("Ventilation Guru: Thermostat(s) set to Heat mode") + } + } else { + if (state.thermostats != state.Cooling) { + state.thermostats = state.Cooling + myThermostats.cool() + sendNotificationEvent("Ventilation Guru: Thermostat(s) set to Cooling mode") + } + } + } +} + +def temperatureChangedHandler(evt) { + state.insideTemp = evt?.doubleValue + state.insideHeatIndex = getHeatIndex(state.insideTemp, state.insideHumidity) + LOG("Inside Temp: ${state.insideTemp}") + makeComparisons() +} + +def humidityChangedHandler(evt) { + state.insideHumidity = evt?.doubleValue + state.insideHeatIndex = getHeatIndex(state.insideTemp, state.insideHumidity) + LOG "Inside Humidity Changed: ${state.insideHumidity} Inside Temp: ${state.insideTemp} HeatIndex: ${state.insideHeatIndex}" + makeComparisons() +} + +// always open window coverings at sunrise +def sunriseHandler(evt) { + state.morningAdjustment = state.morningAdjConst + openUp(myCoverings, "coverings", "Coverings") + state.daytime = true + makeComparisons() +} + +def sunsetHandler(evt) { + state.morningAdjustment = 0 + state.daytime = false + // close window coverings at sunset if windows are closed + if (openWindowCnt() == 0) { + closeUp(myCoverings, "coverings", "Coverings") + } + makeComparisons() +} + +// mode changed +def modeChangedHandler(evt) { + LOG "Mode Changed: ${location.mode}" + makeComparisons() +} + +// Utilities + +boolean inWrongMode() { + boolean status = false + if (myModes) { + status = true + myModes.each { + LOG("mode sensor: ${mode}, it ${it}") + //def mode = it.currentValue("mode") + def mode = it + if (mode == location.mode) { + LOG("mode OK!!!!!") + status = false + } + } + } + return status +} + +def openWindows() { + // don't resend an open command + if (myWindowsSwitch && state.windowsSwitch != state.On && state.windowsSwitch != state.Opening) { + LOG("Open windows start") + state.windowsSwitch = state.Opening + LOG "thermo state: ${myThermostat?.currentValue('thermostatOperatingState')}" + if (myThermostat && myThermostat.currentValue('thermostatOperatingState') != "idle") { + // wait 5 minutes in case the thermostat is in short cycle prevention mode + runIn(5 * 60, physicallyOpenWindows) + } else { + physicallyOpenWindows() + } + } +} + +def physicallyOpenWindows() { + if (state.windowsSwitch == state.Opening) { + turnOn(myWindowsSwitch, "windowsSwitch", "Windows") + state.windowsSwitch = state.Opening + // change state later to indicate we're done, preventing WHF from openning before window is done opening + runIn(90, completeOpenWindows) + } +} + +def completeOpenWindows() { + if (state.windowsSwitch == state.Opening) { + state.windowsSwitch = state.On + } + makeComparisons() +} + +def closeWindows() { + if (myWindowsSwitch) { + if (state.windowsSwitch == state.Unknown) { + turnOff(myWindowsSwitch, "windowsSwitch", "Windows") + LOG("Windows Closed") + } else if (state.windowsSwitch != state.Off && state.windowsSwitch != state.Closing) { + // close windows without thrashing (wait awhile) + state.windowsSwitch = state.Closing + runIn(45 * 60, completeCloseWindows) + } + } +} + +def completeCloseWindows() { + LOG "Complete Close windows: ${state.windowsSwitch}" + if (state.windowsSwitch == state.Closing) { + turnOff(myWindowsSwitch, "windowsSwitch", "Windows") + } +} + +def turnOnWhfs() { + if (openWindowCnt() >= state.windowsNeeded) { + turnOn(myWholeHouseFans, "wholeHouseFans", "Whole House Fans") + } +} + +boolean smokeDetected() { + def status = false + if (mySmokeDetectors) { + mySmokeDetectors.each { + def smoke = it.currentValue("smoke") + LOG "smoke sensor: ${smoke}" + if (smoke == "detected") { + status = true + } + } + } + return status +} + +// returns number of open windows +int openWindowCnt() { + int cntr = 0 + if (myContactSensors) { + // add up contact sensors + myContactSensors.each { + def contact = it.currentValue("contact") + if (contact == "open") { + cntr++ + } + } + } + // don't count windows still opening + if (myWindowsSwitch && state.windowsSwitch == state.On) { + // add up windows + myWindowsSwitch.each { + def window = it.currentValue("switch") + if (window == "on") { + cntr++ + } + } + } + LOG "openWindowCnt: ${cntr}, windowsRequired: ${state.windowsNeeded}" + return cntr +} + +// NOAA formula most accurate from 70-80 degrees F +// HI = 0.5 * {T + 61.0 + [(T-68.0)*1.2] + (RH*0.094)} +def getHeatIndex(double temperature, double humidity) { + // if we have no humidity sensor just deal with dry-bulb values + if (!myHumiditySensor) { + return temperature + } + def temp = temperature + LOG("TempScale: ${location.temperatureScale}") + if (location.temperatureScale == "C") { + // convert to F for calculation + temp = cToF(temp) + } + // calculate heatIndex + temp = 0.5 * (temp + 61.0 + ((temp-68.0) * 1.2) + (humidity * 0.094)) + if (location.temperatureScale == "C") { + // convert back to C + temp = fToC(temp) + } + // do not let this formula lower the dry bulb temperature + if (temp < temperature) { + temp = temperature + } + LOG("Temp: ${temperature}, Humidity: ${humidity}, HeatIndex: ${temp}") + return temp +} + +def cToF(temp) { + return temp * 1.8 + 32 +} + +def fToC(temp) { + return (temp - 32) * 0.5555555556 +} + +// correct an offset if using metric +def cAdj(offset) { + if (location.temperatureScale == "C") { + offset = offset * 0.5555555556 + } + return offset +} + +def turnOn(device, String devName, String extName) { + if (device) { + // don't send an open command if closing as it's already open + if (state[devName] == state.Closing) { + state[devName] = state.On + } else if (state[devName] != state.On) { + state[devName] = state.On + device.on() + def action = (devName == "windowsSwitch") ? "Opened" : "Turned on" + sendNotificationEvent("Ventilation Guru: ${action} ${extName}") + } + } +} + +def turnOff(device, String devName, String extName) { + // don't resend a command + if (device && state[devName] != state.Off) { + state[devName] = state.Off + device.off() + def action = (devName == "windowsSwitch") ? "Closed" : "Turned off" + sendNotificationEvent("Ventilation Guru: ${action} ${extName}") + } +} + +def openUp(device, String devName, String extName) { + // don't set an open command if closing as it's already open + if (state[devName] == state.Closing) { + state[devName] = state.Open + } else if (device && state[devName] != state.Open) { + state[devName] = state.Open + device.open() + sendNotificationEvent("Ventilation Guru: Opened ${extName}") + } +} + +def closeUp(device, String devName, String extName) { + // don't resend a command + if (device && state[devName] != state.Closed) { + state[devName] = state.Closed + device.close() + sendNotificationEvent("Ventilation Guru: Closed ${extName}") + } +} + +def getCurrentConditions() { + def cond = getWeatherFeature("conditions") + if (cond) { + if (location.temperatureScale == "C") { + state.outsideTemp = cond?.current_observation?.temp_c + } else { + state.outsideTemp = cond?.current_observation?.temp_f + } + if (myHumiditySensor) { + // get the outdoor humidity only if needed + def humidityString = cond?.current_observation?.relative_humidity + // drop the % sign and convert to integer + state.outsideHumidity = humidityString?.substring(0, humidityString?.length() - 1)?.toInteger() + if (state.outsideHumidity == null) { + sendNotificationEvent("Ventilation Guru Error: Unable to obtain outside humidity!") + state.outsideHumidity = 20 + } + state.outsideHeatIndex = getHeatIndex(state.outsideTemp, state.outsideHumidity) + } else { + state.outsideHeatIndex = state.outsideTemp + } + processCurPrecip(cond?.current_observation.precip_1hr_in.toDouble()) + state.currentConditionsFailCntr = 0 + } else { + state.currentConditionsFailCntr = state.currentConditionsFailCntr + 1 + if (state.currentConditionsFailCntr > 6) { + // inside hourly values are not accurate anymore + state.insideTempIndex = 1 + state.insideMeanTempReady = false + } + } + // get forecast every hour in case it has changed + state.callCntr = state.callCntr + 1 + if (state.callCntr > 6) { + state.callCntr = 1 + getForecast() + } + LOG "Outside Temp: ${state.outsideTemp}, Humidity: ${state.outsideHumidity}, HeatIndex: ${state.outsideHeatIndex}, cur precip: ${state.curPrecip}" + makeComparisons() + setThermostatMode() +} + +def processCurPrecip(double curPrecip) { + // sanity check curPrecip + curPrecip = (curPrecip <= 0 || curPrecip > 999) ? 0 : curPrecip + // prevent window thrashing, remember precip for an hour + if (curPrecip) { + state.curPrecip = curPrecip + state.curPrecipCntr = 6 + } else if (state.curPrecipCntr <= 0) { + state.curPrecip = 0.0 + state.curPrecipCntr = 0 + } else { + state.curPrecipCntr = state.curPrecipCntr - 1 + } +} + +def getForecast() { + // for garage fan, we need to know when outside temp starts to drop + state.oldOutsideTemp = state.newOutsideTemp + state.newOutsideTemp = state.outsideTemp + + getHourlyConditions() + + def forecastMeanDelta = state.meanRange - state.meanOutside + + // remember inside temp every hour + state[state.insideTempIndex.toString()] = state.insideTemp + state.insideTempIndex = state.insideTempIndex + 1 + if (state.insideTempIndex > 24) { + state.insideTempIndex = 1 + state.insideMeanTempReady = true + } + + // get inside mean temp + def insideMean = 0 + if (state.insideMeanTempReady) { + for (int i = 1; i <= 24; i++) { + insideMean += state[i.toString()] + } + insideMean /= 24 + } else { + insideMean = state.meanRange + } + + calculateSkew(insideMean, forecastMeanDelta) + calculateTarget() + LOG "skew: ${state.skew} Target: ${state.target} meanOutside: ${state.meanOutside} insideMean: ${insideMean}" //greenhouseEffect ${state.greenhouseEffect} deltaWeight ${state.deltaWeight}" +} + +// target is the goal temperature +def calculateTarget() { + // determine target differently for heating and cooling + if (state.skew <= 0) { + // in cooling mode + state.target = state.meanRange + cAdj(2) + state.skew + if (state.target < myDaytimeMin) { + state.target = myDaytimeMin + } + } else { + // in heating mode, heat only up to daytime range mean + state.target = state.meanRange - cAdj(3) + state.skew + if (state.target > state.meanRange + cAdj(1)) { + state.target = state.meanRange + cAdj(1) + } + } +} + +// skew determines how much to heat/cool +def calculateSkew(insideMean, forecastMeanDelta) { + def oldSkew = state.skew + // to determine heating/cooling level, include forecast outside temp mean delta to inside temp mean delta, less a bit for greenhouse effect + double totalDelta = forecastMeanDelta + (state.meanRange - insideMean) / 2 + state.skew = totalDelta - state.greenhouseEffect + + // learn the best way to anticipate heating and cooling needs + if (state.skew <= 2 && state.skew >= -2) { + // no heating or cooling so inside temp should stay near middle of range + state.greenhouseEffectCntr = state.greenhouseEffectCntr + 1 + if (state.greenhouseEffectCntr >= 24) { + state.greenhouseEffectCntr = 0 + // no heating or cooling for 24 hours so mean inside temp should equal midpoint of range + state.greenhouseEffect = state.greenhouseEffect + (insideMean - state.meanRange) / 2 + // sendNotificationEvent("New Greenhouse Effect = ${state.greenhouseEffect}") + sanityCheckGreenhouseEffect() + // recalculate skew + state.skew = totalDelta - state.greenhouseEffect + LOG "New greenhouse effect adjustment: ${state.greenhouseEffect}, New Skew: ${state.skew}" + } + } else { + // not a good sample, so start over + state.greenhouseEffectCntr = 0 + } + + // change thermostat heat/cooling mode if things changed + if (myThermostats && state.thermostats == state.On && (oldSkew > 0 && state.skew <= 0 || oldSkew <= 0 && state.skew > 0)) { + // reset thermostat to heating or cooling after making comparisons and completing any actions + state.thermostats = state.Unknown + } +} + +def getHourlyConditions() { + def resp = getWeatherFeature("hourly") + if (!resp) { + // couldn't get the forecast so leave the old forecast unchanged + LOG("Ventilation Guru Error: Could not obtain forecast information!") + state.forecastFailCntr = state.forecastFailCntr + 1 + return 0 + } + + // get rain forecast for now and next hour + def hourlyRain = resp?.hourly_forecast?.condition + state.rain = (hourlyRain && (hourlyRain[0].contains("Rain") || hourlyRain[1].contains("Rain"))) + if (hourlyRain) { + LOG("hourlyRain ${state.rain} ${hourlyRain}") + } + + // get forecast temps + def outsideHourly + if (location.temperatureScale == "F") { + outsideHourly = resp?.hourly_forecast?.temp?.english + } else { + outsideHourly = resp?.hourly_forecast?.temp?.metric + } + double meanOutside = 0 + if (outsideHourly) { + state.forecastFailCntr = 0 + outsideHourly[0..23].each { + meanOutside += it?.toDouble() + } + meanOutside /= 24 + state.meanOutside = meanOutside + } + else { + LOG("Ventilation Guru Error: Could not obtain the hourly forecast values!") + state.forecastFailCntr = state.forecastFailCntr + 1 + return 0 + } + + LOG "Weather Underground: mean Outside: ${state.meanOutside}, hourly: ${outsideHourly[0..23]}" +} + +def sanityCheckGreenhouseEffect() { + if (state.greenhouseEffect < cAdj(1)) { + state.greenhouseEffect = cAdj(1) + } else if (state.greenhouseEffect > cAdj(5)) { + state.greenhouseEffect = cAdj(5) + } +} + +def turnOffWHFsAndWindows() { + turnOff(myWholeHouseFans, "wholeHouseFans", "Whole House Fans") + turnOff(myWindowsSwitch, "windowsSwitch", "Windows") +} + +def turnOffFans() { + turnOff(myWholeHouseFans, "wholeHouseFans", "Whole House Fans") + turnOff(myAtticFans, "atticFans", "Attic Fans") + turnOff(myBasementFans, "basementFans", "Basement Fans") + turnOff(myGarageFans, "garageFans", "Garage Fans") +} + +def makeComparisons() { + LOG("Begin makeComparisons()") + + // shut everything off if we can't get the forecast or current conditions for too long + if (state.currentConditionsFailCntr > 6 || state.forecastFailCntr > 4) { + sendNotificationEvent("Ventilation Guru Error: Check internet connection. Can not access Weather Underground!") + turnOffWHFsAndWindows() + turnOffFans() + } else if (smokeDetected()) { + // turn off all fans if there's smoke + sendNotificationEvent("Ventilation Guru Warning: Smoke Detected!") + turnOffFans() + } else { + checkWholeHouseFansAndWindows() + checkAtticFans() + checkGarageFans() + checkBasementFans() + } +} + +def checkWholeHouseFansAndWindows() { + if (myWholeHouseFans || myWindowsSwitch) { + // close windows/WHF if not in a selected mode, operation suspended, or we came home and the inside temp is outside the daytime comfort zone + if (inWrongMode() || mySuspendOperation) { + turnOffWHFsAndWindows() + } else if (state.rain || state.curPrecip > 0.0) { // check precipitation + if (state.windowsSwitch == state.On || state.wholeHouseFans == state.On) { + sendNotificationEvent("Ventilation Guru: Windows closed/Whole House Fans turned off due to rain") + } + turnOffWHFsAndWindows() + } else if (state.skew > 0) { // day venting + if (state.insideTemp >= state.target || state.insideTemp + cAdj(6) > state.outsideTemp || state.outsideTemp > myDaytimeMax) { // prevent thermostat with WHF operation + turnOff(myWholeHouseFans, "wholeHouseFans", "Whole House Fans") + // leave windows open longer than WHF to prevent window thrashing + if (state.insideTemp >= state.target || state.insideTemp + cAdj(5) > state.outsideTemp || state.outsideTemp > myDaytimeMax) { // prevent thermostat with window open + turnOff(myWindowsSwitch, "windowsSwitch", "Windows") + } + } + else if (state.insideTemp + cAdj(1) <= state.target && state.insideTemp + cAdj(6) < state.outsideTemp) { + openWindows() + if (state.insideTemp + cAdj(7) < state.outsideTemp) { + turnOnWhfs() + } + } + } else { // night venting + if (state.insideTemp <= state.target || state.outsideTemp > myDaytimeMax || state.insideHeatIndex < state.outsideHeatIndex + cAdj(state.morningAdjustment - 2)) { + turnOff(myWholeHouseFans, "wholeHouseFans", "Whole House Fans") + // leave windows open longer than WHF to prevent window thrashing and close windows after a few minutes in case we just went through the morning adjustment + if (state.insideTemp + cAdj(1) <= state.target || state.outsideTemp > myDaytimeMax || state.insideHeatIndex < state.outsideHeatIndex + cAdj(state.morningAdjustment - 3)) { + closeWindows() + } + } else if (state.insideTemp > state.target + cAdj(1) && state.insideHeatIndex > state.outsideHeatIndex + cAdj(state.morningAdjustment)) { + openWindows() + turnOnWhfs() + } + LOG "Inside Temp: ${state.insideTemp} Outside Temp: ${state.outsideTemp} Target: ${state.target}" + } + } +} + +def checkAtticFans() { + if (myAtticFans) { + // run attic fans all the time when in cooling mode unless WHF is on + if (state.skew < 0 && state.wholeHouseFans != state.On && state.insideTemp > state.meanRange + state.skew) { + turnOn(myAtticFans, "atticFans", "Attic Fans") + } + else { + turnOff(myAtticFans, "atticFans", "Attic Fans") + } + } +} + +def checkGarageFans() { + if (myGarageFans) { + // turn on the garage fan if cooling is needed and night time, it's cool outside, or outside temp is dropping fast + if (state.skew < 0 && state.insideTemp > state.meanRange + state.skew) { + if (!state.daytime || state.outsideTemp < state.meanRange) { + turnOn(myGarageFans, "garageFans", "Garage Fans") + } else { + def myDate = new Date(now()) + def nowString = myDate?.format("HH:mm", location?.timeZone) + if (state.newOutsideTemp + cAdj(2) <= state.oldOutsideTemp && nowString > "16:00") { + turnOn(myGarageFans, "garageFans", "Garage Fans") + } else { + turnOff(myGarageFans, "garageFans", "Garage Fans") + } + } + } else { + turnOff(myGarageFans, "garageFans", "Garage Fans") + } + } +} + +def checkBasementFans() { + if (myBasementFans) { + if (state.skew > 0 && state.outsideTemp >= state.insideTemp && state.insideTemp < state.meanRange + state.skew) { + turnOn(myBasementFans, "basementFans", "Basement Fans") + } else { + turnOff(myBasementFans, "basementFans", "Basement Fans") + } + } +} + + +def LOG(String text) { + if (state.debugMode) { + log.debug(text) + } +}