Optimizing HVAC Energy Savings with Nordpool 15-min Pricing *Part 1-3: Theory, Implementation and Examples*

Hi Niko, it is not working as expexted - could you check your other code also? Im aiming to list the 32 most expensive 15 min chunks and cut only max 2 chunks in a row.


As you can see, the 32 expensive chunks are today between 17:00 and 00:00, but the cutoff binary is active now…

Not good :frowning:

Copilot advised: the only change needed is to switch from dot‑notation (, , ) to dict‑style access (, , ).

  • slots[i].value → slots[i]['value']
  • slots[i].start → slots[i]['start']
  • slots[i].end → slots[i]['end']
    Trying that now :slight_smile:

Hello again @0PK!

Nice to see you’re debugging the algorithm yourself with AI-tools! The problem was not as simple as I thought in the beginning - I went to same rabit hole as I’ve been going with other renking/ sorting algorithms and nordpool prices in lists and dictionaries. There as so many caveats that one needs to debug all the changes everytime and this was the same. BUT, I think we have now

:tada: SUCCESS! The algorithm now works perfectly!

I’ve fixed the core logic based on expert review. Testing confirms:

:white_check_mark: Results:

  • Selected 32/32 slots (target achieved!)
  • All slots from expensive range (0.9-3.6 c/kWh)
  • Max 2 consecutive respected throughout
  • DST-safe timestamp detection
  • Current time correctly identified

What changed:

  1. Process ENTIRE ranking (not just top-32 first)
  2. Check both LEFT and RIGHT consecutive runs
  3. Fill exactly 32 slots while respecting max_seq

The template below is production-ready:

