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:

1
2
3
4
5
6
python_script: 

logger:
    default: critical 
    logs:  
        homeassistant.components.python_script: info

Note that you can have any other levels/logs set if you want/need, only that at least "logger:" should be present

Create variables

  1. Go to Settings > Devices > Helpers
  2. Click Create helper
  3. Select number
  4. Enter name: hvac_temp_lean
  5. Pick a cool icon with sunglasses
  6. 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
  7. Hit create
  8. Create another number helper with a cool icon
  9. Enter name: hvac_target_indoor
  10. 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)
  11. 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.

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,

1
2
3
4
5
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

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

  1. Go to Settings > Automations & scenes
  2. Create automation > Create new automation
  3. In the top right corner click the three dots ..., select Edit in YAML
  4. Copypaste the code
    alias: Heat Pump Optimizer
    description: ""
    triggers:
      - trigger: time_pattern
        hours: "*"
        minutes: /30
    conditions: []
    actions:
      - action: python_script.set_heat_pump
    mode: single
    
  5. Hit save, change name or add description if you want
  6. Go back to the main Automations window, open Heat Pump Optimizer
  7. 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 zerono 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 shortcalculate 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?

  1. Set up the helpers and sensors
  2. Set up automation - same as for v0.1, it's enough to run it once an hour
  3. 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.
  4. (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
  5. 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.

Edit

Pub: 20 Oct 2025 17:21 UTC

Edit: 28 Dec 2025 18:59 UTC

Views: 47