Skip to content

Add physics-based predicted room temperature & humidity#102

Open
bharvey88 wants to merge 3 commits into
ApolloAutomation:betafrom
bharvey88:physics-multiplier-pr
Open

Add physics-based predicted room temperature & humidity#102
bharvey88 wants to merge 3 commits into
ApolloAutomation:betafrom
bharvey88:physics-multiplier-pr

Conversation

@bharvey88

@bharvey88 bharvey88 commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Version: deferred — no bump for now (this can sit in beta a while; set the
date-based YY.M.D.N at merge time)

What does this implement/fix?

Credit to Ellude (Apollo Discord) — they did the research and coefficient
fitting that make this work.

Adds two opt-in template sensors that estimate true room temperature and
humidity, correcting for the Air-1 enclosure's self-heating:

  • Predicted Room Temperature — weighted blend of the SEN55, DPS310 and
    SCD40 temperatures with a thermal-gradient correction.
  • Predicted Room Humidity — re-references each sensor's RH to the predicted
    room temperature (Magnus formula), then blends by manufacturing tolerance.

Supporting CONFIG numbers for per-unit tuning: Physics Multiplier P,
Baseline Offset, SCD40 Temperature Offset, SCD40 Humidity Offset. The
SCD40 temperature/humidity and DPS310 temperature are exposed so the prediction
lambdas can read them. Physics Multiplier P defaults to 0.5357, which is
provisional pending a better fitted value from Ellude.

Everything added here is additive and disabled_by_default — the stock
SEN55/SCD40/DPS310 sensors and their defaults are unchanged, and the new
entities stay hidden until a user enables them. The lambdas read on-device
state regardless of HA enablement, so disabling the entities doesn't affect the
calculation.

A wiki article covering how to enable and tune these (against a reference
thermometer) is in progress and will be linked here.

Types of changes

  • Bugfix (fixed change that fixes an issue)
  • New feature (thanks!)
  • Breaking change (repair/feature that breaks existing functionality)
  • Dependency Update - Does not publish
  • Other - Does not publish
  • Website of github readme file update - Does not publish
  • Github workflows - Does not publish

Checklist / Checklijst:

  • The code change has been tested and works locally
  • The code change has not yet been tested

If user-visible functionality or configuration variables are added/modified:

  • Added/updated documentation for the web page

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added new predicted room temperature and humidity sensors for a more refined room reading.
    • Added configurable temperature, baseline, and SCD40 offset settings for easier tuning.
  • Changes

    • Updated SCD40 readings to support offset adjustments.
    • Set the DPS310 temperature sensor to be disabled by default.

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)
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)
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)
@coderabbitai

coderabbitai Bot commented Jun 26, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Core ESPHome config adds new tuning numbers, computes predicted room temperature and humidity from existing sensor data, exposes corrected SCD40 outputs, and disables the DPS310 temperature sensor by default.

Changes

ESPHome predicted-room sensing

