From b29cc581444154e074ae80d956e657ef978fb63c Mon Sep 17 00:00:00 2001 From: DAVe3283 Date: Tue, 7 Mar 2023 13:47:25 -0700 Subject: [PATCH] Add absolute humidity component (#4519) * Import Absolute Humidity component https://PigLab.ReaperLegion.net/home-automation/hass/esphome/custom-components/absolute-humidity * Fix terminology, add some docstrings * Switch from double to float https://github.com/esphome/esphome/pull/4519#pullrequestreview-1327615169 The additional precision doesn't matter in practice. * Address code review suggestions * Lint code --- CODEOWNERS | 1 + .../components/absolute_humidity/__init__.py | 1 + .../absolute_humidity/absolute_humidity.cpp | 182 ++++++++++++++++++ .../absolute_humidity/absolute_humidity.h | 76 ++++++++ .../components/absolute_humidity/sensor.py | 56 ++++++ esphome/const.py | 3 + tests/test1.yaml | 4 + 7 files changed, 323 insertions(+) create mode 100644 esphome/components/absolute_humidity/__init__.py create mode 100644 esphome/components/absolute_humidity/absolute_humidity.cpp create mode 100644 esphome/components/absolute_humidity/absolute_humidity.h create mode 100644 esphome/components/absolute_humidity/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index e8f264b633..1ff9661add 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -11,6 +11,7 @@ esphome/*.py @esphome/core esphome/core/* @esphome/core # Integrations +esphome/components/absolute_humidity/* @DAVe3283 esphome/components/ac_dimmer/* @glmnet esphome/components/adc/* @esphome/core esphome/components/adc128s102/* @DeerMaximum diff --git a/esphome/components/absolute_humidity/__init__.py b/esphome/components/absolute_humidity/__init__.py new file mode 100644 index 0000000000..8f113b48f6 --- /dev/null +++ b/esphome/components/absolute_humidity/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@DAVe3283"] diff --git a/esphome/components/absolute_humidity/absolute_humidity.cpp b/esphome/components/absolute_humidity/absolute_humidity.cpp new file mode 100644 index 0000000000..13f30c996f --- /dev/null +++ b/esphome/components/absolute_humidity/absolute_humidity.cpp @@ -0,0 +1,182 @@ +#include "esphome/core/log.h" +#include "absolute_humidity.h" + +namespace esphome { +namespace absolute_humidity { + +static const char *const TAG = "absolute_humidity.sensor"; + +void AbsoluteHumidityComponent::setup() { + ESP_LOGCONFIG(TAG, "Setting up absolute humidity '%s'...", this->get_name().c_str()); + + ESP_LOGD(TAG, " Added callback for temperature '%s'", this->temperature_sensor_->get_name().c_str()); + this->temperature_sensor_->add_on_state_callback([this](float state) { this->temperature_callback_(state); }); + if (this->temperature_sensor_->has_state()) { + this->temperature_callback_(this->temperature_sensor_->get_state()); + } + + ESP_LOGD(TAG, " Added callback for relative humidity '%s'", this->humidity_sensor_->get_name().c_str()); + this->humidity_sensor_->add_on_state_callback([this](float state) { this->humidity_callback_(state); }); + if (this->humidity_sensor_->has_state()) { + this->humidity_callback_(this->humidity_sensor_->get_state()); + } +} + +void AbsoluteHumidityComponent::dump_config() { + LOG_SENSOR("", "Absolute Humidity", this); + + switch (this->equation_) { + case BUCK: + ESP_LOGCONFIG(TAG, "Saturation Vapor Pressure Equation: Buck"); + break; + case TETENS: + ESP_LOGCONFIG(TAG, "Saturation Vapor Pressure Equation: Tetens"); + break; + case WOBUS: + ESP_LOGCONFIG(TAG, "Saturation Vapor Pressure Equation: Wobus"); + break; + default: + ESP_LOGE(TAG, "Invalid saturation vapor pressure equation selection!"); + break; + } + + ESP_LOGCONFIG(TAG, "Sources"); + ESP_LOGCONFIG(TAG, " Temperature: '%s'", this->temperature_sensor_->get_name().c_str()); + ESP_LOGCONFIG(TAG, " Relative Humidity: '%s'", this->humidity_sensor_->get_name().c_str()); +} + +float AbsoluteHumidityComponent::get_setup_priority() const { return setup_priority::DATA; } + +void AbsoluteHumidityComponent::loop() { + if (!this->next_update_) { + return; + } + this->next_update_ = false; + + // Ensure we have source data + const bool no_temperature = std::isnan(this->temperature_); + const bool no_humidity = std::isnan(this->humidity_); + if (no_temperature || no_humidity) { + if (no_temperature) { + ESP_LOGW(TAG, "No valid state from temperature sensor!"); + } + if (no_humidity) { + ESP_LOGW(TAG, "No valid state from temperature sensor!"); + } + ESP_LOGW(TAG, "Unable to calculate absolute humidity."); + this->publish_state(NAN); + this->status_set_warning(); + return; + } + + // Convert to desired units + const float temperature_c = this->temperature_; + const float temperature_k = temperature_c + 273.15; + const float hr = this->humidity_ / 100; + + // Calculate saturation vapor pressure + float es; + switch (this->equation_) { + case BUCK: + es = es_buck(temperature_c); + break; + case TETENS: + es = es_tetens(temperature_c); + break; + case WOBUS: + es = es_wobus(temperature_c); + break; + default: + ESP_LOGE(TAG, "Invalid saturation vapor pressure equation selection!"); + this->publish_state(NAN); + this->status_set_error(); + return; + } + ESP_LOGD(TAG, "Saturation vapor pressure %f kPa", es); + + // Calculate absolute humidity + const float absolute_humidity = vapor_density(es, hr, temperature_k); + + // Publish absolute humidity + ESP_LOGD(TAG, "Publishing absolute humidity %f g/m³", absolute_humidity); + this->status_clear_warning(); + this->publish_state(absolute_humidity); +} + +// Buck equation (https://en.wikipedia.org/wiki/Arden_Buck_equation) +// More accurate than Tetens in normal meteorologic conditions +float AbsoluteHumidityComponent::es_buck(float temperature_c) { + float a, b, c, d; + if (temperature_c >= 0) { + a = 0.61121; + b = 18.678; + c = 234.5; + d = 257.14; + } else { + a = 0.61115; + b = 18.678; + c = 233.7; + d = 279.82; + } + return a * expf((b - (temperature_c / c)) * (temperature_c / (d + temperature_c))); +} + +// Tetens equation (https://en.wikipedia.org/wiki/Tetens_equation) +float AbsoluteHumidityComponent::es_tetens(float temperature_c) { + float a, b; + if (temperature_c >= 0) { + a = 17.27; + b = 237.3; + } else { + a = 21.875; + b = 265.5; + } + return 0.61078 * expf((a * temperature_c) / (temperature_c + b)); +} + +// Wobus equation +// https://wahiduddin.net/calc/density_altitude.htm +// https://wahiduddin.net/calc/density_algorithms.htm +// Calculate the saturation vapor pressure (kPa) +float AbsoluteHumidityComponent::es_wobus(float t) { + // THIS FUNCTION RETURNS THE SATURATION VAPOR PRESSURE ESW (MILLIBARS) + // OVER LIQUID WATER GIVEN THE TEMPERATURE T (CELSIUS). THE POLYNOMIAL + // APPROXIMATION BELOW IS DUE TO HERMAN WOBUS, A MATHEMATICIAN WHO + // WORKED AT THE NAVY WEATHER RESEARCH FACILITY, NORFOLK, VIRGINIA, + // BUT WHO IS NOW RETIRED. THE COEFFICIENTS OF THE POLYNOMIAL WERE + // CHOSEN TO FIT THE VALUES IN TABLE 94 ON PP. 351-353 OF THE SMITH- + // SONIAN METEOROLOGICAL TABLES BY ROLAND LIST (6TH EDITION). THE + // APPROXIMATION IS VALID FOR -50 < T < 100C. + // + // Baker, Schlatter 17-MAY-1982 Original version. + + const float c0 = +0.99999683e00; + const float c1 = -0.90826951e-02; + const float c2 = +0.78736169e-04; + const float c3 = -0.61117958e-06; + const float c4 = +0.43884187e-08; + const float c5 = -0.29883885e-10; + const float c6 = +0.21874425e-12; + const float c7 = -0.17892321e-14; + const float c8 = +0.11112018e-16; + const float c9 = -0.30994571e-19; + const float p = c0 + t * (c1 + t * (c2 + t * (c3 + t * (c4 + t * (c5 + t * (c6 + t * (c7 + t * (c8 + t * (c9))))))))); + return 0.61078 / pow(p, 8); +} + +// From https://www.environmentalbiophysics.org/chalk-talk-how-to-calculate-absolute-humidity/ +// H/T to https://esphome.io/cookbook/bme280_environment.html +// H/T to https://carnotcycle.wordpress.com/2012/08/04/how-to-convert-relative-humidity-to-absolute-humidity/ +float AbsoluteHumidityComponent::vapor_density(float es, float hr, float ta) { + // es = saturated vapor pressure (kPa) + // hr = relative humidity [0-1] + // ta = absolute temperature (K) + + const float ea = hr * es * 1000; // vapor pressure of the air (Pa) + const float mw = 18.01528; // molar mass of water (g⋅mol⁻¹) + const float r = 8.31446261815324; // molar gas constant (J⋅K⁻¹) + return (ea * mw) / (r * ta); +} + +} // namespace absolute_humidity +} // namespace esphome diff --git a/esphome/components/absolute_humidity/absolute_humidity.h b/esphome/components/absolute_humidity/absolute_humidity.h new file mode 100644 index 0000000000..9f3b9eab8b --- /dev/null +++ b/esphome/components/absolute_humidity/absolute_humidity.h @@ -0,0 +1,76 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/sensor/sensor.h" + +namespace esphome { +namespace absolute_humidity { + +/// Enum listing all implemented saturation vapor pressure equations. +enum SaturationVaporPressureEquation { + BUCK, + TETENS, + WOBUS, +}; + +/// This class implements calculation of absolute humidity from temperature and relative humidity. +class AbsoluteHumidityComponent : public sensor::Sensor, public Component { + public: + AbsoluteHumidityComponent() = default; + + void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; } + void set_humidity_sensor(sensor::Sensor *humidity_sensor) { this->humidity_sensor_ = humidity_sensor; } + void set_equation(SaturationVaporPressureEquation equation) { this->equation_ = equation; } + + void setup() override; + void dump_config() override; + float get_setup_priority() const override; + void loop() override; + + protected: + void temperature_callback_(float state) { + this->next_update_ = true; + this->temperature_ = state; + } + void humidity_callback_(float state) { + this->next_update_ = true; + this->humidity_ = state; + } + + /** Buck equation for saturation vapor pressure in kPa. + * + * @param temperature_c Air temperature in °C. + */ + static float es_buck(float temperature_c); + /** Tetens equation for saturation vapor pressure in kPa. + * + * @param temperature_c Air temperature in °C. + */ + static float es_tetens(float temperature_c); + /** Wobus equation for saturation vapor pressure in kPa. + * + * @param temperature_c Air temperature in °C. + */ + static float es_wobus(float temperature_c); + + /** Calculate vapor density (absolute humidity) in g/m³. + * + * @param es Saturation vapor pressure in kPa. + * @param hr Relative humidity 0 to 1. + * @param ta Absolute temperature in K. + * @param heater_duration The duration in ms that the heater should turn on for when measuring. + */ + static float vapor_density(float es, float hr, float ta); + + sensor::Sensor *temperature_sensor_{nullptr}; + sensor::Sensor *humidity_sensor_{nullptr}; + + bool next_update_{false}; + + float temperature_{NAN}; + float humidity_{NAN}; + SaturationVaporPressureEquation equation_; +}; + +} // namespace absolute_humidity +} // namespace esphome diff --git a/esphome/components/absolute_humidity/sensor.py b/esphome/components/absolute_humidity/sensor.py new file mode 100644 index 0000000000..f2b075f4d9 --- /dev/null +++ b/esphome/components/absolute_humidity/sensor.py @@ -0,0 +1,56 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import sensor +from esphome.const import ( + CONF_HUMIDITY, + CONF_TEMPERATURE, + STATE_CLASS_MEASUREMENT, + CONF_EQUATION, + ICON_WATER, + UNIT_GRAMS_PER_CUBIC_METER, +) + +absolute_humidity_ns = cg.esphome_ns.namespace("absolute_humidity") +AbsoluteHumidityComponent = absolute_humidity_ns.class_( + "AbsoluteHumidityComponent", sensor.Sensor, cg.Component +) + +SaturationVaporPressureEquation = absolute_humidity_ns.enum( + "SaturationVaporPressureEquation" +) +EQUATION = { + "BUCK": SaturationVaporPressureEquation.BUCK, + "TETENS": SaturationVaporPressureEquation.TETENS, + "WOBUS": SaturationVaporPressureEquation.WOBUS, +} + +CONFIG_SCHEMA = ( + sensor.sensor_schema( + unit_of_measurement=UNIT_GRAMS_PER_CUBIC_METER, + icon=ICON_WATER, + accuracy_decimals=2, + state_class=STATE_CLASS_MEASUREMENT, + ) + .extend( + { + cv.GenerateID(): cv.declare_id(AbsoluteHumidityComponent), + cv.Required(CONF_TEMPERATURE): cv.use_id(sensor.Sensor), + cv.Required(CONF_HUMIDITY): cv.use_id(sensor.Sensor), + cv.Optional(CONF_EQUATION, default="WOBUS"): cv.enum(EQUATION, upper=True), + } + ) + .extend(cv.COMPONENT_SCHEMA) +) + + +async def to_code(config): + var = await sensor.new_sensor(config) + await cg.register_component(var, config) + + temperature_sensor = await cg.get_variable(config[CONF_TEMPERATURE]) + cg.add(var.set_temperature_sensor(temperature_sensor)) + + humidity_sensor = await cg.get_variable(config[CONF_HUMIDITY]) + cg.add(var.set_humidity_sensor(humidity_sensor)) + + cg.add(var.set_equation(config[CONF_EQUATION])) diff --git a/esphome/const.py b/esphome/const.py index 0ab9dd54bf..289a59b424 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -214,6 +214,7 @@ CONF_ENERGY = "energy" CONF_ENTITY_CATEGORY = "entity_category" CONF_ENTITY_ID = "entity_id" CONF_ENUM_DATAPOINT = "enum_datapoint" +CONF_EQUATION = "equation" CONF_ESP8266_DISABLE_SSL_SUPPORT = "esp8266_disable_ssl_support" CONF_ESPHOME = "esphome" CONF_ETHERNET = "ethernet" @@ -860,6 +861,7 @@ ICON_SIGNAL_DISTANCE_VARIANT = "mdi:signal" ICON_THERMOMETER = "mdi:thermometer" ICON_TIMELAPSE = "mdi:timelapse" ICON_TIMER = "mdi:timer-outline" +ICON_WATER = "mdi:water" ICON_WATER_PERCENT = "mdi:water-percent" ICON_WEATHER_SUNSET = "mdi:weather-sunset" ICON_WEATHER_SUNSET_DOWN = "mdi:weather-sunset-down" @@ -881,6 +883,7 @@ UNIT_DEGREE_PER_SECOND = "°/s" UNIT_DEGREES = "°" UNIT_EMPTY = "" UNIT_G = "G" +UNIT_GRAMS_PER_CUBIC_METER = "g/m³" UNIT_HECTOPASCAL = "hPa" UNIT_HERTZ = "Hz" UNIT_HOUR = "h" diff --git a/tests/test1.yaml b/tests/test1.yaml index 4312f2c613..8180400730 100644 --- a/tests/test1.yaml +++ b/tests/test1.yaml @@ -1229,6 +1229,10 @@ sensor: model: 1005 update_interval: 60s i2c_id: i2c_bus + - platform: absolute_humidity + name: DHT Absolute Humidity + temperature: dht_temperature + humidity: dht_humidity esp32_touch: setup_mode: false