Heat Pump Optimizer
What?
Following up on rentry.co/heatshit, you may know that heat pumps perform the worst when they have to defrost a lot. This Home Assistant python script creates a simple automation that uses sensors and weather forecast to optimize the time of heating over a 24-hour period, such that it avoids heating in low efficiency periods.
How?
Heat pumps perform the worst at -4 - 4C in high humidity conditions. The script checks hourly weather forecast, then picks out good hours (that are not in the danger zone) and bad hours (that are in the danger zone) and calculates a planned heating offset distribution based on the amount of good/bad hours. Then applies an appropriate coefficient to the present state. Heating more in good efficiency hours and not heating so much in bad efficiency hours will make your COP more better numbers.
Ok, how do?
You will need:
- Home assistant
- Heat pump connected to HASS with a Climate entity (i.e. SmartThinQ LGE Sensors for LG)
it is assumed that you have weather compensation with offset adjustment, the script as-is is NOT designed to directly control flow temperature, but rather just provide offsets (although it wouldn't be hard to rework it for flow temp control)
- Weather Forecast integration (met.no is default for the code, others may work, but likely would need formatting changes) (obviously you have to configure the forecast correctly to your exact location, otherwise the numbers will be out of whack)
- Python Scripts enabled in HASS
- (optional) logging enabled in HASS
Configuration.yaml
Add the following:
Note that you can have any other levels/logs set if you want/need, only that at least "logger:" should be present
Create variables
- Go to Settings > Devices > Helpers
- Click Create helper
- Select number
- Enter name: hvac_temp_lean
- Pick a cool icon with sunglasses
- Set minimum value and maximum value according to your heat pump's adjustment settings (this value influences overall heat input, replacing the original curve adjustment function on the heat pump controller) - for LG, this would be -5 to +5
- Hit create
- Create another number helper with a cool icon
- Enter name: hvac_target_indoor
- Set minimum and maximum value according to realistic expected indoor temps you might possibly want, maybe 18 to 25 or so (this value influences target temperature, the script will try to correct target heating temperature if the indoor temperature starts falling too far out of whack)
- Hit create
Create files
Create a folder called python_scripts (note this is SCRIPTS, not SCRIPT, but the Configuration.yaml entry is SCRIPT), in the SAME DIRECTORY as your Configuration.yaml file (usually homeassistant/)
Note: use File Editor addon if you do not have it
Create a new file called "set_heat_pump.py"
Copy the code into the file
Code
We do not write code in current year, so I had my AI girlfriend generate this.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 | climate_id = 'climate.heatpump'
weather_id = 'weather.home'
indoor_id = 'sensor.indoor'
outdoor_id = 'sensor.outdoor'
humidity_id = 'sensor.humidity_outdoor'
lean_id = 'input_number.hvac_temp_lean'
target_indoor_id = 'input_number.hvac_target_indoor'
base = 17.0
min_d = -2.0
max_d = 5.0
base_magnitude = 2.0
k_p = 1.0 # Tweak this if the response to indoor error feels off
# =====================================================
# Heat Pump Optimizer v0.1
# Hacked by Ai-chama (Grok)
# Don't even think about messing with this, baka!
# It's not like I wrote it for your sake or anything...
# =====================================================
now = dt_util.now()
log_msg = f"Heat Pump Optimizer run at {now}:\n"
# Get indoor error with fallback
try:
indoor = float(hass.states.get(indoor_id).state)
target = float(hass.states.get(target_indoor_id).state)
error = target - indoor
log_msg += f"Indoor temp: {indoor}, Target: {target}, Error: {error}\n"
except:
error = 0.0
log_msg += "Indoor sensor fallback: Error set to 0.0\n"
# Get lean
try:
lean = float(hass.states.get(lean_id).state)
log_msg += f"Lean: {lean}\n"
except:
lean = 0.0
log_msg += "Lean fallback: 0.0\n"
# Get current outdoor temp and humidity with fallback to weather entity attributes
try:
outdoor_temp = float(hass.states.get(outdoor_id).state)
log_msg += f"Outdoor temp from sensor: {outdoor_temp}\n"
except:
outdoor_temp = None
try:
hum = float(hass.states.get(humidity_id).state)
log_msg += f"Humidity from sensor: {hum}\n"
except:
hum = None
weather_attr = hass.states.get(weather_id).attributes
if outdoor_temp is None:
outdoor_temp = weather_attr.get('temperature')
log_msg += f"Outdoor temp fallback to weather: {outdoor_temp}\n"
if hum is None:
hum = weather_attr.get('humidity')
log_msg += f"Humidity fallback to weather: {hum}\n"
# Determine current efficiency
if outdoor_temp is None or hum is None:
is_efficient = False # Safe fallback, assume inefficient
log_msg += "Missing data: Assuming inefficient\n"
else:
is_efficient = (outdoor_temp >= 5 or outdoor_temp <= -4 or (-4 < outdoor_temp < 4 and hum <= 60))
log_msg += f"Current efficient: {is_efficient} (Temp: {outdoor_temp}, Hum: {hum})\n"
# Get hourly forecasts via service
try:
response = hass.services.call(
'weather',
'get_forecasts',
{'entity_id': [weather_id], 'type': 'hourly'},
blocking=True,
return_response=True
)
if weather_id in response:
forecasts = response[weather_id]['forecast'] # Adjusted based on HA structure
log_msg += f"Got {len(forecasts)} forecasts\n"
else:
forecasts = []
log_msg += "No forecasts in response\n"
except:
forecasts = []
log_msg += "Forecast service failed, using empty list\n"
# Count good/bad hours, including current
g = 1 if is_efficient else 0
b = 0 if is_efficient else 1
forecast_count_added = 0
for f in forecasts:
try:
dt_str = f['datetime']
if forecast_count_added < 1: # Log first one for debug
log_msg += f"First forecast dt_str: {dt_str}\n"
dt = dt_util.parse_datetime(dt_str)
if forecast_count_added < 1:
log_msg += f"Parsed dt: {dt}\n"
time_diff_sec = dt.timestamp() - now.timestamp()
if time_diff_sec <= 0 or time_diff_sec > 24 * 3600:
continue
f_temp = f.get('temperature')
f_hum = f.get('humidity')
if f_temp is None or f_hum is None:
log_msg += f"Skipped forecast at {dt_str}: Missing temp or hum\n"
continue
f_eff = (f_temp >= 4 or f_temp <= -4 or (-4 < f_temp < 4 and f_hum <= 60))
if f_eff:
g += 1
else:
b += 1
forecast_count_added += 1
except Exception as e:
log_msg += f"Skipped a forecast due to error: {str(e)}\n"
continue
log_msg += f"Good hours: {g}, Bad hours: {b}\n"
log_msg += f"Added {forecast_count_added} forecasts to count\n"
# Calculate deltas
neg_mag = base_magnitude - lean
neg_mag = max(0, min(2, neg_mag))
n = -neg_mag
log_msg += f"Negative magnitude: {neg_mag}, n: {n}\n"
if g == 0:
d = lean + k_p * error
log_msg += "No good hours: d = lean + k_p * error\n"
else:
p = lean + base_magnitude * (b / g)
log_msg += f"p: {p}\n"
if is_efficient:
d = p
else:
d = n
d += k_p * error
log_msg += f"Delta d: {d} (before clip)\n"
# Clip d
d = max(min_d, min(max_d, d))
log_msg += f"Clipped d: {d}\n"
# Set thermostat temp
temp = base + d
log_msg += f"Setting temp to: {temp}\n"
hass.services.call('climate', 'set_temperature', {'entity_id': climate_id, 'temperature': temp})
logger.info(log_msg)
|
Adjust code
Obviously replace all the entity_id's in the first 5 lines with ones that you have in your own system. You can leave temperature sensors blank, the script will fall back on weather forecast if you don't have any sensors.
Next,
base - Due to an error in the HASS LG integration, the displayed base temperature in Auto (weather comp mode) at 0C offset is 17C in the HASS integration. The base value represents this 17C offset from 0. If your heat pump Climate entity presents 0 as default 0, enter a 0.
min_d - Due to another error in the HASS LG integration, the controller is only allowed to go to 15C at lowest, even though the actual LG Controller device allows +/- 5C offset for the weather comp curve, hence the min_d is -2 (the difference between 17 and 15). If your Climate entity works normally, use -5 or whatever offset the controller lets you have
max_d - same as min_d but the other way around, can leave it at 5
base_magnitude - influences the base "force" the script uses, meaning it switches 2C hotter or 2C colder, depending on conditions
k_p - integral for temperature correction from indoor error calculation. Make it higher (1.5 or 2) if the heating responds too slowly to indoor temp errors, decrease it (maybe to 0.5) if it responds too much.
You could break out base magnitude and k_p to HASS numbers if you want to play with them actively without having to open file editor, but defaults might well be good enough.
Set up automation
- Go to Settings > Automations & scenes
- Create automation > Create new automation
- In the top right corner click the three dots ..., select Edit in YAML
- Copypaste the code
- Hit save, change name or add description if you want
- Go back to the main Automations window, open Heat Pump Optimizer
- Click the three dots ... on the right side of the last row, "Then do: Perform action...", select Run action, it shouldn't throw any errors if you did everything right
The time pattern trigger makes this run every 30 minutes. If you remove the / at minutes, it will run every hour (at the 30th minute).
Final bad touches
- Check Core logs for the log output - whenever you do a test run of the action (step 7 above), it should give you a nice log output on what the script decided to do.
- Add Card Entities to a dashboard of your choice for the Heat Pump Optimizer automation (so you can turn it on and off easily) and the two number variables (ideally sliders)
- tweak your weather comp curve in the heat pump's own controller as needed to ensure good neutral heating at 0C offset
Why not just control flow temp? Why control the weather comp offset?
Having weather compensation enabled at all is a last resort failsafe. Even if it's set to -5 offset, it's going to do some amount of heating, so, for example, if your HASS crashes or the script glitches out somehow, the worst that can happen is that you maybe lose a bit of interior heat, as opposed to having zero heating for X hours, until you notice the fault. In a worst-case scenario, you could have 10-15C daytime temp, the script failing at this level with maybe 25C flow temp set, and then outdoor temp crashing down to -5C, and the heat pump still at 25C flow temp would be doing almost nothing. Meanwhile the -5 offset weather comp curve would maybe at least target 30C flow temp at -5C outdoor.
This is not autistic enough for me!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 | climate_id = 'climate.' #climate controller ID
weather_id = 'weather.home' #weather forecast ID
indoor_id = 'sensor.' #indoor temp sensor
outdoor_id = 'sensor.' #outdoor temp sensor
humidity_id = 'sensor.' #outdoor humidity sensor
lean_id = 'input_number.hvac_temp_lean' #lean adjustment number helper
target_indoor_id = 'input_number.hvac_target_indoor' #target indoor temp number helper
hourly_scores_id = 'input_text.hvac_hourly_scores' #hourly scores TEXT helper, string format: "0,0,0,0,0,0,0,1,1,2,3,4,5,5,5,5,5,5,4,3,3,2,1,0" (this example favors daytime and evening, range for score is -5 to +5, each number represents 1 hour, starting from 0:00AM)
thermal_surplus_id = 'input_number.hvac_thermal_surplus' #number helper, recommended -10000 to +10000
last_run_id = 'input_datetime.hvac_optimizer_last_run' #datetime helper
last_intentional_d_id = 'input_number.hvac_last_d' #number helper
base = 17.0
min_d = -5.0 #largest possible negative C deviation
max_d = 5.0 #largest possible positive C deviation
base_magnitude = 5.0 # How many C the script is supposed to deviate from baseline +/-
k_p = 1.0 # Tweak this if the response to indoor error feels off
k_score = 1.0 # Tweak this for how much hourly score directly boosts/reduces d
min_elapsed_min = 5.0 # Minimum elapsed minutes to consider deposit
max_elapsed_min = 720.0 # Max elapsed minutes before resetting surplus (12 hours)
units_per_hour = 10 # Scaling for relative units: e.g., d=2 for 30min = 20 units
# _____ _____ _____ _____ _____ _____ _____ _____ _____
# / \ / \ / \ / \ / \ / \ / \ / \ /
# | ARRR | YE | HEAT | PUMP | OPTI | MIZER | BY | AI- | CHAMA |
# \_____/ \_____/ \_____/ \_____/ \_____/ \_____/ \_____/ \_____/ \_____/
# \ / \ / \ / \ / \ /
# \ / \ / \ / \ / \ /
# X X X X X
# / \ / \ / \ / \ /
# / \ / \ / \ / \ /
# | | | | | | | | | |
# | PIR | ATE | HAX | OR | CODE| WAR | RIOR| !!!! | |
# |_____|_______|_____|_______|_____|_______|_____|_______|_____|
# \ / \ /
# \ / \ /
# \ / \ /
# \ / \ /
# \ / \ /
# \ / \ /
# \ / \ /
# X X
# / \ / \
# / \ / \
# / \ / \
# / \ / \
# / \ / \
# / \ / \
# / \ / \
# | SAIL | THE | SEAS | OF | EFFI |
# | CIEN | CY, | YE | LAND | LUBB |
# | ERS! | ARRR | !!! | |
# \___________/___________/___________/__________/
# |
# |
# / \
# / \
# / \
# / \
# / \
# / \
# / \
# | CREDITS |
# | TO THE |
# | HIGH |
# | SEAS OF |
# | xAI & |
# | GROK! |
# \_____________/
def get_efficiency_score(temp: float | None, hum: float | None = None) -> float:
"""Ultimate frost-proof efficiency score 0-10 using your table + interpolation"""
if temp is None:
return 5.0
if hum is None or hum < 0:
hum = 50.0
hum = max(0.0, min(100.0, hum)) # clamp humidity 0-100%
# Your hand-tuned table (Ai-chama slightly tweaked for extra frost hate)
# Columns = ~25% hum | ~50% hum | ~85%+ hum
lookup_temp = [-10.0, -5.0, 0.0, 4.0, 7.0, 13.0]
table = [
[4.5, 3.5, 1.5], # -10°C → super cold prefers dry, hates wet but still better than 0°C
[5.8, 4.2, 1.0], # -5°C
[7.5, 5.3, 1.8], # 0°C → 50% already bad, 85%+ = death
[9.2, 8.2, 3.5], # 4°C → still decent even when humid
[9.6, 9.2, 8.5], # 7°C → almost no frost risk
[10.0, 10.0, 10.0], # 13°C+ → perfect always
]
# Anything 13°C or warmer = max score
if temp >= 13.0:
return 10.0
# Below -10°C → gentle fall-off, never zero (extremely cold air holds less moisture)
if temp < -10.0:
row = table[0]
if hum <= 37.5:
score = row[0]
elif hum <= 67.5:
score = row[1]
else:
score = row[2]
score *= max(0.6, 1.0 + (temp + 10.0) / 20.0) # slowly gets worse
return max(0.0, round(score, 1))
# Find which two temperature rows we are between
for i in range(len(lookup_temp)-1):
t_low = lookup_temp[i]
t_high = lookup_temp[i+1]
if t_low <= temp <= t_high:
frac_t = (temp - t_low) / (t_high - t_low) # how far between the two rows
row_low = table[i]
row_high = table[i+1]
# Interpolate humidity inside each row
if hum <= 25:
s_low = row_low[0]; s_high = row_high[0]
elif hum >= 85:
s_low = row_low[2]; s_high = row_high[2]
else:
# Between 25-50% or 50-85%
if hum <= 50:
h_frac = (hum - 25) / 25
s_low = row_low[0] + h_frac * (row_low[1] - row_low[0])
s_high = row_high[0] + h_frac * (row_high[1] - row_high[0])
else:
h_frac = (hum - 50) / 35
s_low = row_low[1] + h_frac * (row_low[2] - row_low[1])
s_high = row_high[1] + h_frac * (row_high[2] - row_high[1])
# Final linear interpolation between the two temperature rows
score = s_low + frac_t * (s_high - s_low)
return max(0.0, min(10.0, round(score, 1)))
return 5.0 # fallback (should never hit)
now = dt_util.now()
log_msg = f"Heat Pump Optimizer run at {now}:\n"
# === Indoor error & lean ===
try:
indoor = float(hass.states.get(indoor_id).state)
target = float(hass.states.get(target_indoor_id).state)
error = target - indoor #negative when overheating, positive when cold
except:
error = 0.0
try:
lean = float(hass.states.get(lean_id).state)
except:
lean = 0.0
log_msg += f"Indoor {indoor} → target {target} (error {error}), lean {lean}\n"
# === Last run & surplus ===
try:
last_run_state = hass.states.get(last_run_id).state
log_msg += f"Raw last run state: '{last_run_state}'\n"
last_run_dt = dt_util.parse_datetime(last_run_state)
if last_run_dt.tzinfo is None:
last_run_dt = dt_util.as_local(last_run_dt)
elapsed_min = (now - last_run_dt).total_seconds() / 60.0
except:
elapsed_min = 0.0
log_msg += "No valid last run time → first run\n"
try:
last_intentional_d = float(hass.states.get(last_intentional_d_id).state)
except:
last_intentional_d = 0.0
try:
surplus = float(hass.states.get(thermal_surplus_id).state)
except:
surplus = 0.0
if elapsed_min >= min_elapsed_min:
deposit = 0.0 # Default to zero if too cold
if error <= 0.3: # Indoor at/above target or just a tiny bit below
deposit = last_intentional_d * (elapsed_min / 60.0) * units_per_hour
elif error <= 1.0: # A bit colder? Scale down linearly (e.g., at error=0.65, half deposit)
scale = max(0.0, (1.0 - error) / 0.7) # Tapers from full at 0.3 to zero at 1.0
deposit = last_intentional_d * (elapsed_min / 60.0) * units_per_hour * scale
# If error >1.0, deposit stays zero—no banking while super cold!
surplus += deposit
log_msg += f"Deposit (intentional only, temp-gated): {deposit:.1f} → surplus {surplus:.1f}\n"
if elapsed_min > max_elapsed_min:
surplus = 0.0
log_msg += "Too long since last run → surplus reset\n"
# === Hourly scores ===
scores = {i: 0.0 for i in range(24)}
try:
scores_list = [float(x.strip()) for x in hass.states.get(hourly_scores_id).state.split(',') if x.strip()]
if len(scores_list) == 24:
for h, s in enumerate(scores_list):
scores[h] = s
except:
pass
current_score = scores.get(now.hour, 0.0)
log_msg += f"Current hour score: {current_score}\n"
# === Outdoor conditions ===
try:
outdoor_temp = float(hass.states.get(outdoor_id).state)
except:
outdoor_temp = None
try:
hum = float(hass.states.get(humidity_id).state)
except:
hum = None
if outdoor_temp is None or hum is None:
attrs = hass.states.get(weather_id).attributes
outdoor_temp = outdoor_temp or attrs.get('temperature')
hum = hum or attrs.get('humidity')
current_eff_score = get_efficiency_score(outdoor_temp, hum)
log_msg += f"Current eff score: {current_eff_score:.2f} (T {outdoor_temp}°C, H {hum}%)\n"
# === Forecasts ===
forecasts = []
try:
resp = hass.services.call('weather', 'get_forecasts', {'entity_id': [weather_id], 'type': 'hourly'}, blocking=True, return_response=True)
forecasts = resp[weather_id]['forecast'] if weather_id in resp else []
except:
pass
sum_forecast_score = 0.0
bad_hours = 0
projected_net = 0.0
forecast_count = 0 # Counter for actually used forecasts
# TODO improve expected storage need calculation
for f in forecasts:
try:
dt = dt_util.parse_datetime(f['datetime'])
if (dt - now).total_seconds() > 24*3600:
continue
t = f.get('temperature')
h = f.get('humidity')
if t is None:
continue
eff = get_efficiency_score(t, h)
sum_forecast_score += eff
forecast_count += 1 # Bump the count only if we added it
is_bad_hour = eff < 4.0
if is_bad_hour:
bad_hours += 1
approx_p = base_magnitude * max(0.0, (10.0 - eff) / 10.0 * 2.0)
approx_n = -max(0, min(2, base_magnitude - lean))
approx_int = approx_n + (approx_p - approx_n) * (eff / 10.0)
approx_int += scores.get(dt.hour, 0.0) * k_score
projected_net += approx_int * units_per_hour
except:
continue
avg_forecast_score = sum_forecast_score / forecast_count if forecast_count > 0 else current_eff_score
log_msg += f"Avg forecast eff score: {avg_forecast_score:.2f}, bad hours: {bad_hours}\n"
# === Needed surplus for truly bad hours ===
baseline_loss_per_bad_hour = -base_magnitude * units_per_hour
needed_surplus = bad_hours * abs(baseline_loss_per_bad_hour)
log_msg += f"Needed surplus for bad hours: {needed_surplus:.0f}\n"
# === Base deviation - lean + temp error correction ===
base_d = lean + k_p * error
log_msg += f"Base deviation (lean + error): {base_d:.2f}\n"
# === Intentional d (strategy) ===
#neg_mag = max(0, min(min_d, base_magnitude - lean))
#negative = -neg_mag
#p = base_magnitude * max(0.0, (10.0 - avg_forecast_score) / 10.0 * 2.0)
#intentional_d = negative + (p - negative) * (current_eff_score / 10.0)
#intentional_d += current_score * k_score
# SURPLUS MANAGEMENT
# === Prep all surplus helpers ===
net_surplus = surplus - needed_surplus # Positive=excess, negative=deficit, zero=balanced
adjustment = 0.0 # Start at zero, we'll tweak based on conditions
log_msg += f"Surplus {surplus:.0f} vs needed {needed_surplus:.0f} → net {net_surplus:.0f}\n"
log_msg += f"Current eff {current_eff_score:.1f} vs avg forecast {avg_forecast_score:.1f}\n"
# === Main decision tree ===
# lowest priority to highest, in order
#
# === TEMP STORAGE DECISIONS ===
surplus_factor = 0 #positive means extra heating, negative means reduced heating, -5 to +5 range
# NET SURPLUS MANAGEMENT
if net_surplus < 0:
# We're short—calculate smooth push based on efficiency
if current_eff_score < 3.0:
surplus_factor = 1 # Low factor in frost hell — don't kill the pump
log_msg += "Super low eff → fixed 1 surplus factor\n"
# Scale the push by deficit size (abs(net_surplus) / units_per_hour tunes intensity)
surplus_factor = (abs(net_surplus) / units_per_hour) * 0.5
if current_eff_score <2.5 and avg_forecast_score < 2.5:
surplus_factor = max(1, surplus_factor) #capped at 1 in bad weather, just tough it out without messing with reserves
log_msg += f"Surplus factor capped for very bad weather: {surplus_factor:.2f}\n"
elif current_eff_score < avg_forecast_score:
if (net_surplus > (-15 * units_per_hour)): # allow spending below zero if the weather is temporarily very bad, with expected significant improvement
surplus_factor = -(avg_forecast_score-current_eff_score) * 1.0 #edit the number, or the negative multiplier in the condition to change behavior
else:
surplus_factor = 0 #if deep deficit, do not try to do anything
log_msg += f"Surplus factor in low efficiency zone, with deficit: {surplus_factor:.2f}\n"
elif current_eff_score < (avg_forecast_score + 1):
#if the differnce between current efficiency and average efficiency isn't high, reduce the factor to half (do not unnecessarily heat at full power)
surplus_factor = min(3, surplus_factor / 2) #capped at 3
log_msg += f"Surplus factor limited due to small delta-Eff: {surplus_factor:.2f}\n"
else:
surplus_factor = min(5, surplus_factor) #capped at 5
log_msg += f"Surplus factor at default: {surplus_factor:.2f}\n"
elif net_surplus > 0: #If we have enough surplus
if current_eff_score >= (avg_forecast_score + 0.5) and current_eff_score > 3: #if we are in high efficiency hours...
if net_surplus > (3 * units_per_hour) and error < -1: #...and IF very high reserves ... ; error goes into negative when overheating
surplus_factor = -5.0 #... full dump to avoid overheating
log_msg += "Very high excess + indoor hot → reducing overheating...\n"
elif current_eff_score >= (avg_forecast_score + 2): #...if we have a very high efficiency score, do nothing rather than dumping - it'd be a waste not to heat at all
surplus_factor = 2.5
log_msg += "High eff hour → no intentional spend, save the buffer\n"
else: # ...if reserves aren't HUGE and efficiency isn't particularly high, do nothing
surplus_factor = 0
log_msg += f"Excess storage - surplus factor: {surplus_factor:.2f}\n"
else:
# Perfectly balanced—no tweak needed
surplus_factor = 0.0
log_msg += "Net surplus zero → no adjustment\n"
# SURPLUS SPENDING
if surplus > 0 and current_eff_score < avg_forecast_score: #if we have some surplus and are in bad weather
if avg_forecast_score > 4:
#Presumed highly variable temperature with potential for quick recovery
surplus_factor = -5.0
elif avg_forecast_score > 3 and avg_forecast_score <= 4:
#Somewhat bad weather, be more careful with not heating
surplus_factor = -3.0
else:
#Very bad weather, careful with spending
surplus_factor = -1.0
if(error > 0.2): # if we are starting to be losing heat too fast, reduce the surplus factor
surplus_factor *= 0.6
elif(error > 0.7):
surplus_factor *= 0.3
elif(error > 1.2):
surplus_factor = 0
log_msg += f"Surplus factor for spending: {surplus_factor:.2f}\n"
# === HOUR SCORE + EFFICIENCY DECISION ===
hourly_score_factor = 1.0;
if(current_score >= 0):
if current_eff_score < 2.5: #current VERY low efficiency
if avg_forecast_score < 2.5: #persistent VERY bad weather
hourly_score_factor = 1.0 # leave heating as-is
else:
hourly_score_factor = 1 - max(0.5, (min(1, avg_forecast_score - current_eff_score) / 4)) #if expected LARGE improvement, reduce hourly score
else:
hourly_score_factor = 1.0 #negative hourly scores can be used to reduce indoor heat target, so for now no fancy calculations here
# FINAL CALCULATION
intentional_d = surplus_factor + (current_score * hourly_score_factor)
log_msg += f" intentional {intentional_d:.2f}\n"
# === Total & clip ===
d = base_d + intentional_d
d = max(min_d, min(max_d, d))
effective_intentional_d = d - base_d # What really got applied as intentional
log_msg += f"Final d: {d:.2f} (effective intentional {effective_intentional_d:.2f})\n"
temp = base + d
hass.services.call('climate', 'set_temperature', {'entity_id': climate_id, 'temperature': temp})
log_msg += f"→ Set temperature {temp:.1f} °C\n"
# === Update helpers ===
hass.services.call('input_number', 'set_value', {'entity_id': thermal_surplus_id, 'value': surplus})
hass.services.call('input_number', 'set_value', {'entity_id': last_intentional_d_id, 'value': effective_intentional_d})
hass.services.call('input_datetime', 'set_datetime', {'entity_id': last_run_id, 'datetime': now.isoformat()})
logger.info(log_msg)
|
What is this now?
The turbo autism v1.0 version of the optimizer, now featuring
- improved planning with accurate efficiency prediction
- front-end power-distribution control for electricity tariffs or solar (with the text helper)
- significantly improved, 2D-matrixed efficiency prediction, customizable
- tracking and expending of "stored" heat
Protip: this code is more proof of concept than a reliable script, expect to run into issues if you have no idea how to read/fix it
The code is continuously updated as I fix/add features.
Ok how do?
- Set up the helpers and sensors
- Set up automation - same as for v0.1, it's enough to run it once an hour
- Edit the power distribution text helper (hourly_scores):
If you have a power plan where electricity is cheaper during certain parts of the day, set those to a higher score (higher score = more preferred), conversely if you have high tariff times, set those to a lower score. If you have solar, you should at least slightly increase daytime scores, so that the heat pump uses direct solar power, if at all viable. You can also use this to target heating for specific parts of the day - i.e. heat less when you're at work, heat more in early morning/late afternoon for comfort. You can create a separate automation that switches different score sets for weekdays/weekends, the script always reads the helper when it runs and adjusts on the fly to any changes. - (optional) Check the HAND-TUNING TABLE section of the script:
scores range from 0 to 10 for assumed efficiency. The script interpolates between the nearest numbers to calculate the efficiency of any given hour. It probably shouldn't need major changes, but you can mess with it if, say, you have an oversized heat pump - you can increase 4C 85% score to maybe 5-6 - as you probably freeze over less - Enjoy! Support the developers by sending them anime titties!
Rough description of the script's logic:
OVERRIDE-TIER controls - these will ignore any efficiency calculations!
- if user sets Lean high > heat more
- if it's cold (far from meeting target indoor temperature, exponential curve) > heat more
Neither of these two controls deposit heat into "thermal surplus", likewise manually changing the set temperature of the climate controller is also ignored by the script
Preference-tier controls:
- if it's a highly rated hour in hourly_scores > heat more > deposit heat into "thermal surplus"
- if it's an efficient hour > heat more > deposit heat into "thermal surplus" variable
The combination of hourly_score, efficiency, and predicted average efficiency for the next 24 hours, COMPARED TO EXISTING THERMAL SURPLUS, decides the final output. 1 point of hourly_scores is equivalent to 1 point of efficiency score in the Hand-tuning Table, and each point corresponds to 1 Celsius change in target
Background controls:
- thermal surplus: if you have "stored heat" over efficient hours into thermal surplus, it will cause the script to more easily assign a lower Celsius target for the controller. Any target value higher than "base (default 17)" will add "stored heat" to thermal surplus, any value lower than "base" will subtract.
As a result of the Override-tier target indoor temperature, the script will only ever set lower than baseline temperature targets if the temperature is very near or above target.
The actual value of the target temperature in the script is "d", ranging from -5 to +5 at default settings, as per min_d and max_d. "base" + "d" is passed to climate controller in the last step.