Layer / File(s) Summary
Prediction inputs
Integrations/ESPHome/Core.yaml
Adds CONFIG template numbers for coefficient_p, baseline_offset, and the SCD40 temperature/humidity offsets.
Predicted sensors and outputs
Integrations/ESPHome/Core.yaml
Adds predicted_room_temperature and predicted_room_humidity, applies the new SCD40 offset filters, and sets dps310temperature to disabled_by_default: true.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I hopped through numbers, soft and keen,
and found a room both warm and clean.
With offsets tuned and sensors bright,
the burrow hums just right tonight.
Thump! ✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: new physics-based predicted room temperature and humidity sensors.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Integrations/ESPHome/Core.yaml`:
- Around line 410-415: Clamp the SCD40 humidity correction before publishing so
the lambda under humidity does not emit values outside 0-100% RH. Update the
filter in the SCD40 humidity sensor block to apply the offset in the existing
lambda and bound the result to the valid range, using the scd40_humidity and
scd40_humidity_offset identifiers to locate the change.
- Line 316: The warm-up gate in the prediction lambdas currently compares
id(sys_uptime).state directly, which can pass too early when the value is still
NaN before the first publish. Update both lambda guards that use sys_uptime to
first check std::isnan(uptime) and only allow prediction once uptime is valid
and at least 180.0, using the existing lambda logic around id(sys_uptime) so
both paths behave consistently.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 57e0793d-adb9-4d2c-b85e-f51d15820182

📥 Commits

Reviewing files that changed from the base of the PR and between 6a6c388 and be8e447.

📒 Files selected for processing (1)
  • Integrations/ESPHome/Core.yaml

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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

git ls-files 'Integrations/ESPHome/Core.yaml'
wc -l Integrations/ESPHome/Core.yaml
sed -n '300,370p' Integrations/ESPHome/Core.yaml

Repository: ApolloAutomation/AIR-1

Length of output: 2807


🏁 Script executed:

#!/bin/bash
set -euo pipefail

git ls-files 'Integrations/ESPHome/Core.yaml'
wc -l Integrations/ESPHome/Core.yaml
sed -n '300,370p' Integrations/ESPHome/Core.yaml

Repository: ApolloAutomation/AIR-1

Length of output: 2807


🏁 Script executed:

#!/bin/bash
set -euo pipefail

git ls-files 'Integrations/ESPHome/Core.yaml'
wc -l Integrations/ESPHome/Core.yaml
sed -n '300,370p' Integrations/ESPHome/Core.yaml

Repository: ApolloAutomation/AIR-1

Length of output: 2807


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "sys_uptime|uptime" Integrations/ESPHome/Core.yaml

Repository: ApolloAutomation/AIR-1

Length of output: 341


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "sys_uptime|uptime" Integrations/ESPHome/Core.yaml

Repository: ApolloAutomation/AIR-1

Length of output: 341


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "sys_uptime|uptime" Integrations/ESPHome/Core.yaml

Repository: ApolloAutomation/AIR-1

Length of output: 341


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "sys_uptime|uptime" Integrations/ESPHome/Core.yaml

Repository: ApolloAutomation/AIR-1

Length of output: 341


🌐 Web query:

ESPHome uptime sensor initial state NAN state before first update uptime platform state comparison false

💡 Result:

In ESPHome, the uptime sensor's initial state behavior depends on its configuration type and the device's synchronization status [1]. Sensors in ESPHome, including the uptime sensor, default to NaN (Not a Number) if no value has been published to the sensor's state yet [2]. Key aspects regarding uptime sensor initialization and state: 1. Initialization and "NaN" State: ESPHome sensors are initialized with NaN [2]. The uptime sensor will report this state until the first update occurs [3]. For the default type: seconds configuration, this update happens according to the update_interval (defaulting to 60 seconds) [1]. There have been historical reports of the initial state not appearing immediately in Home Assistant following a device boot, as the sensor must first perform its initial polling cycle [3]. 2. Timestamp Type Regression: If you are using type: timestamp, this mode requires an external time source (the time component) to be synchronized [1][4]. A known regression introduced in ESPHome 2026.1.0 caused the timestamp uptime sensor to report as "unavailable" (NaN) because the time synchronization callback was being skipped when the system time was already synchronized [5][6]. This issue was addressed in subsequent patches (e.g., PR #13456) to ensure the callback is always triggered [6]. 3. Platform State and state_class: While the uptime sensor has historically been assigned total_increasing as its state_class to assist with statistics in Home Assistant [7][8], this has been debated because uptime can decrease upon reboot [9]. You may see variations in how this is handled in different ESPHome versions [9][7]. If you are experiencing unexpected NaN or unavailable states, verify that: - Your update_interval is set to an appropriate duration if using seconds type [1]. - You are running a version of ESPHome that includes the fix for time synchronization callbacks if using timestamp type [5][6]. - The time component is correctly configured and successfully syncing if using timestamp type [4]. If you need the sensor to report a specific value instead of NaN before the first update, you can utilize sensor filters such as heartbeat or use publish_state() within a lambda to force an initial value upon boot [2].

Citations:


🌐 Web query:

ESPHome uptime sensor initial state NAN state before first update uptime platform state comparison false

💡 Result:

In ESPHome, the uptime sensor typically reports NaN (Not a Number) or "unavailable" state before its first successful update [1]. The behavior depends on the sensor type and the state of your system: 1. Initial State Behavior: Sensors in ESPHome return NaN when accessed via get_state() or get_raw_state() if no value has been published yet [1]. The uptime sensor platform waits for its initial update interval (defaulting to 60 seconds) to publish its first value [2]. 2. Timestamp Type Regression: If you are using type: timestamp, please note that as of January 2026, some users reported the sensor showing "unavailable" (or NA) due to a regression involving time synchronization callbacks [3][4]. This was addressed in PR #13456, which ensures that time synchronization callbacks are correctly triggered even when the system time has not changed, allowing the uptime timestamp sensor to initialize properly [4]. 3. State Comparison: When writing lambdas, you should check for NaN before performing operations, as arithmetic with NaN will result in NaN. You can use std::isnan(id(uptime_sensor).state) to check this in your code. 4. Note on State Classes: The uptime sensor has historically transitioned between having no state_class and using total_increasing [5][6][7]. Because uptime can technically reset (decrease) upon a device reboot, it is sometimes debated whether total_increasing is appropriate, though it is the current standard for many uptime configurations in Home Assistant [5][8]. If your sensor remains NaN or unavailable significantly longer than the update_interval, check your logs to see if the ESPHome device is successfully connecting to the API or, in the case of the timestamp type, if the time component is successfully synchronizing [9][10].

Citations:


🌐 Web query:

ESPHome uptime sensor initial state NAN state before first update uptime platform state comparison false

💡 Result:

In ESPHome, sensors that have not yet published a value will typically return NaN (Not a Number) when queried via lambdas like id(sensor_id).get_state or.get_raw_state [1]. The uptime sensor is a polling component, and its state is not available until the first update interval has elapsed [2][3]. Regarding the specific behaviors mentioned: Initial NaN State: Before the first update interval occurs, the sensor has no value to report [3]. If your configuration or logic relies on a valid state immediately at boot, it will encounter this NaN state [1]. The uptime sensor defaults to an update interval of 60 seconds [2]. State Comparison: If you are performing state comparisons (e.g., checking if uptime > X) in a lambda, comparing against NaN will generally result in false [1]. To handle this, you should verify the sensor has a valid state before performing comparisons, for example by checking if the value is not NaN: if (!isnan(id(uptime_sensor).state)) {... } Timestamp Type Regression: Note that a known issue in ESPHome version 2026.1.0 caused the uptime sensor with type: timestamp to report as unavailable (NaN/NA) due to a regression in time synchronization callbacks [4][5]. This was addressed in subsequent patches [5]. State Class: Historically, the uptime sensor's state_class has been a subject of discussion, with some users and PRs advocating for total_increasing and others arguing that uptime does not meet the criteria for statistical tracking as it resets on reboot [6][7][8]. If you find that your sensor remains in a NaN state indefinitely, ensure your ESP is successfully connected to the network or time source (if using the timestamp type) and that the update_interval is configured appropriately for your requirements [2][5][9].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '284,365p' Integrations/ESPHome/Core.yaml
printf '\n---\n'
sed -n '840,860p' Integrations/ESPHome/Core.yaml

Repository: ApolloAutomation/AIR-1

Length of output: 3479


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '284,365p' Integrations/ESPHome/Core.yaml
printf '\n---\n'
sed -n '840,860p' Integrations/ESPHome/Core.yaml

Repository: ApolloAutomation/AIR-1

Length of output: 3479


Guard the warm-up gate against NaN uptime.
sys_uptime is only published every 60s and can still be NaN before its first update, so id(sys_uptime).state < 180.0 can let both prediction lambdas run too early. Check std::isnan(uptime) before comparing in both places.

Proposed fix
-      if (id(sys_uptime).state < 180.0) {
+      const float uptime = id(sys_uptime).state;
+      if (std::isnan(uptime) || uptime < 180.0) {
         return NAN;
       }

Apply the same change in both lambdas.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (id(sys_uptime).state < 180.0) {
const float uptime = id(sys_uptime).state;
if (std::isnan(uptime) || uptime < 180.0) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Integrations/ESPHome/Core.yaml` at line 316, The warm-up gate in the
prediction lambdas currently compares id(sys_uptime).state directly, which can
pass too early when the value is still NaN before the first publish. Update both
lambda guards that use sys_uptime to first check std::isnan(uptime) and only
allow prediction once uptime is valid and at least 180.0, using the existing
lambda logic around id(sys_uptime) so both paths behave consistently.