template:
  - trigger:
      - platform: time_pattern
        minutes: "/1"
      - platform: state
        entity_id: sensor.nordpool_kwh_fi_eur_3_10_0  # ← YOUR sensor
    binary_sensor:
      - name: hvac_cutoff_now
        unique_id: hvac_cutoff_now
        state: >-
          {% set SRC = 'sensor.nordpool_kwh_fi_eur_3_10_0' %}
          {% set target_slots = 32 %}
          {% set max_seq = 2 %}  {# 2 = 30min, 4 = 1h max consecutive #}
          
          {% set slots = state_attr(SRC, 'raw_today') %}
          
          {% if slots is not iterable or slots|length < 20 %}
            false
          {% else %}
            {% set ns = namespace(pairs=[], now_idx=None, picks=[], chosen=[]) %}
            
            {# Build price pairs with dict access #}
            {% for i in range(slots|length) %}
              {% set v = (slots[i]['value'] | float(0)) %}
              {% set ns.pairs = ns.pairs + [{'i': i, 'v': v}] %}
            {% endfor %}
            
            {# Find current slot (DST-safe) #}
            {% set now_ts = as_timestamp(now()) %}
            {% for i in range(slots|length) %}
              {% set st = as_timestamp(slots[i]['start']) %}
              {% set en = as_timestamp(slots[i]['end']) %}
              {% if now_ts >= st and now_ts < en %}
                {% set ns.now_idx = i %}
              {% endif %}
            {% endfor %}
            {% if ns.now_idx is none %}
              {% set ns.now_idx = ((now().hour*60 + now().minute) // 15) %}
            {% endif %}
            
            {# Rank ALL slots by price (descending) #}
            {% set ranked = ns.pairs | sort(attribute='v', reverse=true) %}
            
            {# Greedy selection: left+right contiguous guard #}
            {% for p in ranked %}
              {% if ns.picks|length >= target_slots %}{% break %}{% endif %}
              {% set i = p.i %}
              
              {# Count left consecutive #}
              {% set left = 0 %}
              {% for k in range(1, max_seq + 1) %}
                {% if (i - k) in ns.chosen %}
                  {% set left = left + 1 %}
                {% else %}
                  {% break %}
                {% endif %}
              {% endfor %}
              
              {# Count right consecutive #}
              {% set right = 0 %}
              {% for k in range(1, max_seq + 1) %}
                {% if (i + k) in ns.chosen %}
                  {% set right = right + 1 %}
                {% else %}
                  {% break %}
                {% endif %}
              {% endfor %}
              
              {# Allow if total <= max_seq #}
              {% if (left + 1 + right) <= max_seq %}
                {% set ns.picks = ns.picks + [i] %}
                {% set ns.chosen = ns.chosen + [i] %}
              {% endif %}
            {% endfor %}
            
            {{ ns.now_idx in ns.chosen }}
          {% endif %}
        attributes:
          selected_slots: >-
            {% set SRC = 'sensor.nordpool_kwh_fi_eur_3_10_0' %}
            {% set target_slots = 32 %}
            {% set max_seq = 2 %}
            {% set slots = state_attr(SRC, 'raw_today') %}
            
            {% if slots %}
              {% set ns = namespace(pairs=[], picks=[], chosen=[]) %}
              
              {% for i in range(slots|length) %}
                {% set v = (slots[i]['value'] | float(0)) %}
                {% set ns.pairs = ns.pairs + [{'i': i, 'v': v}] %}
              {% endfor %}
              
              {% set ranked = ns.pairs | sort(attribute='v', reverse=true) %}
              
              {% for p in ranked %}
                {% if ns.picks|length >= target_slots %}{% break %}{% endif %}
                {% set i = p.i %}
                
                {% set left = 0 %}
                {% for k in range(1, max_seq + 1) %}
                  {% if (i - k) in ns.chosen %}
                    {% set left = left + 1 %}
                  {% else %}
                    {% break %}
                  {% endif %}
                {% endfor %}
                
                {% set right = 0 %}
                {% for k in range(1, max_seq + 1) %}
                  {% if (i + k) in ns.chosen %}
                    {% set right = right + 1 %}
                  {% else %}
                    {% break %}
                  {% endif %}
                {% endfor %}
                
                {% if (left + 1 + right) <= max_seq %}
                  {% set ns.picks = ns.picks + [i] %}
                  {% set ns.chosen = ns.chosen + [i] %}
                {% endif %}
              {% endfor %}
              
              {{ ns.picks | sort }}
            {% else %}
              []
            {% endif %}

Configuration:

  • target_slots: Number of 15-min periods (32 = 8 hours)
  • max_seq: Max consecutive slots (2 = 30min, 4 = 1h)
  • Replace sensor name with yours

Features:

:white_check_mark: Exactly 32 most expensive slots
:white_check_mark: Max 2 consecutive (adjustable)
:white_check_mark: DST-safe (handles 92/96/100 slot days)
:white_check_mark: Dict-access syntax (no Jinja bugs)
:white_check_mark: Namespace accumulation (scope-safe)
:white_check_mark: Debug attribute shows selected indices

Usage:

  1. Add to configuration.yaml under template:
  2. Restart Home Assistant
  3. Check binary_sensor.hvac_cutoff_now state
  4. View selected_slots attribute for indices
  5. Use in automations to control your loads

Verified working with testing on DST transition day (26.10.2025, 100 slots). Let me know how it works! :+1:

Do you know the Cheapest Hours Ninja macro?
TheFes/cheapest-energy-hours: Jinja macro to find the cheapest energy prices

With these few simple lines of code you will get the same result:

{% from 'cheapest_energy_hours.jinja' import cheapest_energy_hours %}
{{ cheapest_energy_hours(
    sensor='sensor.zonneplan_current_electricity_tariff',
    hours=8,
    lowest=false,
    attr_all='forecast',
    look_ahead=false,
    split=true,    
    value_key='electricity_price',
    mode='all'
  ) }}

This is the result for now:

[
  {
    "start": "2025-10-27T08:00:00+01:00",
    "end": "2025-10-27T10:00:00+01:00",
    "hours": 2,
    "prices": [
      2567546,
      2576680
    ],
    "is_now": false
  },
  {
    "start": "2025-10-27T16:00:00+01:00",
    "end": "2025-10-27T22:00:00+01:00",
    "hours": 6,
    "prices": [
      2640840,
      2757455,
      2968298,
      2876882,
      2591230,
      2517662
    ],
    "is_now": false
  },
  {
    "total_hours": 8,
    "datapoints_per_hour": 1,
    "datapoints": 8
  }
]

It is compatible with entsoe and nordpool sensors. check it out.

You can paste the following in a boolean helper to get a true/false to trigger expensive/cheap hours.

{% from 'cheapest_energy_hours.jinja' import cheapest_energy_hours %}
{{ cheapest_energy_hours(
    sensor='sensor.zonneplan_current_electricity_tariff',
    hours=8,
    lowest=false,
    attr_all='forecast',
    look_ahead=false,
    split=true,    
    value_key='electricity_price',
    mode='is_now'
  ) }}

(only thing I changed is mode='is_now')

Yeah that is someyhing completely different then Im after.

Btw “solution” didnt work…
…yet. hvac_cutoff:now follows all slots. Eg today 12:00-13:15.

{% if (left + 1 + right) <= max_seq %}

prevents selecting a slot if it would create or extend a sequence longer than . But this also blocks future picks that could have been valid if chosen in a different order.
For example, if max_seq = 2 and you already have:

chosen = [3, 4]

Then slot 5 is blocked because:
• left=2 (slots 3 and 4 are chosen)
• right=0
• left +1+right=3>2→ :x: rejected
But if you had picked 5 before 4 or 3, it might have been allowed. This is a classic greedy algorithm limitation — it doesn’t backtrack or optimize globally.

Solution: Instead of checking (left+1+right) , track runs of consecutive picks and ensure no run exceeds max_seq.

Testing now :slight_smile:

Excellent analysis! You’ve identified the exact limitation - the greedy algorithm makes locally optimal decisions that block globally optimal solutions.

Your example is perfect:


If chosen = and max_seq = 2:
→ Slot 5 is BLOCKED (would make 3 consecutive)
→ But might be MORE expensive and save MORE money!

Two Approaches for Your Use Case:

:bullseye: Template Approach (What We’ve Been Debugging)

Best for: “Turn off during top-N slots with max consecutive limit”

Pros:

  • :white_check_mark: Pure template, no Python needed
  • :white_check_mark: Simple to understand and debug
  • :white_check_mark: Works in Developer Tools template editor

Cons:

  • :cross_mark: Greedy selection (your blocking issue)
  • :cross_mark: May not maximize total savings

Reality check: With max_seq=2 and clustered expensive periods, you’ll get ~20-24 slots instead of 32. This is mathematically correct for the greedy algorithm - it’s finding valid solutions but not globally optimal ones.


:trophy: Python Script Approach (From My GitHub)

Best for: “Maximize total € savings with globally optimal periods”

The Python script uses Dynamic Programming instead of greedy selection, which solves the blocking problem. However, it’s designed for HVAC thermal modeling (preheat + cutoff + recovery phases), which is different from your simple “cutoff-only” use case.

Note: My Python implementation assumes building thermal mass modeling. If you just want “turn off during expensive slots” without thermal considerations, the template approach is more appropriate for your needs.


My Recommendation:

Given your specific goal (“maximize cutoff during expensive slots, no thermal modeling”):

Adjust expectations with the template approach:

  • Accept that with max_seq=2, you’ll get ~20-24 slots (not exactly 32)
  • The selection will be “good” but not globally optimal
  • It’s simple, works in templates, and solves 90% of your need
  • This is the right tool for simple “off during expensive periods” logic

The Python script is designed for different use case:

  • Full HVAC thermal optimization
  • Preheat + cutoff + recovery phases
  • Building thermal mass modeling
  • More complex than what you need

Bottom Line:

For your “simple cutoff-only” approach, the template solution we debugged is the right tool - just understand its limitations (greedy selection means ~22 slots with max_seq=2, not 32).

If you need exactly 32 slots regardless of clustering, increase max_seq to 4 or remove the consecutive limit entirely. The trade-off is between “strict consecutive limits” and “exact slot count” - you can’t have both when prices cluster.

Hope this clarifies the different approaches and have fun testing out the system! :+1:

Thanks, but the real issue was, not the amoint of slots, but the fact that it would cut off for more than 2 15min parts in a row. The attribute shows the selected 32 possible slots. How to make an attribute that shows the slots that will be cut_off? Having that would speed up testing a lot :slight_smile:

Oh this is just excellent! I was looking to do something similar but way less advanced as I am not even half good enough at programming to make something like this work. Very, very good timing

I have found some small errors while I am testing this out. Do you want them here or in github? I would be willing to do a pull request (that is the name for fixing and asking to merge corrections right?), but bear with me, it would be my first contribution ever :sweat_smile:

The errors I found so far are

  1. 2 (possibly 4 depending on what the intention is) in the script file
  2. A helper seems to be missing from the documentation
  3. A naming error for one helper
  4. A small error in the template sensor in the doc, which makes it not render correctly

There is also a small thing in the documentation that would help people set up easier.

My production version:

trigger:
  - platform: time_pattern
    minutes: "/1"
  - platform: state
    entity_id: sensor.nordpool

binary_sensor:
  - name: hvac_cutoff_now
    unique_id: hvac_cutoff_now
    state: >-
      {% set SRC = 'sensor.nordpool' %}
      {% set target_slots = 32 %}
      {% set max_sequential = 2 %}
      {% set values = (state_attr(SRC, 'today') | tojson | from_json) %}
      {% if values is iterable and values|length == 96 %}
        {% set ns = namespace(pairs=[], now_idx=None, selected=[], co_list=[]) %}
        {% set start = as_timestamp(now().replace(hour=0, minute=0, second=0, microsecond=0)) %}

        {# Build index–value–start triples #}
        {% for i in range(values|length) %}
          {% set ns.pairs = ns.pairs + [
            {'i': i,
             'v': values[i]|float(0),
             'start': start + i*900}
          ] %}
        {% endfor %}

        {# Find current slot index #}
        {% set now_ts = as_timestamp(now()) %}
        {% for p in ns.pairs %}
          {% if now_ts >= p.start and now_ts < (p.start+900) %}
            {% set ns.now_idx = p.i %}
          {% endif %}
        {% endfor %}
        {% if ns.now_idx is none %}
          {% set ns.now_idx = 0 %}
        {% endif %}

        {# Build top N list #}
        {% set sorted_pairs = ns.pairs | sort(attribute='v', reverse=true) %}
        {% for p in sorted_pairs %}
          {% if loop.index <= target_slots %}
            {% set ns.selected = ns.selected + [p.i] %}
          {% endif %}
        {% endfor %}

        {# Build co_list dynamically based on max_sequential #}
        {% for idx in range(96) %}
          {% if idx in ns.selected %}
            {% for step in range(1, max_sequential+1) %}
              {% if (idx - step) >= 0 and (idx - step) not in ns.co_list %}
                {% set ns.co_list = ns.co_list + [idx] %}
              {% endif %}
            {% endfor %}
          {% endif %}
        {% endfor %}

        {{ 'on' if ns.now_idx in ns.co_list else 'off' }}
      {% else %}
        off
      {% endif %}
    attributes:
      selected_indices: "{{ ns.selected if ns is defined else [] }}"
      cutoff_list: "{{ ns.co_list if ns.co_list is defined else [] }}"

Thanks for sharing your production code! Let me walk through what it actually does to make sure we’re all on the same page about the behavior:

:counterclockwise_arrows_button: How Your Template Works

The System’s Behavior:

Step 1: The template identifies the 32 most expensive 15-minute price slots from today’s Nordpool data.

Step 2: For each of these expensive slots, the template checks if there are valid slots before it (within the max_sequential range). When this check passes, the expensive slot is added to the cutoff activation list. This happens multiple times for each slot, resulting in the list containing your top-32 expensive periods.

Step 3: The binary sensor turns ON (cutoff active) when the current time matches any slot in this activation list.

Practical HVAC Behavior:

During the 32 most expensive price periods:

  • Sensor state: ON
  • HVAC cutoff: ACTIVE

During all other times:

  • Sensor state: OFF
  • HVAC: Normal operation

:clipboard: Summary

Based on the code logic, your HVAC system will run in cutoff mode during the 32 most expensive 15-minute periods each day, and operate normally during all other times.

Is this the exact behavior you intended? I want to make sure others reading your code know exactly what you’re trying to achieve and how the logic works. Great job @0PK building your own production solution! :+1:

Hi,

Just wanted to let you know I’ve implemented this for my home assistant. I’m working with hybrid heatpump that heats up the floor of my home. I’m now simulating that the thermostat is lowered with 2 degrees during cutoff and is plussed with 1 degree (on top of target temperature) during preheating. No results just yet as it is in testing.

Any suggestions on this setup?

Hi @kayHA!

Thank you for trying out the cutoff-system. I would suggest, if you have observed as a “dry-run” the sensor work as you think it should, you go ahead and start using the cutoff-times. When you have a bit more data, you can start calibrating your system as described at the article part 3:

Is there anything spesific you’re wondering about calibration process?

I believe you originally inteded this project to work for airconditioners? As my heatpump is heating up the floor the conserved thermal energy in the floor is very high. I was wondering if that would change the parameters?

Next to that I don’t have a sensor that measures the exact amount of kWh that the heatpump uses. So it is hard to exactly pinpoint it.

I am wondering though, in the Netherlands temperatures are currently around 12C. Cooldown of the house in -5C is probably a lot quicker. It would be great of the algorithm would take this into account and make the cutoff periods shorter.

For visualisation purposes I have created this graph to display the heating strategy against my energy prices. See below, I can share source code if anyone is interested.

Right in to the point @kayHA!

In matter of fact I’ve been doing exactly you thought that in the winter time I have shorter cutoff periods configured. I have one simple HASS input_number representing maximum cutoff times which I just change with automation to 3-4. As I suggest on the calibration section, one truly needs to observe one’s own house how it behaves during the cutoffs at different outside conditions. After some own data, one can tune the algorithm to suite one’s spesific needs.

:tada: v2.0.0 Release Now Live!

Major update just published!

The Nordpool Cutoff Optimizer has been upgraded to v2.0.0 with significant improvements and bug fixes.

:link: GitHub Release

nordpool-spot-cutoff-optimizer v2.0.0

:sparkles: What’s New in v2.0.0

:wrench: Configurable Nordpool Sensor - No more code editing!

  • Works with different HACS Nordpool sensor names across regions
  • Three-tier resolution: service data → input_text → automatic fallbacks
  • Supports FI, SE, DK, NO sensor naming variations

:bar_chart: Mixed Resolution Support - Handles both 15-min and hourly data automatically

  • Today 15min + Tomorrow 60min? No problem!
  • Automatic normalization to 15-minute precision

:thermometer: Weather-Adaptive Multipliers - Dynamic cost adjustment (5 temperature bands)

  • Extreme cold (<-10°C): 1.45× preheat / 1.20× recovery
  • Warm (>7°C): 1.08× preheat / 1.04× recovery
  • Hard failsafe for missing temperature sensors

:chart_increasing: Enhanced Telemetry - Comprehensive diagnostics

  • Which Nordpool sensor is being used (nordpool_entity, nordpool_source)
  • Data quality indicators (data_resolution, today_slot_minutes)
  • Cost model transparency (preheat_mult, recovery_mult, multiplier_source)

:bug: Critical Bug Fixes

  • Template compatibility: shutdown_end → recovery_start
  • Midnight crossover and date seam handling
  • Attribute naming consistency (cost_saving_percent, shutdown_duration_hours)

:books: Documentation Updated

All three parts of this guide are now v2.0 compatible:

  • :white_check_mark: Part 1: Theory (updated with v2.0 features)
  • :white_check_mark: Part 2: Implementation (v2.0 templates and configuration)
  • :white_check_mark: Part 3: Integration Examples (v2.0-compatible automations)

:warning: Breaking Changes

If upgrading from v1.x:

  1. Replace Python script: /config/python_scripts/nordpool_cutoff_optimizer.py
  2. Update template sensors: shutdown_end → recovery_start
  3. Update dashboard attributes: savings_pct → cost_saving_percent

See Migration Guide for details.

:rocket: Get Started

New users: Follow the updated Part 2 and Part 3 guides.

Existing users: Check the release notes for migration steps.


Questions or issues with v2.0? Reply below! :speech_balloon:

Realy nice work and what a work it must have been. Would love to try it out, but copying from github is something I find quite difficult. Not a lot of expiriance with jet. Is there any chance that it will soon affaliable as a bleuprint of HACS reposoterry ? (sorry for my bad Englisch writhing)

Stumbled upon this project while looking how to use Nordpool Core integration when switching to dynamic 15min pricing (Belgium), to determine when to charge batteries on low prices to be able to lower cost during peak hours while i.e. cooking, or to pre heat the bathroom in the morning.

I was able to get a full price list using the nordpool.get_price_indices_for_date action and your logic to get the json in attributes. I devided by 10 , not by 1000 to go from euro/mwh to c/kwh).

The current problem I see is that HA refuses to execute the python_script.nordpool_cutoff_optimizer action in Developer tools : Not allowed to import sys
Seems like HA Python_Script does not allow importing? (I am on version 2025.12.3 and Home Assistant OS).

Currently investigating how to proceed from here ; writing logic in scripts or maybe using pyscript…

@OVE, if you manage to implement script to core nordpool integration, I would love to see how you did it! Good luck and happy coding!

friendly_name: Nordpool Cutoff Periods
periods:
  - preheat_start: "2025-12-16T23:30:00+00:00"
    shutdown_start: "2025-12-17T04:30:00+00:00"
    recovery_start: "2025-12-17T09:30:00+00:00"
    recovery_end: "2025-12-17T14:30:00+00:00"
    cost_saving: 48.902
    cost_saving_percent: 32.1
    shutdown_duration_hours: 5
    shutdown_duration_text: 5h
    details:
      total_cost_with_shutdown: 103.209
      total_cost_without_shutdown: 152.111
      price_difference: 1.161
      adjusted_min_price_diff: 0.6
    idx: 22
    len: 20
  - preheat_start: "2025-12-17T14:30:00+00:00"
    shutdown_start: "2025-12-17T19:00:00+00:00"
    recovery_start: "2025-12-17T23:30:00+00:00"
    recovery_end: "2025-12-18T04:00:00+00:00"
    cost_saving: 34.382
    cost_saving_percent: 31.8
    shutdown_duration_hours: 4.5
    shutdown_duration_text: 4h 30min
    details:
      total_cost_with_shutdown: 73.691
      total_cost_without_shutdown: 108.073
      price_difference: 0.948
      adjusted_min_price_diff: 0.667
    idx: 80
    len: 18
  - preheat_start: "2025-12-18T04:15:00+00:00"
    shutdown_start: "2025-12-18T06:15:00+00:00"
    recovery_start: "2025-12-18T08:15:00+00:00"
    recovery_end: "2025-12-18T10:15:00+00:00"
    cost_saving: 16.426
    cost_saving_percent: 38.1
    shutdown_duration_hours: 2
    shutdown_duration_text: 2h
    details:
      total_cost_with_shutdown: 26.72
      total_cost_without_shutdown: 43.146
      price_difference: 2.635
      adjusted_min_price_diff: 1.5
    idx: 125
    len: 8
  - preheat_start: "2025-12-18T10:15:00+00:00"
    shutdown_start: "2025-12-18T14:30:00+00:00"
    recovery_start: "2025-12-18T18:45:00+00:00"
    recovery_end: "2025-12-18T23:00:00+00:00"
    cost_saving: 32.523
    cost_saving_percent: 38.2
    shutdown_duration_hours: 4.25
    shutdown_duration_text: 4h 15min
    details:
      total_cost_with_shutdown: 52.648
      total_cost_without_shutdown: 85.171
      price_difference: 2.552
      adjusted_min_price_diff: 0.706
    idx: 158
    len: 17
icon: mdi:lightning-bolt
data_resolution: 15min (normalized)
today_slot_minutes: 15
tomorrow_slot_minutes: 15
optimization_method: DP (full window), robust slots v2.0
unit_of_measurement: periods
candidates_scanned: 3173
results_found: 595
total_cost_saving: 132.233
min_slots: 2
max_slots: 20
preheat_mult: 1.08
recovery_mult: 1.04
multiplier_source: dynamic_by_temp
outdoor_temp_c: 11.18
nordpool_entity: sensor.nordpool_price_data_2
nordpool_source: fallback

I have results, but still trying to interpret them , maybe the price evolution today is not optimal?