From 9732d3bd837b7732aac412bcc59183cee805f760 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Thu, 25 Jun 2026 19:12:48 -0500 Subject: [PATCH 1/4] Add physics-based predicted room temperature & humidity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two template sensors that estimate true room temperature and humidity, correcting for the Air-1 enclosure's self-heating: - Predicted Room Temperature: weighted blend of SEN55/DPS310/SCD40 temperatures with a thermal-gradient correction (Physics Multiplier P, Baseline Offset). - Predicted Room Humidity: re-references each sensor's RH to the predicted room temperature via the Magnus formula, then blends by manufacturing tolerance. Supporting additions, all additive (stock sensors and their defaults are unchanged): - New CONFIG numbers: Physics Multiplier P, Baseline Offset, SCD40 Temperature Offset, SCD40 Humidity Offset (all default to a neutral value). - Expose SCD40 Temperature/Humidity and name the DPS310 Temperature sensor so the prediction lambdas can read them. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Integrations/ESPHome/Core.yaml | 160 +++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) diff --git a/Integrations/ESPHome/Core.yaml b/Integrations/ESPHome/Core.yaml index 6c84cb9..62fd636 100644 --- a/Integrations/ESPHome/Core.yaml +++ b/Integrations/ESPHome/Core.yaml @@ -142,6 +142,66 @@ number: step: 0.1 mode: box + # Physics-based room temperature/humidity prediction. + # The Air-1 enclosure self-heats, so the on-board sensors read warmer/drier + # than the room. These CONFIG numbers feed the "Predicted Room *" template + # sensors below; defaults are the empirically fitted values and can be tuned + # per unit. They do not alter the stock SEN55/SCD40/DPS310 sensors. + - platform: template + name: "Physics Multiplier P" # thermal-gradient multiplier + id: coefficient_p + restore_value: true + initial_value: 0.5357 + min_value: 0.0000 + max_value: 1.0000 + entity_category: "CONFIG" + optimistic: true + update_interval: never + step: 0.0001 + mode: box + + - platform: template + name: "Baseline Offset" + id: baseline_offset + restore_value: true + initial_value: 0.0 + min_value: -5.0 + max_value: 5.0 + entity_category: "CONFIG" + unit_of_measurement: "°C" + optimistic: true + update_interval: never + step: 0.01 + mode: box + + - platform: template + name: SCD40 Temperature Offset + id: scd40_temperature_offset + restore_value: true + initial_value: 0.0 + min_value: -70.0 + max_value: 70.0 + entity_category: "CONFIG" + unit_of_measurement: "°C" + optimistic: true + update_interval: never + step: 0.1 + mode: box + + - platform: template + name: SCD40 Humidity Offset + id: scd40_humidity_offset + restore_value: true + initial_value: 0.0 + min_value: -70.0 + max_value: 70.0 + entity_category: "CONFIG" + unit_of_measurement: "%" + optimistic: true + update_interval: never + step: 0.1 + mode: box + - platform: template name: "Sleep Duration" id: deep_sleep_sleep_duration @@ -233,6 +293,95 @@ sensor: update_interval: 60s entity_category: "diagnostic" + # Room temperature prediction. Blends the three on-board temperature sensors + # and corrects for enclosure self-heating using the Physics Multiplier P / + # Baseline Offset CONFIG numbers above. + - platform: template + name: "Predicted Room Temperature" + id: predicted_room_temperature + unit_of_measurement: "°C" + device_class: temperature + state_class: measurement + accuracy_decimals: 2 + update_interval: 10s + lambda: |- + // Make sure the Air-1 is running at least for 3 minutes (avoid bad initial predictions) + if (id(sys_uptime).state < 180.0) { + return NAN; + } + + // Fetch Sensor data and coefficients + float t_sen = id(sen55_temperature).state; + float t_dps = id(dps310temperature).state; + float t_scd = id(scd40_temperature).state; + float p_val = id(coefficient_p).state; + float baseline = id(baseline_offset).state; + + // Ensure all sensors are actively broadcasting valid numbers + if (std::isnan(t_sen) || std::isnan(t_dps) || std::isnan(t_scd)) { + return NAN; + } + + // Estimate the temperature inside Air-1 + float t_base = (0.4703 * t_sen) + (0.3809 * t_dps) + (0.1488 * t_scd); + + // Estimate the required offset + float t_offset = p_val * (t_scd - t_sen); + + // Return predicted room temperature + return t_base - t_offset + baseline; + + # Room humidity prediction. Re-references each sensor's RH to the predicted + # room temperature (Magnus formula), then blends by manufacturing tolerance. + - platform: template + name: "Predicted Room Humidity" + id: predicted_room_humidity + unit_of_measurement: "%" + device_class: humidity + state_class: measurement + accuracy_decimals: 1 + update_interval: 10s + lambda: |- + // Make sure the Air-1 is running at least for 3 minutes (avoid bad initial predictions) + if (id(sys_uptime).state < 180.0) { + return NAN; + } + + // Fetch the calculated Predicted Room Temperature + float t_room = id(predicted_room_temperature).state; + + // Fetch Sensor data + float t_sen = id(sen55_temperature).state; + float rh_sen = id(sen55_humidity).state; + float t_scd = id(scd40_temperature).state; + float rh_scd = id(scd40_humidity).state; + + // Ensure all sensors are actively broadcasting valid numbers + if (std::isnan(t_room) || std::isnan(t_sen) || std::isnan(rh_sen) || std::isnan(t_scd) || std::isnan(rh_scd)) { + return NAN; + } + + // Calculate Saturation Vapor Pressures (Magnus Formula) + float mag_room = expf((17.62 * t_room) / (243.12 + t_room)); + float mag_sen = expf((17.62 * t_sen) / (243.12 + t_sen)); + float mag_scd = expf((17.62 * t_scd) / (243.12 + t_scd)); + + // Calculate Actual Room RH for each sensor + float true_rh_sen = rh_sen * (mag_sen / mag_room); + float true_rh_scd = rh_scd * (mag_scd / mag_room); + + // Lowest statistical variance based on sensor manufacturing tolerances (64% SEN55, 36% SCD40) + float final_rh = (0.64 * true_rh_sen) + (0.36 * true_rh_scd); + + // Clamp to valid range + if (final_rh > 100.0) { + final_rh = 100.0; + } else if (final_rh < 0.0) { + final_rh = 0.0; + } + + return final_rh; + - platform: scd4x id: scd40 co2: @@ -244,6 +393,16 @@ sensor: lambda: 'return id(air_quality_led_source).current_option() == "CO2";' then: - script.execute: update_air_quality_led + temperature: + name: "SCD40 Temperature" + id: scd40_temperature + filters: + - lambda: return x - id(scd40_temperature_offset).state; + humidity: + name: "SCD40 Humidity" + id: scd40_humidity + filters: + - lambda: return x - id(scd40_humidity_offset).state; automatic_self_calibration: true update_interval: 60s measurement_mode: "periodic" @@ -260,6 +419,7 @@ sensor: float offset = id(dps310_pressure_offset).state; return isnan(offset) ? x : x + offset; temperature: + name: "DPS310 Temperature" id: dps310temperature update_interval: 30s i2c_id: bus_a From 387af309be4bc6d66c565cb36960906a0a5f3616 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Thu, 25 Jun 2026 19:16:40 -0500 Subject: [PATCH 2/4] Disable predicted-room entities by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These are advanced, opt-in sensors. Mark the two Predicted Room sensors, their CONFIG tuning numbers, and the newly-exposed SCD40/DPS310 inputs as disabled_by_default so the stock device page stays clean. The prediction lambdas still read the on-device state regardless of HA enablement, so functionality is unaffected when the entities are hidden. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Integrations/ESPHome/Core.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Integrations/ESPHome/Core.yaml b/Integrations/ESPHome/Core.yaml index 62fd636..6b0c0a9 100644 --- a/Integrations/ESPHome/Core.yaml +++ b/Integrations/ESPHome/Core.yaml @@ -150,6 +150,7 @@ number: - platform: template name: "Physics Multiplier P" # thermal-gradient multiplier id: coefficient_p + disabled_by_default: true restore_value: true initial_value: 0.5357 min_value: 0.0000 @@ -163,6 +164,7 @@ number: - platform: template name: "Baseline Offset" id: baseline_offset + disabled_by_default: true restore_value: true initial_value: 0.0 min_value: -5.0 @@ -177,6 +179,7 @@ number: - platform: template name: SCD40 Temperature Offset id: scd40_temperature_offset + disabled_by_default: true restore_value: true initial_value: 0.0 min_value: -70.0 @@ -191,6 +194,7 @@ number: - platform: template name: SCD40 Humidity Offset id: scd40_humidity_offset + disabled_by_default: true restore_value: true initial_value: 0.0 min_value: -70.0 @@ -299,6 +303,7 @@ sensor: - platform: template name: "Predicted Room Temperature" id: predicted_room_temperature + disabled_by_default: true unit_of_measurement: "°C" device_class: temperature state_class: measurement @@ -336,6 +341,7 @@ sensor: - platform: template name: "Predicted Room Humidity" id: predicted_room_humidity + disabled_by_default: true unit_of_measurement: "%" device_class: humidity state_class: measurement @@ -396,11 +402,13 @@ sensor: temperature: name: "SCD40 Temperature" id: scd40_temperature + disabled_by_default: true filters: - lambda: return x - id(scd40_temperature_offset).state; humidity: name: "SCD40 Humidity" id: scd40_humidity + disabled_by_default: true filters: - lambda: return x - id(scd40_humidity_offset).state; automatic_self_calibration: true @@ -421,6 +429,7 @@ sensor: temperature: name: "DPS310 Temperature" id: dps310temperature + disabled_by_default: true update_interval: 30s i2c_id: bus_a From be8e4470c682a9a2470f6b49e058c613da054791 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Thu, 25 Jun 2026 19:22:18 -0500 Subject: [PATCH 3/4] Credit Ellude and mark multiplier as provisional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add attribution to Ellude (Apollo Discord), who did the research and coefficient fitting. Note that the Physics Multiplier P default (0.5357) is provisional pending a better fitted value. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Integrations/ESPHome/Core.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Integrations/ESPHome/Core.yaml b/Integrations/ESPHome/Core.yaml index 6b0c0a9..47ca4c1 100644 --- a/Integrations/ESPHome/Core.yaml +++ b/Integrations/ESPHome/Core.yaml @@ -143,6 +143,7 @@ number: mode: box # Physics-based room temperature/humidity prediction. + # Research and coefficient fitting by Ellude (Apollo Discord). # The Air-1 enclosure self-heats, so the on-board sensors read warmer/drier # than the room. These CONFIG numbers feed the "Predicted Room *" template # sensors below; defaults are the empirically fitted values and can be tuned @@ -152,6 +153,7 @@ number: id: coefficient_p disabled_by_default: true restore_value: true + # Provisional value from Ellude; update once a better fit is available. initial_value: 0.5357 min_value: 0.0000 max_value: 1.0000 From 225ebdb8a47de8c2094784e71e6ec03876df421b Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Fri, 26 Jun 2026 11:10:47 -0500 Subject: [PATCH 4/4] Harden prediction lambdas against NaN inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Guard the warm-up gate against a NaN sys_uptime. sys_uptime is NaN until its first publish (~60s) and NaN < 180.0 is false, so the gate could let both prediction lambdas run before warm-up. Check isnan() first. - Extend the temperature lambda's NaN check to the coefficient/baseline values so a bad restore can't slip a NaN through the arithmetic. - Add isnan offset guards to the SCD40 temperature/humidity filters, matching the existing DPS310 pressure-offset pattern. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Integrations/ESPHome/Core.yaml | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/Integrations/ESPHome/Core.yaml b/Integrations/ESPHome/Core.yaml index 47ca4c1..7afa79f 100644 --- a/Integrations/ESPHome/Core.yaml +++ b/Integrations/ESPHome/Core.yaml @@ -312,8 +312,10 @@ sensor: accuracy_decimals: 2 update_interval: 10s lambda: |- - // Make sure the Air-1 is running at least for 3 minutes (avoid bad initial predictions) - if (id(sys_uptime).state < 180.0) { + // Make sure the Air-1 is running at least for 3 minutes (avoid bad initial predictions). + // sys_uptime is NAN until its first publish, and NAN < 180.0 is false, so guard for it. + float uptime = id(sys_uptime).state; + if (std::isnan(uptime) || uptime < 180.0) { return NAN; } @@ -324,8 +326,9 @@ sensor: float p_val = id(coefficient_p).state; float baseline = id(baseline_offset).state; - // Ensure all sensors are actively broadcasting valid numbers - if (std::isnan(t_sen) || std::isnan(t_dps) || std::isnan(t_scd)) { + // Ensure all sensors and coefficients are valid numbers before predicting + if (std::isnan(t_sen) || std::isnan(t_dps) || std::isnan(t_scd) || + std::isnan(p_val) || std::isnan(baseline)) { return NAN; } @@ -350,8 +353,10 @@ sensor: accuracy_decimals: 1 update_interval: 10s lambda: |- - // Make sure the Air-1 is running at least for 3 minutes (avoid bad initial predictions) - if (id(sys_uptime).state < 180.0) { + // Make sure the Air-1 is running at least for 3 minutes (avoid bad initial predictions). + // sys_uptime is NAN until its first publish, and NAN < 180.0 is false, so guard for it. + float uptime = id(sys_uptime).state; + if (std::isnan(uptime) || uptime < 180.0) { return NAN; } @@ -406,13 +411,17 @@ sensor: id: scd40_temperature disabled_by_default: true filters: - - lambda: return x - id(scd40_temperature_offset).state; + - lambda: |- + float offset = id(scd40_temperature_offset).state; + return isnan(offset) ? x : x - offset; humidity: name: "SCD40 Humidity" id: scd40_humidity disabled_by_default: true filters: - - lambda: return x - id(scd40_humidity_offset).state; + - lambda: |- + float offset = id(scd40_humidity_offset).state; + return isnan(offset) ? x : x - offset; automatic_self_calibration: true update_interval: 60s measurement_mode: "periodic"