Comment on lines +410 to +415
humidity:
name: "SCD40 Humidity"
id: scd40_humidity
disabled_by_default: true
filters:
- lambda: return x - id(scd40_humidity_offset).state;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Clamp corrected SCD40 humidity before publishing.

Line 415 can emit values outside the valid 0-100% RH range, e.g. 40 - (-70) = 110.

Proposed fix
     humidity:
       name: "SCD40 Humidity"
       id: scd40_humidity
       disabled_by_default: true
       filters:
-        - lambda: return x - id(scd40_humidity_offset).state;
+        - lambda: |-
+            float adjusted = x - id(scd40_humidity_offset).state;
+            if (adjusted > 100.0f) return 100.0f;
+            if (adjusted < 0.0f) return 0.0f;
+            return adjusted;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
humidity:
name: "SCD40 Humidity"
id: scd40_humidity
disabled_by_default: true
filters:
- lambda: return x - id(scd40_humidity_offset).state;
humidity:
name: "SCD40 Humidity"
id: scd40_humidity
disabled_by_default: true
filters:
- lambda: |-
float adjusted = x - id(scd40_humidity_offset).state;
if (adjusted > 100.0f) return 100.0f;
if (adjusted < 0.0f) return 0.0f;
return adjusted;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Integrations/ESPHome/Core.yaml` around lines 410 - 415, Clamp the SCD40
humidity correction before publishing so the lambda under humidity does not emit
values outside 0-100% RH. Update the filter in the SCD40 humidity sensor block
to apply the offset in the existing lambda and bound the result to the valid
range, using the scd40_humidity and scd40_humidity_offset identifiers to locate
the change.

@secondof9

Copy link
Copy Markdown

Code Review: Add physics-based predicted room temperature & humidity

🔴 Critical

⚠️ Warnings

  • Integrations/ESPHome/Core.yaml (SCD40 temperature filter) — The SCD40 temperature offset filter return x - id(scd40_temperature_offset).state; has no NaN guard. On first boot before restore_value completes, or if the offset entity becomes NaN, the entire SCD40 temperature chain propagates NaN silently into the Predicted Room Temperature/Humidity lambdas. The DPS310 pressure offset at line 426-427 demonstrates the correct pattern:

    float offset = id(dps310_pressure_offset).state;
    return isnan(offset) ? x : x + offset;

    The SCD40 filters should follow the same defensive pattern.

    Suggestion:

    temperature:
      name: "SCD40 Temperature"
      id: scd40_temperature
      disabled_by_default: true
      filters:
        - 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: |
            float offset = id(scd40_humidity_offset).state;
            return isnan(offset) ? x : x - offset;
  • Integrations/ESPHome/Core.yaml (Predicted Room Temperature lambda) — The NaN guard in the temperature prediction lambda only checks sensor states (t_sen, t_dps, t_scd), but not the CONFIG coefficient states (coefficient_p, baseline_offset). If either coefficient is NaN (e.g., first boot before restore, or a corrupted restore), the arithmetic at p_val * (t_scd - t_sen) silently produces NaN which bypasses the existing guard and is returned as the final value.

    Suggestion: Extend the NaN check to include the coefficient values:

    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;
    
    if (std::isnan(t_sen) || std::isnan(t_dps) || std::isnan(t_scd)
        || std::isnan(p_val) || std::isnan(baseline)) {
      return NAN;
    }
  • Integrations/ESPHome/Core.yaml (Predicted Room Humidity lambda) — Same coefficient NaN issue: the humidity lambda reads predicted_room_temperature (which could be NaN if its own coefficients are NaN), but it has no independent NaN check on its own upstream dependencies. While the current std::isnan(t_room) check catches a NaN room temperature, it doesn't distinguish why it's NaN — a sensor failure vs. a coefficient restore failure — which makes debugging harder. Consider adding a diagnostic log or at minimum ensuring the humidity lambda's NaN check is explicit about what it's guarding against.

💡 Suggestions

  • Integrations/ESPHome/Core.yaml (sys_uptime dependency) — Both prediction lambdas depend on id(sys_uptime), which is not defined in this PR. This is a cross-module dependency that could silently break if the uptime sensor is renamed or removed. Consider adding a // Requires: sys_uptime sensor comment near the top of each lambda, or better yet, adding a compile-time assertion.

  • Integrations/ESPHome/Core.yaml (clamping with min/max) — The final humidity clamping at the bottom of the humidity lambda uses an if/else block. ESPHome supports the simpler fminf(fmaxf(final_rh, 0.0f), 100.0f) idiom which is more concise and has no branching. Not a correctness issue, just a style suggestion.

✅ Looks Good

  • All new entities are disabled_by_default: true — excellent user experience; stock sensors remain untouched.
  • restore_value: true on all CONFIG number entities — state persists across restarts.
  • The 3-minute warmup guard (sys_uptime < 180.0) prevents cold-start prediction artifacts.
  • The Magnus formula usage in the humidity prediction is physically correct.
  • DPS310 temperature, SCD40 temperature, and SCD40 humidity are exposed with disabled_by_default: true so users can see raw sensor values for tuning.
  • Coefficient ranges are well-bounded (P: 0–1, Baseline: ±5°C, SCD40 offsets: ±70°C).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants