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

Introduction

Since October 2025, electric markets in Finland and Nordpool has moved to
15-minute electricity pricing data. This granular data reveals significant
price spikes lasting just 15-45 minutes - opportunities that hourly averaging
completely misses.

:zap: About This Series

While this series uses HVAC heating as the primary example, the optimization
principles apply to any controllable electrical load:

  • Heat pumps and air conditioning
  • Water heaters and boilers
  • EV charging systems
  • Pool pumps and spa heaters
  • Industrial equipment with thermal/energy storage

The key requirement: Your load must tolerate short interruptions or have some
form of energy storage capacity (thermal mass, battery, water tank, etc.).

Traditional HVAC energy-saving approaches:

  • :x: Turn off heating during the most expensive hour
  • :x: Ignore building thermal mass
  • :x: Result in temperature drops and comfort loss

This series presents a smarter approach: A Python-based optimization system that:

  • :white_check_mark: Exploits 15-minute price peaks
  • :white_check_mark: Respects building thermodynamics
  • :white_check_mark: Maintains comfort while saving 10-20% on peak costs
  • :white_check_mark: v2.0: Now works with ANY Nordpool sensor configuration
  • :white_check_mark: v2.0: Automatic handling of mixed 15/60 min data

My heating system: Two air-source heat pumps (ILPs) as the primary heat source, with hydronic radiator heating set very low as backup. This hybrid approach provides flexibility for intelligent optimization.

The Core Problem

Traditional Approach

17:00 - Expensive hour detected
17:00 - Turn off HVAC completely
18:00 - Turn on HVAC
18:00 - Building is cold, recovery takes hours

Result: Discomfort, slower recovery, suboptimal actual savings.

My Approach: Intelligent Load Reduction

13:00 - PREHEAT: Warm building above target (store thermal energy)
17:00 - REDUCED HEATING: HVAC fan-only + radiators minimum
21:00 - RECOVERY: Gentle return to normal operation
01:00 - System back to baseline

Result: Minimal temperature drop, even heat distribution, optimal energy use, maximum savings.

The Three Phases Explained

Phase 1: Preheat (Pre-heating)

Duration: Same as cutoff period
HVAC Mode: Active heating (+1Β°C above target)
Radiators: Elevated temperature (target + offset)
Fan: High speed
Purpose: Store thermal energy in building mass

Your building is a thermal battery. By heating it 1-2Β°C above target before an expensive period, you store energy in walls, floors, and air that can sustain comfort during the reduced heating phase.

Cost multiplier: 1.5Γ— (or weather-adaptive in v2.0)
Why? Heating above target requires more energy due to increased heat loss at higher Ξ”T.

v2.0 Enhancement: Weather-adaptive multipliers automatically adjust based on outdoor temperature:

  • Extreme cold (<-10Β°C): 1.45Γ— preheat, 1.20Γ— recovery
  • Cold (-10Β°C to -3Β°C): 1.34Γ— preheat, 1.15Γ— recovery
  • Cool (-3Β°C to 2Β°C): 1.24Γ— preheat, 1.12Γ— recovery
  • Mild (2Β°C to 7Β°C): 1.14Γ— preheat, 1.08Γ— recovery
  • Warm (>7Β°C): 1.08Γ— preheat, 1.04Γ— recovery

This ensures optimal comfort while adapting to real-time weather conditions.

Phase 2: Reduced Heating (Cutoff)

Duration: 1-4 hours (optimized dynamically)
HVAC Mode: Fan-only (circulates air without heating)
Radiators: Minimum temperature setting
Fan: High speed (maintains air circulation)
Purpose: Minimize electricity consumption during peak prices

The heat pump switches to fan-only mode to maintain air circulation and even temperature distribution throughout the building, while radiators are set to minimum. This allows the building’s thermal mass to sustain comfort with minimal active heating.

Why not completely off? Fan circulation prevents cold spots and maintains even temperature distribution using stored thermal energy. The fan draws minimal power (~50W) compared to active heating (2000-5000W).

Weather-adaptive duration:

  • In extreme cold (<0Β°C) or heat (>24Β°C), the system automatically reduces max cutoff to 3 hours to maintain comfort
  • In mild conditions (0-24Β°C), full 4-hour cutoffs are allowed

Phase 3: Recovery

Duration: Same as cutoff period
HVAC Mode: Active heating
Fan: Medium to high speed
Radiators: Return to normal offset
Purpose: Return to baseline without shock-loading

Gentle ramp-up to normal operation. Slightly elevated consumption to restore baseline quickly without temperature overshoot.

Cost multiplier: 1.2Γ— (or weather-adaptive in v2.0)
Why? You’re catching up from a slightly lower temperature, requiring brief elevated output.

The Optimization Algorithm

Dynamic Price Difference Scaling

The Problem: How do you compare a 1-hour cutoff vs a 4-hour cutoff?

A 1-hour cutoff targets a sharp price spike:

  • 17:30-18:30: Peak price 36.6 c/kWh
  • Average of 1h: ~30 c/kWh
  • Requires high price difference: 3.5 c/kWh

A 4-hour cutoff spans multiple price levels:

  • 16:00-20:00: Mix of 5-35 c/kWh
  • Average of 4h: ~15 c/kWh
  • If we required 3.5Γ— 4 = 14 c/kWh difference, we’d never find 4h cutoffs

Solution: Dynamic scaling

required_price_diff = base_requirement Γ— (1h / cutoff_duration)

1h cutoff: 3.5 Γ— (1/1) = 3.5 c/kWh
2h cutoff: 3.5 Γ— (1/2) = 1.75 c/kWh
4h cutoff: 3.5 Γ— (1/4) = 0.88 c/kWh

Calibrating the base requirement (3.5 c/kWh):
This value isn’t arbitrary - I calculated it from my real heating data and Nordpool price history. By analyzing which cutoff periods actually delivered 10%+ savings over several months, I found that 3.5 c/kWh price difference for 1-hour cutoffs consistently met this threshold for my specific building.

Your building will likely need different values depending on:

  • Thermal mass (concrete, wood, insulation)
  • Heat pump efficiency (SCOP values)
  • Backup heating system (radiators, resistive)
  • Building size and layout

Part 3 of this series will show you how to calibrate this value for your own home using historical data analysis.

This allows the algorithm to find both sharp peaks and broader elevated periods while ensuring each cutoff delivers meaningful savings for your specific building characteristics.

15-Minute Precision

The system tests:

  • Start time: Every 15 minutes (16:00, 16:15, 16:30…)
  • Duration: Every 15 minutes (1.0h, 1.25h, 1.5h, 1.75h, 2.0h…)
  • Total candidates: ~100-200 per day

Why not test every possible combination?
Testing every start Γ— every duration would create 1000+ candidates β†’ memory overflow in Home Assistant templates.

Python script solution: Handles this easily, selecting the top ~100 candidates that meet criteria.

v2.0 Enhancement: Now handles mixed 15-minute and 60-minute data automatically! If your Nordpool sensor provides hourly data (24 slots) or mixed today (96Γ—15min) + tomorrow (24Γ—60min), the script normalizes everything to 15-minute intervals for optimal precision.

Real-World Example

October 1, 2025 - Extreme Price Day

Peak price: 36.6 c/kWh at 17:45
Minimum price: 0.3 c/kWh at 03:00
Average: 6.2 c/kWh

Optimized schedule:

  • 13:00-17:00: Preheat
  • 17:00-21:00: Reduced heating (4h)
  • 21:00-01:00: Recovery

Costs:

  • Without optimization: 33.82 kWh-value
  • With optimization: 21.67 kWh-value
  • Savings: 12.15 (35.9%)

Price difference: 2.70 c/kWh (average cutoff vs average preheat+recovery)
Scaled requirement: 0.88 c/kWh (for 4h cutoff) :white_check_mark:

October 3, 2025 - Moderate Price Day

Peak price: 7.66 c/kWh at 17:45
Minimum price: 0.25 c/kWh at 00:45
Average: 2.0 c/kWh

Optimized schedule:

  • 12:15-16:15: Preheat
  • 16:15-20:15: Reduced heating (4h) ← 15-min optimization!
  • 20:15-00:15: Recovery

Why 16:15 instead of 16:00?
The 16:00-16:15 slot is relatively cheap (2.45 c/kWh). By starting at 16:15, the system:

  • Avoids cutoff during that cheaper period
  • Includes the 20:00-20:15 slot in cutoff (2.90 c/kWh)
  • Result: +4.1% additional savings vs hourly optimization

Key Insights

  1. Building thermal mass is a battery: Store energy when cheap, use when expensive
  2. Symmetry is critical: Preheat and recovery durations must match cutoff
  3. Dynamic scaling is necessary: Different cutoff lengths need different thresholds
  4. 15-minute precision matters: 4% additional savings over hourly precision
  5. Fan-only maintains comfort: Air circulation uses minimal energy while distributing stored heat
  6. Weather-adaptive optimization: Cutoff duration and multipliers adjust automatically based on outdoor temperature
  7. v2.0: Universal Nordpool compatibility: Works with any Nordpool sensor configuration - no script editing required
  8. v2.0: Mixed resolution support: Handles both 15-minute and hourly data seamlessly

What’s Your Experience?

This approach has saved me 10-20% on peak electricity costs while maintaining perfect comfort levels. The building never drops temperature significantly during cutoffs, and the system runs completely automatically.

But I want to hear from you:

  • Are you using the optimizer already? Share your results!
  • What challenges have you faced with configuration?
  • What features would you like to see in future versions?

:tada: v2.0.0 Now Available!

Major update just released! The complete implementation is production-ready and includes significant improvements:

:link: GitHub Repository: nordpool-spot-cutoff-optimizer

What’s new in v2.0.0:

:sparkles: Key Features

  • :wrench: Configurable Nordpool Sensor - Works with ANY Nordpool integration without editing code

    • Three-tier resolution: service data β†’ input_text helper β†’ automatic fallbacks
    • Supports different sensor naming conventions across regions
    • Telemetry shows which sensor is being used
  • :bar_chart: Mixed Resolution Support - Automatic handling of 15-minute + hourly data

    • Today: 96Γ—15min, Tomorrow: 24Γ—60min? No problem!
    • Automatic normalization to 15-minute precision
    • Diagnostics show data resolution and slot durations
  • :thermometer: Weather-Adaptive Multipliers - Dynamic cost adjustment based on outdoor temperature

    • 5 temperature bands with optimal multipliers
    • Hard failsafe for missing/invalid temperature sensors
    • Telemetry exposes current multipliers and source
  • :chart_with_upwards_trend: Enhanced Telemetry - Comprehensive diagnostics for troubleshooting

    • nordpool_entity_used, nordpool_source - Which sensor was selected
    • data_resolution, today_slot_minutes, tomorrow_slot_minutes - Data quality
    • preheat_mult, recovery_mult, multiplier_source, outdoor_temp_c - Cost model
    • candidates_scanned, results_found, total_cost_saving - Optimization stats
  • :bug: Bug Fixes - Template compatibility and timestamp handling

    • Fixed midnight crossovers and date seam issues
    • Corrected attribute names (shutdown_end β†’ recovery_start)
    • Robust error handling with graceful fallbacks

:books: Documentation

  • :snake: Part 2: Python Implementation - Complete algorithm with v2.0 updates
  • :house: Part 3: Integration Examples - HVAC & water heater setups updated for v2.0
  • :gear: Ready-to-use Python script with copy-paste configurations
  • :clipboard: Migration guide from v1.x to v2.0
  • :wrench: Troubleshooting section for v2.0-specific issues

:arrows_counterclockwise: Breaking Changes

If upgrading from v1.x:

  • Update template sensors: shutdown_end β†’ recovery_start
  • Update attribute references in dashboards (see migration guide)
  • Python script must be replaced (not compatible with v1.x)

Now production-ready and deployed in real-world installations!

Have fun optimizing your electric/HVAC systems! :wrench::zap:

(And save some money while you’re at it) :moneybag:


P.S. If you break your heating system, that’s between you and your thermostat. We just provide the math! :wink:


Questions about the theory, v2.0 features, or calculations? Discussion starts below! :point_down:

9 Likes

Part 2: The Cutoff Optimizer - Python Implementation

Part 2 of 3: Technical Deep-Dive

This document covers the core optimization algorithm and Home Assistant integration.
β†’ Discuss on Home Assistant Community

:tada: v2.0.0 Update

Major improvements in this version:

  • :white_check_mark: Configurable Nordpool sensor - Works with ANY integration, no code editing
  • :white_check_mark: Mixed resolution support - Handles 15-minute + hourly data automatically
  • :white_check_mark: Weather-adaptive multipliers - Dynamic cost adjustment (5 temperature bands)
  • :white_check_mark: Enhanced telemetry - Comprehensive diagnostics for troubleshooting
  • :white_check_mark: Bug fixes - Template compatibility, timestamp handling, error messages

See GitHub Release v2.0.0 for full changelog.


GitHub Repository

:link: Complete code and examples: nordpool-spot-cutoff-optimizer

All documentation, Python script, and integration examples are available in the repository above.


Introduction

In Part 1, we explored the theory behind load cutoff optimization using 15-minute Nordpool pricing. Now it’s time to build the actual system.

This part covers:

  • :white_check_mark: Core optimization algorithm (Dynamic Programming)
  • :white_check_mark: Python script for Home Assistant
  • :white_check_mark: Template sensors and integration
  • :white_check_mark: v2.0: Configurable Nordpool sensor selection
  • :white_check_mark: v2.0: Mixed 15/60 min data handling
  • :white_check_mark: How to adapt it for YOUR load

Key principle: The optimizer is load-agnostic. It finds optimal cutoff schedules and outputs them as sensors. YOU implement the actual load control in Part 3.


Architecture Overview

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ 1. Nordpool Integration (HACS)                          β”‚
β”‚    β”œβ”€ sensor.nordpool_kwh_fi_eur_3_10_0                β”‚
β”‚    β”œβ”€ raw_today (96 Γ— 15min OR 24 Γ— 1h)                β”‚
β”‚    └─ raw_tomorrow (96 Γ— 15min OR 24 Γ— 1h)             β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                   β”‚
                   β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ 2. Python Script: nordpool_cutoff_optimizer.py         β”‚
β”‚    β”œβ”€ v2.0: Resolve Nordpool sensor (3-tier)           β”‚
β”‚    β”œβ”€ v2.0: Normalize mixed 15/60 min data             β”‚
β”‚    β”œβ”€ Dynamic Programming optimization                  β”‚
β”‚    β”œβ”€ Find globally optimal cutoff schedule(s)         β”‚
β”‚    └─ Output: sensor.nordpool_cutoff_periods_python    β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                   β”‚
                   β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ 3. Template Sensors (Home Assistant)                    β”‚
β”‚    β”œβ”€ sensor.cutoff_current_period                      β”‚
β”‚    β”œβ”€ sensor.cutoff_phase (preheat/cutoff/recovery)    β”‚
β”‚    └─ Extract data from periods list                    β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                   β”‚
                   β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ 4. YOUR Automations (YOU implement this in Part 3)     β”‚
β”‚    β”œβ”€ When cutoff_phase = "preheat" β†’ YOUR action      β”‚
β”‚    β”œβ”€ When cutoff_phase = "shutdown" β†’ YOUR action     β”‚
β”‚    └─ When cutoff_phase = "recovery" β†’ YOUR action     β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Core Algorithm: Dynamic Programming

The optimizer uses Dynamic Programming to find the globally optimal cutoff schedule(s).

Why Dynamic Programming?

Alternative approach (Greedy):

  • Find the most expensive hour
  • Apply cutoff there
  • Problem: Might miss better opportunities or create overlaps

Dynamic Programming approach:

  • Test all possible cutoff combinations
  • Consider preheat and recovery costs
  • Find globally optimal solution
  • Supports multiple cutoffs per day (e.g., morning + evening peaks)

Algorithm Steps

1. Get price data (today + tomorrow) from Nordpool sensor
2. v2.0: Normalize to 15-min intervals if data is hourly or mixed
3. For each possible cutoff candidate:
   a. Calculate cutoff duration (0.5h, 0.75h, 1h, ... up to max)
   b. Calculate preheat start (same duration before cutoff)
   c. Calculate recovery end (same duration after cutoff)
   d. Compute costs:
      - baseline_cost = avg_price Γ— duration
      - cutoff_cost = cutoff_price Γ— residual_load (e.g. 0.1)
      - preheat_cost = preheat_price Γ— preheat_multiplier (v2.0: weather-adaptive)
      - recovery_cost = recovery_price Γ— recovery_multiplier (v2.0: weather-adaptive)
      - total_cost = cutoff + preheat + recovery
      - savings = baseline_cost - total_cost
   e. Check if savings > threshold
4. Use Dynamic Programming to select non-overlapping cutoffs
5. Output schedule(s) to sensor attributes (v2.0: enhanced telemetry)

Key Parameters

These are read from input_number entities you configure:

Parameter Default Description
base_price_diff 3.5 c/kWh Min price diff for 1h cutoff
max_cutoff_duration 4.0 hours Max cutoff length
min_cutoff_duration 0.5 hours Min cutoff length
preheat_multiplier 1.5 Cost multiplier for preheat (v2.0: auto-adaptive)
recovery_multiplier 1.2 Cost multiplier for recovery (v2.0: auto-adaptive)
residual_multiplier 0.1 Residual load during cutoff (10%)
min_savings_pct 10% Min savings to activate

v2.0 Note: Preheat/recovery multipliers are automatically adjusted based on outdoor temperature if sensor.outdoor_temperature is available (5 temperature bands). Manual values are used as fallbacks.

These are calibrated for YOUR system in Part 3.


Python Script Output (v2.0)

Output Sensor: sensor.nordpool_cutoff_periods_python

The script creates a sensor with a list of cutoff periods:

sensor.nordpool_cutoff_periods_python:
  state: "2"  # Number of cutoffs today/tomorrow
  attributes:
    periods:
      - preheat_start: "2025-10-07T13:00:00"
        shutdown_start: "2025-10-07T17:00:00"
        recovery_start: "2025-10-07T21:00:00"
        recovery_end: "2025-10-08T01:00:00"
        cost_saving: 12.15
        cost_saving_percent: 35.9
        shutdown_duration_hours: 4.0
        shutdown_duration_text: "4h"
        details:
          total_cost_with_shutdown: 21.67
          total_cost_without_shutdown: 33.82
          price_difference: 3.1
          adjusted_min_price_diff: 0.88

      - preheat_start: "2025-10-08T06:00:00"
        shutdown_start: "2025-10-08T08:00:00"
        recovery_start: "2025-10-08T10:00:00"
        recovery_end: "2025-10-08T12:00:00"
        cost_saving: 5.18
        cost_saving_percent: 18.2
        shutdown_duration_hours: 2.0
        shutdown_duration_text: "2h"
        details:
          total_cost_with_shutdown: 23.27
          total_cost_without_shutdown: 28.45
          price_difference: 2.8
          adjusted_min_price_diff: 1.75

    # v2.0 Telemetry
    data_resolution: "15min (normalized)"
    today_slot_minutes: 15
    tomorrow_slot_minutes: 15
    optimization_method: "DP (full window), robust slots v2.0"
    nordpool_entity: "sensor.nordpool_kwh_fi_eur_3_10_0"
    nordpool_source: "service_data"
    preheat_mult: 1.24
    recovery_mult: 1.12
    multiplier_source: "dynamic_by_temp"
    outdoor_temp_c: -1.5
    candidates_scanned: 1234
    results_found: 12
    total_cost_saving: 17.33

v2.0 Attribute Reference

Per-period attributes:

Attribute Description v2.0 Change
preheat_start Start of preheat window -
shutdown_start Start of cutoff -
recovery_start End of cutoff, start of recovery :warning: Use this, not shutdown_end
recovery_end End of recovery window -
cost_saving Absolute cost savings :sparkles: New in v2.0
cost_saving_percent Savings percentage :sparkles: Renamed from savings_pct
shutdown_duration_hours Cutoff length :sparkles: Renamed
shutdown_duration_text Human-readable duration :sparkles: New in v2.0
details Cost breakdown object :sparkles: New in v2.0

Important: There is NO shutdown_end attribute in v2.0. Use recovery_start as the moment when shutdown phase ends.

Top-level telemetry:

Attribute Description
nordpool_entity Which Nordpool sensor was used
nordpool_source How it was resolved (service_data, input_text, fallback)
data_resolution 15min (normalized) or mixed→15min
today_slot_minutes Resolution of today’s data (15 or 60)
tomorrow_slot_minutes Resolution of tomorrow’s data
preheat_mult, recovery_mult Current cost multipliers
multiplier_source dynamic_by_temp or fallback_legacy
outdoor_temp_c Current outdoor temperature (if available)

Key feature: The periods list can contain multiple cutoffs (e.g., morning + evening peaks).


Nordpool Sensor Configuration (v2.0)

The v2.0 optimizer supports configurable Nordpool sensor selection with automatic fallbacks.

How the sensor is resolved

The script checks these sources in order:

1. Service data (highest priority)

service: python_script.nordpool_cutoff_optimizer
data:
  np_entity: "sensor.nordpool_kwh_fi_eur_3_10_0"

2. input_text helper (medium priority)

input_text:
  nordpool_entity_override:
    name: "Nordpool Entity Override"
    initial: "sensor.nordpool_kwh_fi_eur_3_10_0"

3. Hardcoded fallback (lowest priority)

# Inside the script
NP_FALLBACKS = [
    'sensor.nordpool_fi',
    'sensor.nordpool_kwh_fi_eur_3_10_0',
    'sensor.nordpool_kwh_se3_eur_3_10_0',
    # ... add your region
]

Why this matters: Different Nordpool integrations use different entity names. This lets you configure it without editing the Python script.

Telemetry attributes

The sensor exposes which entity was used:

sensor.nordpool_cutoff_periods_python:
  attributes:
    nordpool_entity: "sensor.nordpool_kwh_fi_eur_3_10_0"
    nordpool_source: "service_data"
    data_resolution: "15min (normalized)"
    today_slot_minutes: 15
    tomorrow_slot_minutes: 60  # Mixed resolution!

Use these attributes to verify the correct sensor is being used and data is coming through properly.

Mixed resolution support

v2.0 handles mixed 15-min + 60-min data:

  • Today might be 15-min slots (96 data points)
  • Tomorrow might be 60-min slots (24 data points)
  • Script normalizes both to 15-min for optimization
  • Check data_resolution attribute: "mixedβ†’15min" indicates mixed sources

Template Sensors: Extract Current Phase (v2.0)

You need template sensors to extract which phase is currently active:

Minimal Example: Current Phase Sensor

template:
  - sensor:
      - name: "Cutoff Current Phase"
        unique_id: cutoff_current_phase
        state: >
          {% set periods = state_attr('sensor.nordpool_cutoff_periods_python', 'periods') %}
          {% if not periods %}
            normal
          {% else %}
            {% set now_ts = now().timestamp() %}
            {% set ns = namespace(phase='normal') %}
            {% for period in periods %}
              {% set preheat_ts = as_timestamp(period.preheat_start) %}
              {% set shutdown_ts = as_timestamp(period.shutdown_start) %}
              {% set rec_start = as_timestamp(period.recovery_start) %}
              {% set recovery_end_ts = as_timestamp(period.recovery_end) %}

              {% if now_ts >= preheat_ts and now_ts < shutdown_ts %}
                {% set ns.phase = 'preheat' %}
              {% elif now_ts >= shutdown_ts and now_ts < rec_start %}
                {% set ns.phase = 'shutdown' %}
              {% elif now_ts >= rec_start and now_ts < recovery_end_ts %}
                {% set ns.phase = 'recovery' %}
              {% endif %}
            {% endfor %}
            {{ ns.phase }}
          {% endif %}

v2.0 Changes:

  • Uses recovery_start instead of deprecated shutdown_end
  • Uses Jinja namespace pattern (HA best practice)
  • Handles multiple periods correctly

This sensor will show:

  • normal - No active cutoff
  • preheat - Currently preheating
  • shutdown - Currently in cutoff phase
  • recovery - Currently recovering

Weather-Adaptive Optimization (v2.0)

The script automatically adjusts cutoff parameters based on outdoor temperature:

Dynamic Multipliers (5 temperature bands)

Outdoor Temp Preheat Mult Recovery Mult Why?
< -10Β°C 1.45Γ— 1.20Γ— Extreme cold β†’ higher heat loss
-10Β°C to -3Β°C 1.34Γ— 1.15Γ— Cold β†’ moderate adjustment
-3Β°C to 2Β°C 1.24Γ— 1.12Γ— Cool β†’ slight adjustment
2Β°C to 7Β°C 1.14Γ— 1.08Γ— Mild β†’ minimal adjustment
> 7Β°C 1.08Γ— 1.04Γ— Warm β†’ very minimal adjustment

Failsafe System

If outdoor temperature sensor is:

  • Missing β†’ Falls back to manual input_number values (1.5Γ—, 1.2Γ—)
  • Invalid (non-numeric, out of range) β†’ Falls back to manual values
  • Available β†’ Uses dynamic multipliers

Configuration

Required sensor:

# The script looks for this entity:
sensor.outdoor_temperature

If your sensor has a different name, edit the ENTITY_OUTDOOR_TEMP constant in the Python script.

Telemetry exposes:

attributes:
  preheat_mult: 1.24
  recovery_mult: 1.12
  multiplier_source: "dynamic_by_temp"
  outdoor_temp_c: -1.5

Why weather-adaptive? Extreme temperatures increase heat loss β†’ dynamic multipliers ensure optimal comfort while adapting to real-time weather conditions.


Installation

Step 1: Enable Python Scripts

Add to configuration.yaml:

python_script:

Restart Home Assistant.


Step 2: Copy Python Script

  1. Download nordpool_cutoff_optimizer.py from GitHub repository
  2. Place it in /config/python_scripts/
  3. File should be: /config/python_scripts/nordpool_cutoff_optimizer.py

Step 3: Add Input Numbers

Add these to configuration.yaml (or use packages):

input_number:
  cutoff_base_price_diff:
    name: "Base Price Difference"
    min: 0.5
    max: 10.0
    step: 0.1
    initial: 3.5
    unit_of_measurement: "c/kWh"

  cutoff_max_duration:
    name: "Max Cutoff Duration"
    min: 0.5
    max: 6.0
    step: 0.25
    initial: 4.0
    unit_of_measurement: "hours"

  cutoff_preheat_multiplier:
    name: "Preheat Cost Multiplier"
    min: 1.0
    max: 2.0
    step: 0.1
    initial: 1.5

  cutoff_recovery_multiplier:
    name: "Recovery Cost Multiplier"
    min: 1.0
    max: 2.0
    step: 0.1
    initial: 1.2

  cutoff_residual_load:
    name: "Cutoff Residual Load"
    min: 0.0
    max: 0.5
    step: 0.05
    initial: 0.1

  cutoff_min_savings_pct:
    name: "Min Savings Threshold"
    min: 0
    max: 30
    step: 1
    initial: 10
    unit_of_measurement: "%"

Restart Home Assistant.


Step 4: Add Template Sensor

Add the β€œCutoff Current Phase” sensor from above to your configuration.yaml:

template:
  - sensor:
      - name: "Cutoff Current Phase"
        unique_id: cutoff_current_phase
        state: >
          # ... (copy from above)

Restart Home Assistant.


Step 5: Automation to Run Script (v2.0)

Create an automation that runs the optimizer:

automation:
  - alias: "Run Nordpool Cutoff Optimizer"
    trigger:
      # Run every 15 minutes
      - platform: time_pattern
        minutes: "/15"

      # Run when tomorrow's prices arrive
      - platform: state
        entity_id: sensor.nordpool_kwh_fi_eur_3_10_0
        attribute: raw_tomorrow

    action:
      - service: python_script.nordpool_cutoff_optimizer
        data:
          np_entity: sensor.nordpool_kwh_fi_eur_3_10_0  # v2.0: Optional, specify your sensor

v2.0 Note: The np_entity parameter lets you specify which Nordpool sensor to use without editing the script.

The script will now run automatically!


Testing Without Load Control

You can test the optimizer without connecting it to any loads:

1. Check the Output Sensor

Go to: Developer Tools β†’ States β†’ Search for sensor.nordpool_cutoff_periods_python

You should see:

  • State: Number of cutoffs (e.g., β€œ2”)
  • Attributes: periods list with all cutoff details
  • v2.0: Check nordpool_entity, data_resolution, multiplier_source

2. Monitor Current Phase

Watch sensor.cutoff_current_phase throughout the day:

  • Should be normal most of the time
  • Changes to preheat before expensive periods
  • Changes to shutdown during expensive periods
  • Changes to recovery after cutoffs

3. Verify Schedule Matches Prices

Compare cutoff times with Nordpool prices:

  • Cutoffs should be during expensive hours
  • Preheat should be during cheaper hours before peaks

Only after verification, implement actual load control (Part 3).


Debugging

Enable Debug Logging

Add to configuration.yaml:

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

Check Sensor Attributes (v2.0)

Developer Tools β†’ States β†’ sensor.nordpool_cutoff_periods_python

Look for:

  • All timestamps present in periods (preheat_start, shutdown_start, recovery_start, recovery_end)
  • Reasonable cost_saving_percent values
  • Cutoffs during expensive hours
  • v2.0: data_resolution showing correct normalization (15min or mixedβ†’15min)
  • v2.0: nordpool_entity confirming correct sensor source
  • v2.0: multiplier_source showing dynamic_by_temp or fallback reason

Common Issues (v2.0)

Issue Fix
No cutoff detected Lower base_price_diff or min_savings_pct
Cutoff during cheap hours Increase base_price_diff
Cutoff too long Lower max_cutoff_duration
Script error Check logs, verify Nordpool sensor exists
v2.0: Sensor not found Check nordpool_entity attribute, verify sensor name
v2.0: Mixed resolution warning Check today_slot_minutes and tomorrow_slot_minutes

Performance

  • Execution time: ~0.5-2 seconds
  • Memory usage: ~5-10 MB
  • CPU usage: Negligible (runs every 15 min)

Safe for Raspberry Pi and low-power systems.


Next Steps

β†’ Part 3: Integration Examples - How to connect the optimizer to YOUR specific loads (HVAC, water heater, etc.)


Key Takeaways (v2.0)

  • The optimizer is load-agnostic - it only finds schedules
  • Output: sensor.nordpool_cutoff_periods_python with periods list
  • Multiple cutoffs per day are supported (morning + evening peaks)
  • YOU implement actual load control based on sensor.cutoff_current_phase
  • Dynamic Programming finds globally optimal solution
  • v2.0: Weather-adaptive multipliers maintain comfort automatically
  • v2.0: Works with ANY Nordpool sensor - no code editing required
  • v2.0: Handles mixed 15/60 min data seamlessly
  • v2.0: Comprehensive telemetry for troubleshooting
  • Test thoroughly before connecting to real loads

Questions? Discuss in the Home Assistant Community thread

Part 3: Integration Examples - Making It Work for Your Home

Part 3 of 3: Configuration & Real-World Results

This document shows how to integrate the cutoff optimizer with YOUR specific loads.
β†’ Discuss on Home Assistant Community

:tada: v2.0.0 Update

This guide is fully updated for v2.0.0!

Key v2.0 improvements covered in this part:

  • :white_check_mark: Template fixes - Uses recovery_start instead of deprecated shutdown_end
  • :white_check_mark: v2.0 attribute names - Updated to cost_saving_percent, shutdown_duration_hours
  • :white_check_mark: Weather-adaptive multipliers - Automatic comfort optimization
  • :white_check_mark: Troubleshooting section - v2.0-specific issues and solutions

See GitHub Release v2.0.0 for full changelog.


GitHub Repository & Examples

:link: Complete repository: nordpool-spot-cutoff-optimizer

The repository contains:

  • :page_facing_up: Full documentation (docs/ folder)
  • :snake: Python script (python_scripts/nordpool_cutoff_optimizer.py)
  • :hammer_and_wrench: Copy-paste ready configuration examples (this post)
  • :clipboard: Installation checklist and troubleshooting

Introduction

In Part 1 we covered the theory, and Part 2 provided the Python implementation. Now it’s time to integrate the optimizer with YOUR system.

This part covers:

  • :white_check_mark: Quick installation checklist
  • :white_check_mark: Parameter calibration for YOUR building
  • :white_check_mark: HVAC integration example (complete working system)
  • :white_check_mark: Water heater integration example
  • :white_check_mark: Creating automations for your loads
  • :white_check_mark: Dashboard examples
  • :white_check_mark: v2.0: Troubleshooting and v2.0-specific issues

Installation Checklist

Follow these steps in order:

:ballot_box_with_check: Phase 1: Prerequisites

  • Home Assistant 2024.10+ installed
  • Nordpool HACS integration installed
  • 15-minute data enabled in Nordpool integration settings
  • Verify sensor.nordpool_kwh_fi_eur_3_10_0 exists and shows raw_today with 96 slots (or 24 if 1h data)
  • Python Scripts integration enabled in configuration.yaml:
python_script:

:ballot_box_with_check: Phase 2: Install Optimizer

  • Download nordpool_cutoff_optimizer.py from GitHub repo
  • Copy to /config/python_scripts/nordpool_cutoff_optimizer.py
  • Add input_number entities (see Part 2)
  • Restart Home Assistant
  • Verify script runs: Check Developer Tools β†’ Services β†’ python_script.nordpool_cutoff_optimizer

:ballot_box_with_check: Phase 3: Add Template Sensors

  • Add sensor.cutoff_current_phase template (see Part 2 - v2.0 version)
  • Restart Home Assistant
  • Verify sensor exists in Developer Tools β†’ States

:ballot_box_with_check: Phase 4: Create Automation

  • Add automation to run optimizer every 15 minutes (see Part 2)
  • v2.0: Optionally specify np_entity parameter in automation
  • Verify automation triggers
  • Check sensor.nordpool_cutoff_periods_python has data

:ballot_box_with_check: Phase 5: Test Without Load Control

  • Monitor sensor.cutoff_current_phase throughout the day
  • Verify cutoffs occur during expensive hours
  • v2.0: Check telemetry attributes (data_resolution, nordpool_entity, multiplier_source)
  • Check logs for errors

Only proceed to Phase 6 after verifying the optimizer works correctly!

:ballot_box_with_check: Phase 6: Implement Load Control

  • Create automations for YOUR specific loads (see examples below)
  • Test in dry-run mode first (log actions, don’t control loads)
  • Monitor for 24-48 hours
  • Enable full control when confident

Parameter Calibration

The optimizer needs parameters calibrated for YOUR specific building and system.

Key Parameters to Calibrate

Parameter Purpose How to Calibrate
base_price_diff Minimum price difference to trigger cutoff Start with 3.5 c/kWh, adjust based on results
max_cutoff_duration Maximum cutoff length Start with 4h, reduce if temperature drops too much
preheat_multiplier Cost of preheating v2.0: Auto-adaptive (1.08-1.45 by temp), or manual 1.5
recovery_multiplier Cost of recovery v2.0: Auto-adaptive (1.04-1.20 by temp), or manual 1.2
residual_load Load during cutoff 0.1 for fan-only, 0.05 for complete off

v2.0 Note: If you have sensor.outdoor_temperature configured, the script automatically adjusts preheat/recovery multipliers. Manual input_number values are used as fallbacks only.

Calibration Method 1: Historical Data Analysis

If you have 1-2 months of data:

  1. Note your average heating consumption during different outdoor temperatures
  2. Identify expensive price days where cutoff would have occurred
  3. Estimate savings based on actual consumption patterns
  4. Adjust base_price_diff so optimizer finds 2-3 cutoffs per week

Example:

  • Your heating: 5 kWh/hour at 0Β°C outdoor temp
  • Average price: 5 c/kWh
  • Peak price: 15 c/kWh
  • Price difference: 10 c/kWh β†’ Should trigger cutoff
  • If no cutoff β†’ Lower base_price_diff to 2.5 c/kWh

Calibration Method 2: A/B Testing

Start conservative, iterate:

  1. Week 1: Set base_price_diff = 5.0 c/kWh (few cutoffs)
  • Monitor temperature impact
  • Check savings
  1. Week 2: Lower to 3.5 c/kWh (more cutoffs)
  • Compare comfort vs Week 1
  • Check savings increase
  1. Week 3: Lower to 2.5 c/kWh (many cutoffs)
  • If temperature drops >1Β°C β†’ Too aggressive
  • If comfort OK β†’ Keep this setting

Rule of thumb: If cutoffs cause >1Β°C temperature drop, increase base_price_diff or reduce max_cutoff_duration.


Example 1: HVAC System Integration

This example shows a real working system with:

  • 2Γ— air-source heat pumps (primary heating)
  • Hydronic radiator heating (backup)
  • Weather-adaptive control (v2.0 automatic)

Architecture

sensor.cutoff_current_phase
        ↓
  β”Œβ”€β”€β”€β”€β”΄β”€β”€β”€β”€β”
  β”‚         β”‚
preheat  shutdown  recovery
  β”‚         β”‚         β”‚
  ↓         ↓         ↓
[Heat]  [Fan Only]  [Heat]
+Rads    +Rads Min   +Rads

Template Sensors for HVAC (v2.0)

template:
  - sensor:
      # Current cutoff phase
      - name: "Cutoff Current Phase"
        unique_id: cutoff_current_phase
        state: >
          {% set periods = state_attr('sensor.nordpool_cutoff_periods_python', 'periods') %}
          {% if not periods %}
            normal
          {% else %}
            {% set now_ts = now().timestamp() %}
            {% set ns = namespace(phase='normal') %}
            {% for period in periods %}
              {% set preheat_ts = as_timestamp(period.preheat_start) %}
              {% set shutdown_ts = as_timestamp(period.shutdown_start) %}
              {% set rec_start = as_timestamp(period.recovery_start) %}
              {% set recovery_end_ts = as_timestamp(period.recovery_end) %}

              {% if now_ts >= preheat_ts and now_ts < shutdown_ts %}
                {% set ns.phase = 'preheat' %}
              {% elif now_ts >= shutdown_ts and now_ts < rec_start %}
                {% set ns.phase = 'shutdown' %}
              {% elif now_ts >= rec_start and now_ts < recovery_end_ts %}
                {% set ns.phase = 'recovery' %}
              {% endif %}
            {% endfor %}
            {{ ns.phase }}
          {% endif %}

      # Next cutoff info (for dashboard)
      - name: "Next Cutoff Time"
        unique_id: next_cutoff_time
        state: >
          {% set periods = state_attr('sensor.nordpool_cutoff_periods_python', 'periods') %}
          {% if periods %}
            {% set now_ts = now().timestamp() %}
            {% set future = periods | selectattr('shutdown_start', '>', now().isoformat()) | list %}
            {% if future %}
              {{ as_timestamp(future[0].shutdown_start) | timestamp_custom('%H:%M') }}
            {% else %}
              No cutoff scheduled
            {% endif %}
          {% else %}
            No data
          {% endif %}
        attributes:
          duration: >
            {% set periods = state_attr('sensor.nordpool_cutoff_periods_python', 'periods') %}
            {% if periods %}
              {% set future = periods | selectattr('shutdown_start', '>', now().isoformat()) | list %}
              {% if future %}
                {{ future[0].shutdown_duration_hours }} hours
              {% endif %}
            {% endif %}
          estimated_savings: >
            {% set periods = state_attr('sensor.nordpool_cutoff_periods_python', 'periods') %}
            {% if periods %}
              {% set future = periods | selectattr('shutdown_start', '>', now().isoformat()) | list %}
              {% if future %}
                {{ future[0].cost_saving_percent }}%
              {% endif %}
            {% endif %}

v2.0 Changes:

  • Uses recovery_start instead of shutdown_end
  • Uses namespace pattern (HA best practice)
  • Updated attribute names: shutdown_duration_hours, cost_saving_percent
  • Adds list indexing [0] for future list

Automations for HVAC Control

Automation 1: Preheat Phase

automation:
  - alias: "Cutoff - HVAC Preheat"
    description: "Increase heating 1Β°C before cutoff period"
    trigger:
      - platform: state
        entity_id: sensor.cutoff_current_phase
        to: "preheat"
    action:
      # Heat pumps: Increase target temperature
      - service: climate.set_temperature
        target:
          entity_id:
            - climate.heat_pump_1
            - climate.heat_pump_2
        data:
          temperature: >
            {{ state_attr('climate.heat_pump_1', 'temperature') | float(21) + 1 }}

      # Radiators: Increase target temperature
      - service: number.set_value
        target:
          entity_id: number.radiator_target_temp
        data:
          value: >
            {{ states('number.radiator_target_temp') | float(20) + 2 }}

      # Optional: Increase fan speed
      - service: climate.set_fan_mode
        target:
          entity_id:
            - climate.heat_pump_1
            - climate.heat_pump_2
        data:
          fan_mode: "high"

Automation 2: Shutdown Phase

  - alias: "Cutoff - HVAC Shutdown"
    description: "Reduce heating during expensive period"
    trigger:
      - platform: state
        entity_id: sensor.cutoff_current_phase
        to: "shutdown"
    action:
      # Heat pumps: Switch to fan-only mode
      - service: climate.set_hvac_mode
        target:
          entity_id:
            - climate.heat_pump_1
            - climate.heat_pump_2
        data:
          hvac_mode: "fan_only"

      # Keep fan running for air circulation
      - service: climate.set_fan_mode
        target:
          entity_id:
            - climate.heat_pump_1
            - climate.heat_pump_2
        data:
          fan_mode: "high"

      # Radiators: Set to minimum
      - service: number.set_value
        target:
          entity_id: number.radiator_target_temp
        data:
          value: 15  # Minimum safe temperature

Automation 3: Recovery Phase

  - alias: "Cutoff - HVAC Recovery"
    description: "Return to normal heating after cutoff"
    trigger:
      - platform: state
        entity_id: sensor.cutoff_current_phase
        to: "recovery"
    action:
      # Heat pumps: Return to heating mode
      - service: climate.set_hvac_mode
        target:
          entity_id:
            - climate.heat_pump_1
            - climate.heat_pump_2
        data:
          hvac_mode: "heat"

      # Heat pumps: Normal target temperature
      - service: climate.set_temperature
        target:
          entity_id:
            - climate.heat_pump_1
            - climate.heat_pump_2
        data:
          temperature: 21  # Your normal setpoint

      # Radiators: Return to normal
      - service: number.set_value
        target:
          entity_id: number.radiator_target_temp
        data:
          value: 20  # Your normal radiator temp

      # Fan: Return to normal speed
      - service: climate.set_fan_mode
        target:
          entity_id:
            - climate.heat_pump_1
            - climate.heat_pump_2
        data:
          fan_mode: "auto"

Automation 4: Return to Normal

  - alias: "Cutoff - HVAC Normal"
    description: "Ensure normal operation after recovery"
    trigger:
      - platform: state
        entity_id: sensor.cutoff_current_phase
        to: "normal"
    action:
      # Same as recovery, but ensures everything is reset
      - service: climate.set_hvac_mode
        target:
          entity_id:
            - climate.heat_pump_1
            - climate.heat_pump_2
        data:
          hvac_mode: "heat"
      - service: climate.set_temperature
        target:
          entity_id:
            - climate.heat_pump_1
            - climate.heat_pump_2
        data:
          temperature: 21

Safety Overrides

Important: Always include safety overrides!

automation:
  - alias: "Cutoff - Safety Override Cold"
    description: "Cancel cutoff if too cold"
    trigger:
      - platform: numeric_state
        entity_id: sensor.indoor_temperature
        below: 19  # Your minimum comfort threshold
    condition:
      - condition: state
        entity_id: sensor.cutoff_current_phase
        state: "shutdown"
    action:
      # Force return to heating
      - service: climate.set_hvac_mode
        target:
          entity_id:
            - climate.heat_pump_1
            - climate.heat_pump_2
        data:
          hvac_mode: "heat"

      # Send notification
      - service: notify.mobile_app
        data:
          title: "Cutoff Override"
          message: "Indoor temp too low, heating restored"

Example 2: Water Heater Integration

Simpler system: Water tank acts as thermal storage.

Template Sensors (v2.0)

template:
  - sensor:
      - name: "Water Heater Cutoff Phase"
        unique_id: water_heater_cutoff_phase
        state: >
          {% set periods = state_attr('sensor.nordpool_cutoff_periods_python', 'periods') %}
          {% if not periods %}
            normal
          {% else %}
            {% set now_ts = now().timestamp() %}
            {% set ns = namespace(phase='normal') %}
            {% for period in periods %}
              {% set preheat_ts = as_timestamp(period.preheat_start) %}
              {% set shutdown_ts = as_timestamp(period.shutdown_start) %}
              {% set rec_start = as_timestamp(period.recovery_start) %}

              {% if now_ts >= preheat_ts and now_ts < shutdown_ts %}
                {% set ns.phase = 'preheat' %}
              {% elif now_ts >= shutdown_ts and now_ts < rec_start %}
                {% set ns.phase = 'shutdown' %}
              {% endif %}
            {% endfor %}
            {{ ns.phase }}
          {% endif %}

v2.0 Change: Uses recovery_start instead of shutdown_end

Automations

automation:
  - alias: "Water Heater - Preheat"
    trigger:
      - platform: state
        entity_id: sensor.water_heater_cutoff_phase
        to: "preheat"
    action:
      # Increase water temp by 5Β°C
      - service: water_heater.set_temperature
        target:
          entity_id: water_heater.main
        data:
          temperature: >
            {{ state_attr('water_heater.main', 'temperature') | float(60) + 5 }}

  - alias: "Water Heater - Shutdown"
    trigger:
      - platform: state
        entity_id: sensor.water_heater_cutoff_phase
        to: "shutdown"
    action:
      # Turn off water heater
      - service: water_heater.turn_off
        target:
          entity_id: water_heater.main

  - alias: "Water Heater - Return to Normal"
    trigger:
      - platform: state
        entity_id: sensor.water_heater_cutoff_phase
        to: "normal"
    action:
      # Return to normal temperature
      - service: water_heater.set_temperature
        target:
          entity_id: water_heater.main
        data:
          temperature: 60  # Normal setpoint

Dashboard Example (v2.0)

Create a cutoff monitoring dashboard:

type: vertical-stack
cards:
  - type: markdown
    content: |
      # Nordpool Cutoff Optimizer
      
      **Current Phase:** {{ states('sensor.cutoff_current_phase') | title }}
      
      **Next Cutoff:** {{ states('sensor.next_cutoff_time') }}  
      **Duration:** {{ state_attr('sensor.next_cutoff_time', 'duration') }}  
      **Est. Savings:** {{ state_attr('sensor.next_cutoff_time', 'estimated_savings') }}

  - type: entities
    title: Optimizer Parameters
    entities:
      - entity: input_number.cutoff_base_price_diff
      - entity: input_number.cutoff_max_duration
      - entity: input_number.cutoff_min_savings_pct

  - type: entities
    title: v2.0 Telemetry
    entities:
      - entity: sensor.nordpool_cutoff_periods_python
        attribute: nordpool_entity
        name: "Nordpool Sensor Used"
      - entity: sensor.nordpool_cutoff_periods_python
        attribute: data_resolution
        name: "Data Resolution"
      - entity: sensor.nordpool_cutoff_periods_python
        attribute: multiplier_source
        name: "Multiplier Source"
      - entity: sensor.nordpool_cutoff_periods_python
        attribute: outdoor_temp_c
        name: "Outdoor Temperature"

  - type: history-graph
    title: Temperature During Cutoffs
    entities:
      - entity: sensor.indoor_temperature
      - entity: sensor.cutoff_current_phase
    hours_to_show: 24

  - type: custom:apexcharts-card
    title: Nordpool Prices with Cutoffs
    graph_span: 48h
    header:
      show: true
    series:
      - entity: sensor.nordpool_kwh_fi_eur_3_10_0
        name: Electricity Price
        type: line
        color: blue
      - entity: sensor.cutoff_current_phase
        name: Cutoff Phase
        type: column
        transform: |
          return x === 'shutdown' ? 1 : 0;
        color: red

Troubleshooting

Common Issues

Problem Cause Solution
No cutoffs detected Price threshold too high Lower base_price_diff to 2.5 c/kWh
Too many cutoffs Threshold too low Increase base_price_diff to 4.0 c/kWh
Temperature drops >1Β°C Cutoff too long Reduce max_cutoff_duration to 3h
Recovery takes too long Poor thermal mass Increase recovery_multiplier to 1.3
Script errors Missing sensor Check Nordpool sensor exists

v2.0-Specific Issues

Issue: Template sensor shows β€œunknown” or β€œunavailable”

Cause: Template references period.shutdown_end which doesn’t exist in v2.0

Fix: Update all templates to use period.recovery_start instead:

{% set rec_start = as_timestamp(period.recovery_start) %}
{% elif now_ts >= shutdown_ts and now_ts < rec_start %}
  {% set ns.phase = 'shutdown' %}

Issue: Current phase stuck in one state

Check:

  1. Verify optimizer sensor has periods: Developer Tools β†’ States β†’ sensor.nordpool_cutoff_periods_python
  2. Check timestamps are valid ISO format (YYYY-MM-DDTHH:MM:SS)
  3. Verify template uses recovery_start (not shutdown_end)
  4. Check data_resolution attribute shows correct normalization

Issue: Nordpool sensor not found

Fix: Specify sensor explicitly in automation:

action:
  - service: python_script.nordpool_cutoff_optimizer
    data:
      np_entity: sensor.your_nordpool_sensor_name

Check sensor name in Developer Tools β†’ States and verify it has raw_today and raw_tomorrow attributes.

Issue: Mixed resolution data causing errors

Check: today_slot_minutes and tomorrow_slot_minutes attributes in optimizer sensor.

Normal: Both should be 15 (or 60 if using hourly data)

If different: v2.0 handles this automatically with normalization. Check data_resolution attribute shows β€œmixedβ†’15min”.

Debug Mode

Enable detailed logging:

logger:
  default: info
  logs:
    homeassistant.components.python_script: debug
    homeassistant.components.automation: debug

Expected Results

Results vary significantly based on your specific setup:

Factors Affecting Performance

Factor Impact on Results
Building insulation Better insulation = longer cutoffs possible
Thermal mass High mass (concrete) = better energy storage
Heat pump efficiency Higher SCOP = more cost-effective
Outdoor temperature Extreme temps require shorter cutoffs
Price volatility Higher peaks = better savings potential
Backup heating Multiple heat sources = more flexibility

What to Monitor

Track these metrics during your testing period:

Temperature metrics:

  • Indoor temperature during cutoffs (target: < 1Β°C drop)
  • Recovery time to normal temperature
  • Temperature variation between rooms

Energy metrics:

  • Total electricity consumption before/after
  • Peak hour consumption reduction
  • Actual savings vs estimated savings

Comfort metrics:

  • Perceived comfort level (subjective)
  • Cold spot occurrence
  • Recovery comfort (overheating?)

v2.0 Telemetry:

  • data_resolution - Verify correct data normalization
  • multiplier_source - Confirm weather-adaptive or fallback
  • nordpool_entity - Ensure correct sensor is used

Typical Results Range

Based on system design theory and building thermodynamics:

  • Savings on cutoff days: 10-35% of peak hour costs
  • Temperature impact: 0.3-1.0Β°C drop during cutoff
  • Optimal cutoff duration: 2-4 hours depending on weather
  • Recovery time: Equal to cutoff duration

Your mileage WILL vary! Start conservative and iterate based on real data.

Community Results

We encourage users to share their results in the Home Assistant Community forum.

Consider sharing:

  • Building type and insulation level
  • Heat pump model and SCOP
  • Outdoor temperature range
  • Cutoff duration and frequency
  • Actual savings percentage
  • Temperature impact
  • v2.0: Your Nordpool sensor configuration and data resolution

Future enhancement: We may create a collaborative data analysis project to build calibration guidelines based on community-submitted data including weather forecast integration.


Next Steps

  1. :white_check_mark: Complete installation checklist
  2. :white_check_mark: Calibrate parameters for YOUR building
  3. :white_check_mark: Test for 1 week in monitoring mode
  4. :white_check_mark: v2.0: Verify telemetry attributes
  5. :white_check_mark: Enable full automation
  6. :white_check_mark: Fine-tune based on results
  7. :white_check_mark: Share your results in the community!

Contributing Your Example

If you’ve successfully integrated this with a different system, please share!

Submit to GitHub:

  • Fork the repository
  • Create examples/your_system/
  • Include README, configuration, and results
  • Submit Pull Request

Discuss in Community:

  • Share your setup in the forum thread
  • Help others with questions
  • Improve the documentation

Summary

  • The optimizer is generic - you adapt it to YOUR loads
  • Start with conservative parameters and iterate
  • Monitor temperature during cutoffs
  • Always include safety overrides
  • Test thoroughly before full automation
  • v2.0: Use recovery_start attribute (not shutdown_end)
  • v2.0: Check telemetry for troubleshooting
  • Share your results to help others

Questions? Discuss in the Home Assistant Community thread

1 Like

This is exactly what I was searching for! Some of the electricity companies here in the Netherlands also starting inementing 15min dynamic priceblocks. Mine not yet, but I think it doesn’t matter for your project.

Is there something I/we can test? The easiest way is a HACS integration I think, with a good readme. Reading this post, that is not a problem for you.

My house has a Mitsubishi Heavy Industries Airco, floor heating and radiators. I’m controlling the Airco now with the HACS integration Versatile Thermostat, wich not thinks about prices…

1 Like

This is an excellent project!! My compliments Niko!

I have tried something similar by taking the 8 most expensive hours, put them in order hight to low, then takinh out the sequential hours; I do not want my bathroom floor to be swithed off for more than one hour otherwise re-heating will take too long, especially at -10 to -24 degrees outsside. Then I put the hours in a calendar and can use it in automations (in combination with procing levels, outside temp and working day).

But now in Finland we switched to a 15 min interval pricing and that multiplied all my helpers with 4 and turned my very basic scripting into an unmanagable hell…

So if you are willing to share the details of your setup, it would at least help me out a lot…

Best , Olaf

1 Like

Thank you for the interest on the project!

The HACS coding/ integration implementation might not be suitable to explain why, what and how to implement in the most diverse range of housing options and energy solutions. My idea now is to give one the tools how to implement in one’s own house what I have done in my house.

But never the less, I think you find the code snippets and explanation posts at least inspiring to tweak your own system.

See you soon on the part 2.

I feel you, trust me! Countless hours on my own system trying to optimize it for surging electricity prices.

As interest is here, I will see you soon at the part 2. :slight_smile:

Excellent - thank you!

:gift: BONUS: Using the Official Nordpool Integration

Alternative implementation for the core integration


Background

The cutoff optimizer was originally designed for the HACS Nordpool custom component which provides convenient sensor attributes with all price data. However, Home Assistant now has an official Nordpool integration (since 2024.12) that works differently.

Key differences:

  • HACS version: Exposes prices as sensor attributes (raw_today, raw_tomorrow)
  • Official version: Uses service calls to fetch price data (nordpool.get_prices_for_date)

Since I don’t personally use the official integration, this guide is theoretical but based on the official documentation and community feedback.


:warning: Important Considerations

Why HACS might be better for this use case:

HACS Custom Component advantages:

  • All price data readily available in sensor attributes :white_check_mark:
  • No need for trigger templates :white_check_mark:
  • Simpler integration with Python scripts :white_check_mark:
  • More features out-of-the-box (VAT, additional costs built-in) :white_check_mark:

Official Integration advantages:

  • Maintained by Home Assistant core team :white_check_mark:
  • Guaranteed 15-minute MTU support after transition :white_check_mark:
  • No HACS dependency :white_check_mark:
  • Official support channel :white_check_mark:

Can they coexist?

No - They share the same domain (nordpool), so only one can be installed at a time.


Adapting the Python Script

The main challenge is that the official integration doesn’t expose raw_today and raw_tomorrow attributes. Instead, you need to call services to get price data.

Option 1: Pre-fetch prices with a trigger template

Create a helper template sensor that fetches prices and stores them:

template:
  - trigger:
      - trigger: time_pattern
        minutes: /15
      - trigger: homeassistant
        event: start
    action:
      - action: nordpool.get_prices_for_date
        data:
          config_entry: YOUR_CONFIG_ENTRY_ID  # Get this from Developer Tools
          date: "{{ now().date() }}"
          areas: FI
          currency: EUR
        response_variable: today_price
      
      - action: nordpool.get_prices_for_date
        data:
          config_entry: YOUR_CONFIG_ENTRY_ID
          date: "{{ now().date() + timedelta(days=1) }}"
          areas: FI
          currency: EUR
        response_variable: tomorrow_price
    
    sensor:
      - name: "Nordpool Price Data"
        unique_id: nordpool_price_data_helper
        state: "{{ now().isoformat() }}"
        attributes:
          raw_today: >
            {% set data = namespace(prices=[]) %}
            {% if today_price is mapping %}
              {% for state in today_price['FI'] %}
                {% set data.prices = data.prices + [{'start': state.start, 'end': state.end, 'value': state.price / 1000}] %}
              {% endfor %}
            {% endif %}
            {{ data.prices }}
          
          raw_tomorrow: >
            {% set data = namespace(prices=[]) %}
            {% if tomorrow_price is mapping %}
              {% for state in tomorrow_price['FI'] %}
                {% set data.prices = data.prices + [{'start': state.start, 'end': state.end, 'value': state.price / 1000}] %}
              {% endfor %}
            {% endif %}
            {{ data.prices }}

How to get your config_entry ID:

  1. Go to Developer Tools β†’ Actions
  2. Select nordpool.get_prices_for_date
  3. Choose your Nordpool instance
  4. Switch to YAML mode
  5. Copy the config_entry value

Option 2: Modify the Python script

You would need to modify nordpool_cutoff_optimizer.py to:

  1. Call the nordpool.get_prices_for_date service instead of reading sensor attributes
  2. Parse the response format (which is slightly different)
  3. Handle the different data structure

Example modification (conceptual):

# OLD (HACS version):
nordpool_sensor = hass.states.get('sensor.nordpool_kwh_fi_eur_3_10_0')
raw_today = nordpool_sensor.attributes.get('raw_today', [])
raw_tomorrow = nordpool_sensor.attributes.get('raw_tomorrow', [])

# NEW (Official version):
# Read from the helper template sensor created above
price_helper = hass.states.get('sensor.nordpool_price_data_helper')
raw_today = price_helper.attributes.get('raw_today', [])
raw_tomorrow = price_helper.attributes.get('raw_tomorrow', [])

The advantage of Option 1 (helper template) is that you don’t need to modify the Python script at all - just point it to the new sensor entity.


Data Format Differences

HACS format:

{
  'start': '2025-10-07T00:00:00+03:00',
  'end': '2025-10-07T00:15:00+03:00',
  'value': 5.23  # c/kWh
}

Official format (from service call):

{
  'start': '2025-10-07T00:00:00Z',  # UTC!
  'end': '2025-10-07T00:15:00Z',
  'price': 52.3  # EUR/MWh (note: 1000x larger!)
}

Key differences:

  1. Timezone: Official returns UTC, HACS returns local time
  2. Units: Official uses EUR/MWh, HACS uses c/kWh
  3. Key names: price vs value

The helper template above handles these conversions.


Complete Setup Steps

1. Install Official Nordpool Integration

  • Go to Settings β†’ Devices & Services
  • Click + Add Integration
  • Search for β€œNord Pool”
  • Configure with your area and currency

2. Create Helper Template

Copy the helper template from Option 1 above to your configuration.yaml (or use packages).

Important: Replace YOUR_CONFIG_ENTRY_ID with your actual config entry ID!

3. Modify Script Configuration

In the Python script input configuration, change the Nordpool sensor entity:

# Change this line in your script configuration:
# OLD:
NORDPOOL_SENSOR = 'sensor.nordpool_kwh_fi_eur_3_10_0'

# NEW:
NORDPOOL_SENSOR = 'sensor.nordpool_price_data_helper'

4. Verify Data Format

Check that the helper sensor attributes match the expected format:

- Developer Tools β†’ States
- Find: sensor.nordpool_price_data_helper
- Check attributes: raw_today and raw_tomorrow

They should look like the HACS format (with value key, c/kWh units).


Limitations & Caveats

:warning: I haven’t tested this myself - I use the HACS version which works perfectly for this use case.

Potential issues:

  1. Performance: Calling services every 15 minutes might be slower than reading attributes
  2. Timing: Service calls might fail if API is unavailable
  3. Complexity: More moving parts = more potential failure points
  4. 15-minute transition: Untested how this behaves during the MTU transition

Recommendation: If the HACS version works for you, stick with it. The official integration is better suited for simpler use cases (Energy Dashboard, basic automations).


Why This Matters

The official integration is great for most users who just want current/next/average prices. But for advanced optimization like our cutoff scheduler, having all price data easily accessible (as the HACS version provides) is significantly simpler.

This is not a criticism of the official integration - it’s designed for different use cases and intentionally keeps things simple and maintainable.


Community Feedback Needed! :pray:

If you use the official Nordpool integration with this optimizer:

  • Did the helper template work?
  • Any issues with timing or data format?
  • Performance problems?
  • Better solutions?

Please share your experience! This will help improve the documentation for official integration users.


This is a community-contributed guide. If you successfully adapt the system for the official integration, please share your configuration!


Questions? Discuss below!

1 Like

Hi! Just curious, did you try out the official Nordpool-integration or did you go with HACS one?

1 Like

Hi Niko, Im using the HACS one; but even after reading this page 3 times, it still looks like a giant undertaking. And my use case is much simpler; perhaps more usefull to the simple people? :wink: I need input of 2 helpers: 1= the amount of (now 15 mins) chunks that my electricity can be β€œoff” during 24hrs and "= the amount of consecutive chunk allowed to be off. Using that I want to write events in the calendar β€œOff_Time”. Thats it. Based on those events you can automate like no tomorrow :slight_smile: Im trying to find snippets and learn from your work - but its a steep (but brilliant) hill…

I think I understand better now - it sounds like you already have a working system with 1-hour data, and you want to adapt it to work with 15-minute data while keeping your calendar-based automation approach. That’s actually much simpler than the full HVAC optimizer!

Quick questions to understand your current setup:

  1. Your current system: How do you find the expensive hours now?

    • Template sensor?
    • Custom integration?
    • Manual selection?
  2. Desired granularity: With 15-minute chunks, do you want:

    • Option A: Full hour blocks (4 Γ— 15min together)
      • Example: OFF 14:00-15:00 and 18:00-19:00
    • Option B: Flexible 15min chunks (can be separate)
      • Example: OFF 14:15-14:30, 14:45-15:00, 18:00-18:15, etc.
  3. Current calendar integration: How do you create calendar events now?

    • Automation?
    • Python script?
    • Manual?

Proposed Solutions (Lightweight):

Depending on your answers, I can provide:

Solution 1: Aggregate 15min β†’ hourly (keep your current logic)

  • Template sensor that converts 15min prices to hourly averages
  • Your existing automation keeps working as-is
  • Minimal changes to your setup
  • Example template:
    template:
      - sensor:
          - name: "Nordpool Hourly Aggregated"
            state: "{{ now().isoformat() }}"
            attributes:
              hourly_prices: >
                {% set prices = state_attr('sensor.nordpool_kwh_fi_eur_3_10_0', 'raw_today') %}
                {% set hourly = namespace(data=[]) %}
                {% for i in range(0, prices|length, 4) %}
                  {% set hour_avg = (prices[i:i+4] | map(attribute='value') | sum) / 4%}
                  {% set hourly.data = hourly.data + [{'hour': i//4, 'price': hour_avg}] %}
                {% endfor %}
                {{ hourly.data }}
    

Solution 2: Native 15min chunk selector

  • Template sensor that finds N most expensive 15min periods
  • Respects your constraints (max total, max consecutive)
  • Outputs list of periods for your calendar automation

Can you share a bit about your current setup? That way I can give you the most relevant solution without over-engineering it!

If you want to keep things really simple, Solution 1 (aggregation) might be all you need - it would make your existing 1h system work with 15min data with just one extra template sensor.

1 Like

Hi Niko, Thanks for yout reply. To answer your questions:

  1. How do you find the expensive hours now?: using a template sensor like so
    sensor:
      - name: "Elec_Today_Time_High_to_Low_1"
        state: >
          {% set prices = state_attr('sensor.nordpool', 'raw_today') %}
          {% if prices %}
            {% set sorted = prices | sort(attribute='value', reverse=true) %}
            {{ sorted[0].start }}
          {% else %}
            unavailable
          {% endif %}
      - name: "Elec_Today_Price_High_to_Low_1"
        state: >
          {% set prices = state_attr('sensor.nordpool', 'raw_today') %}
          {% if prices %}
            {% set sorted = prices | sort(attribute='value', reverse=true) %}
            {{ sorted[0].value }}
          {% else %}
            unavailable
          {% endif %}
          
      - name: "Elec_Today_Time_High_to_Low_2"
        state: >
          {% set prices = state_attr('sensor.nordpool', 'raw_today') %}
          {% if prices %}
            {% set sorted = prices | sort(attribute='value', reverse=true) %}
            {{ sorted[1].start }}
          {% else %}
            unavailable
          {% endif %}
      - name: "Elec_Today_Price_High_to_Low_2"
  1. Granurarity: I thought about combining 4x15 mins into one hour, but i think that the 15 min chunks gives us more flexibility and possibly the opportunity to isolate the expensive moments.

  2. Calendar events creation: through automation

  - repeat:
      count: 8
      sequence:
      - variables:
          idx: '{{ repeat.index }}'
          adj_val: '{{ states("input_text.elec_tomorrow_adj_" ~ idx) }}'
          is_valid_hour: '{% set h = adj_val | int(-1) %} {{ h >= 0 and h <= 23 }}

            '
          start_time: "{% if is_valid_hour %}\n  {{ states(\"sensor.elec_tomorrow_time_high_to_low_\"
            ~ idx)[:16] }}\n{% else %}\n  \"\"\n{% endif %}\n"
          end_time: "{% if is_valid_hour %}\n  {{ (as_datetime(start_time) + timedelta(hours=1)).isoformat()
            }}\n{% else %}\n  \"\"\n{% endif %}\n"
      - choose:
        - conditions:
          - condition: template
            value_template: '{{ is_valid_hour }}'
          sequence:
          - service: calendar.get_events
            target:
              entity_id: calendar.switchingoffcalendar
            data:
              start_date_time: '{{ start_time }}'
              end_date_time: '{{ end_time }}'
            response_variable: agenda
          - choose:
            - conditions:
              - condition: template
                value_template: '{{ agenda[''calendar.switchingoffcalendar''][''events'']
                  | length == 0 }}

                  '
              sequence:
              - service: calendar.create_event
                target:
                  entity_id: calendar.switchingoffcalendar
                data:
                  summary: SwitchOffHour
                  start_date_time: '{{ start_time }}'
                  end_date_time: '{{ end_time }}'

And my β€œadjacent hour filter” is quite clumsy, but it works :wink:

alias: Adjacency Filter
  triggers:
  - entity_id: input_boolean.testing
    to: 'on'
    trigger: state
  - trigger: time_pattern
    hours: /2
  conditions:
  - condition: template
    value_template: "      {{ state_attr('sensor.nordpool', 'tomorrow') is defined
      and\n         state_attr('sensor.nordpool', 'tomorrow') | length > 0 }}"
  actions:
  - variables:
      list_a: []
  - repeat:
      count: 8
      sequence:
      - variables:
          idx: '{{ repeat.index }}'
          raw: '{{ states("sensor.elec_tomorrow_time_high_to_low_" ~ idx) }}'
          hour: "{% if raw not in ['unknown', 'unavailable', '', None] %}\n  {{ raw[11:13]
            | int }}\n{% else %}\n  99\n{% endif %}\n"
      - variables:
          entity_id: '{{ "input_text.elec_tomorrow_adj_" ~ idx }}'
          val: "{% if hour != 99 and (hour not in list_a) %}\n  {{ hour }}\n{% else
            %}\n  \"\"\n{% endif %}\n"
          list_a: "{% set a = list_a %} {% if hour != 99 and (hour not in list_a)
            %}\n  {% set a = a + [hour - 1, hour, hour + 1] %}\n{% endif %} {{ a }}\n"
      - data:
          entity_id: '{{ entity_id }}'
          value: '{{ val }}'
        action: input_text.set_value
      - data:
          entity_id: input_text.list_a_debug
          value: '{{ list_a }}'
        action: input_text.set_value

I hope this gives you an idea of my setup.

Hi @opk β€” thanks for the detailed write-up! From your post I understand your current 1-hour system does this: pick the 8 most expensive hours, drop adjacent hours (so the bathroom floor is never off for >1h), then write those hours to a calendar and use that in automations. We’ll keep that exact flow and just switch the granularity to 15-minute slots while preserving your original hours total.

Goal: If you want 8 hours off in total, you now want 32 Γ— 15-min slots off (and still allow at most 1 hour in a row β†’ max 4 consecutive 15-min slots).


Minimal edits (keep your calendar-based system)

1) Keep your β€œtop-N expensive” list as is

Your sensor(s) that already produce a time-ordered list will naturally point to 15-minute start times with 15-min pricing. No redesign needed.

2) Replace β€œ8 hours” with β€œtarget slots = hours Γ— 4”

Add a helper or variable and compute the number of slots you want:

# variables you already set before looping candidates
- variables:
    hours_to_cut: 8                           # or: states('input_number.cutoff_hours')|int
    target_slots: '{{ hours_to_cut * 4 }}'    # 8h -> 32 quarter-hours
    chosen: []                                # store chosen quarter indices (0..95)

3) Selection rule: allow up to 4 consecutive slots

When you iterate your candidates (now 15-min starts), accept a candidate qindex only if adding it doesn’t create a run longer than 4:

# inside your existing repeat over "candidates by price (desc)"
- variables:
    raw: '{{ states("sensor.elec_tomorrow_time_high_to_low_" ~ repeat.index) }}'
    qindex: >-
      {% if raw not in ['unknown','unavailable','', None] %}
        {% set dt = as_datetime(raw) %}
        {{ (dt.hour * 4) + (dt.minute // 15) }}
      {% else %} -1 {% endif %}

    # Accept if: not picked yet, within 0..95, and adding it won't exceed 4-in-a-row
    ok: >-
      {% set a = chosen %}
      {% set q = qindex|int(-1) %}
      {% if 0 <= q <= 95 and (q not in a) %}
        {% set left  = ((q-1) in a)|int + (((q-2) in a) and ((q-1) in a))|int + (((q-3) in a) and ((q-2) in a) and ((q-1) in a))|int %}
        {% set right = ((q+1) in a)|int + (((q+2) in a) and ((q+1) in a))|int + (((q+3) in a) and ((q+2) in a) and ((q+1) in a))|int %}
        {{ (left + 1 + right) <= 4 }}
      {% else %} false {% endif %}

    chosen: >-
      {% set a = chosen %}
      {% if ok and (a|length) < target_slots|int %}
        {{ (a + [qindex]) | sort }}
      {% else %} {{ a }} {% endif %}

This keeps picking the priciest 15-min slots until you have 32 (or whatever hoursΓ—4 is) while ensuring no block exceeds 4 in a row (i.e., ≀ 1 hour contiguous β€œOFF”), just like before.

4) Calendar writer: change only the duration to 15 minutes

Where you currently write calendar events for each selected time, keep your logic but set end = start + 15 min. Use the same raw timestamp (no double-templating), or compute from qindex β€” here’s the simple timestamp-based version:

# when emitting events for each accepted slot
- variables:
    start_time: >-
      {{ as_datetime(raw).isoformat() }}            # from your existing sensor timestamp
    end_time: >-
      {{ (as_datetime(raw) + timedelta(minutes=15)).isoformat() }}

# ... then call your existing calendar.create_event with these times

That’s all: your automations that react to the calendar don’t need to change β€” they’ll simply see 15-minute events that still total your original N hours per day, with no more than one hour off at a time.

Tell me if I understood your system correctly and have fun testing modifications out!

1 Like

I guess helpers etc have to be created now up to …_96?

Eg in my template:


 sensor:
      - name: "Elec_Today_Time_High_to_Low_1"
        state: >
          {% set prices = state_attr('sensor.nordpool', 'raw_today') %}
          {% if prices %}
            {% set sorted = prices | sort(attribute='value', reverse=true) %}
            {{ sorted[0].start }}
          {% else %}
            unavailable
          {% endif %}
      - name: "Elec_Today_Price_High_to_Low_1"
        state: >
          {% set prices = state_attr('sensor.nordpool', 'raw_today') %}
          {% if prices %}
            {% set sorted = prices | sort(attribute='value', reverse=true) %}
            {{ sorted[0].value }}
          {% else %}
            unavailable
          {% endif %}
          
      - name: "Elec_Today_Time_High_to_Low_2"
        state: >
          {% set prices = state_attr('sensor.nordpool', 'raw_today') %}
          {% if prices %}
            {% set sorted = prices | sort(attribute='value', reverse=true) %}
            {{ sorted[1].start }}
          {% else %}
            unavailable
          {% endif %}
      - name: "Elec_Today_Price_High_to_Low_2"
        state: >
          {% set prices = state_attr('sensor.nordpool', 'raw_today') %}
          {% if prices %}
            {% set sorted = prices | sort(attribute='value', reverse=true) %}
            {{ sorted[1].value }}
          {% else %}
            unavailable
          {% endif %}

      - name: "Elec_Today_Time_High_to_Low_3"
        state: >
          {% set prices = state_attr('sensor.nordpool', 'raw_today') %}
          {% if prices %}
            {% set sorted = prices | sort(attribute='value', reverse=true) %}
            {{ sorted[2].start }}
          {% else %}
            unavailable
          {% endif %}
      - name: "Elec_Today_Price_High_to_Low_3"
        state: >
          {% set prices = state_attr('sensor.nordpool', 'raw_today') %}
          {% if prices %}
            {% set sorted = prices | sort(attribute='value', reverse=true) %}
            {{ sorted[2].value }}
          {% else %}
            unavailable
          {% endif %}

etc etc now till 24, but all needs to go to 96. I assume there is an easier way…

Great question!

You don’t need 96 helpers: you can avoid the calendar entirely by using one template binary sensor (e.g., binary_sensor.hvac_cutoff_now) that turns on during the selected 15-minute β€œcut-off” slots (e.g., top 32 most expensive slots with a max 4 in a row constraint). That keeps your logic declarative and avoids maintaining dozens of helpers.

Quick clarifier:

what do you use the calendar entries for in practiceβ€”just switching a single device on/off, or do you need a human-readable schedule to coordinate multiple automations/ devices?

If it’s the latter, the built-in Schedule helper or the community Scheduler component + card can still present/override the plan, but for dynamic daily Nordpool windows a single template binary sensor is simpler and more robust.

If helpful, here’s a tiny YAML snippet that computes the β€œnow in cut-off window” flag from a Nordpool sensor’s 15-minute data and enforces β€œmax 4 consecutive slots.” (Most Nordpool setups expose per-slot prices via attributes like raw_today, and since the move to 15-minute MTU there are 96 slots per day.)

# One binary sensor that turns ON during the selected 15-min β€œcut-off” slots.
# Drop this under your `template:` section in configuration.yaml (or a split file).

template:
  - trigger:
      # Re-evaluate once per minute and whenever the price sensor updates
      - platform: time_pattern
        minutes: "/1"
      - platform: state
        entity_id: sensor.nordpool_fi        # ← your price sensor entity
    binary_sensor:
      - name: hvac_cutoff_now
        unique_id: hvac_cutoff_now
        state: >-
          {% set SRC = 'sensor.nordpool_fi' %}
          {% set raw = state_attr(SRC,'raw_today') or [] %}
          {% set target_slots = 32 %}  {# total 15-min slots to cut (e.g., 32 = 8 hours) #}
          {% set max_seq = 4 %}        {# max consecutive 15-min slots (4 = 1 hour)      #}
          {% if not raw %}
            false
          {% else %}
            {# Build (index, value) pairs and sort by price, highest first #}
            {% set prices = raw | map(attribute='value') | list %}
            {% set pairs = [] %}
            {% for i in range(prices|length) %}
              {% set pairs = pairs + [{'i': i, 'v': prices[i]}] %}
            {% endfor %}
            {% set ranked = pairs | sort(attribute='v', reverse=true) %}

            {# Greedy pick: choose most expensive slots, but never exceed max_seq in a row #}
            {% set pick = namespace(chosen=[]) %}
            {% for p in ranked %}
              {% if pick.chosen|length >= target_slots %}
                {% break %}
              {% endif %}
              {% set i = p.i %}
              {% set before = [i-1,i-2,i-3] | select('in', pick.chosen) | list %}
              {% set after  = [i+1,i+2,i+3] | select('in', pick.chosen) | list %}
              {% if (before|length + 1 + after|length) <= max_seq %}
                {% set _ = pick.chosen.append(i) %}
              {% endif %}
            {% endfor %}

            {# Map current time to today’s 15-min slot index (0..95) #}
            {% set now_idx = ((now().hour*60 + now().minute) // 15) %}
            {{ now_idx in pick.chosen }}
          {% endif %}

What it does

  • Creates one binary sensor: binary_sensor.hvac_cutoff_now.
  • It updates every minute and whenever your Nordpool sensor changes.
  • It looks at today’s 15-minute prices, picks the 32 most expensive slots (8 hours total), and enforces max 4 in a row (so a single cut never exceeds 1 hour). The sensor is ON inside those slots and OFF otherwise.
  • Tweak target_slots (minutes to cut Γ· 15) and max_seq (longest allowed continuous cut).
  • If you still want a visual/override UI, pair it with Schedule or Scheduler; otherwise, use this single sensor directly in your automations.

How to use hvac_cutoff_now in an automation (instead of a calendar)

Option A β€” edge-based control (simple & robust): react when the sensor flips ON/OFF and turn HVAC off/on accordingly.

alias: HVAC – obey hvac_cutoff_now
mode: restart
trigger:
  - platform: state
    entity_id: binary_sensor.hvac_cutoff_now   # fires on both ON and OFF
  - platform: homeassistant
    event: start                               # reconcile state after a restart
condition: []
action:
  - choose:
      # During expensive slots β†’ cut off HVAC (and any other loads you want)
      - conditions: "{{ is_state('binary_sensor.hvac_cutoff_now','on') }}"
        sequence:
          - service: climate.turn_off
            target:
              entity_id: climate.living_room   # ← your thermostat
          # Example extra:
          # - service: switch.turn_off
          #   target: { entity_id: switch.boiler }
      # Outside cut-off slots β†’ resume HVAC (pick what fits your device)
      - conditions: "{{ is_state('binary_sensor.hvac_cutoff_now','off') }}"
        sequence:
          - service: climate.turn_on
            target:
              entity_id: climate.living_room
          # or explicitly set a mode/preset if needed:
          # - service: climate.set_hvac_mode
          #   target: { entity_id: climate.living_room }
          #   data: { hvac_mode: heat }

This uses standard automation triggers/conditions/actions and climate services that most thermostats support. If your device prefers explicit modes, swap to climate.set_hvac_mode.

Option B β€” keep your existing automation but replace the calendar condition:
Anywhere you had β€œcalendar is busy/active”, replace with:

condition:
  - condition: state
    entity_id: binary_sensor.hvac_cutoff_now
    state: "on"

That way your old logic stays intact, but the cut/off decision now comes from the dynamic 15-minute pricing windows.

1 Like

So 28 lines to replace my model that took me weeks… I think I’m going to live under a bridge :sob:

But hee, thx for your time and input. I will definitely use it. Especially now our pricing went to 75ct/kWh…

Hi @0PK β€” I feel you, totally do. When I upgraded to 15-min pricing, a lot of my HASS stack needed a cleanup too. For a couple of months I’ve been using AI coding assistants to refactor/test snippets of the code ; it’s a huge unlock when you’re stuck in getting something off the ground fast.

And your comment made me smile β€” we’ve all had those β€œweeks of work vs 28 lines” moments. Thanks for being such a good sport (and ouch, I dread those 75c/kWh spikes too!).

One friendly side-note on the simple ON/OFF approach: make it robust. Internet can wobble, Nordpool can temporarily miss attributes (e.g., raw_today), and restarts happen β€” so it’s worth adding a few guardrails. Below is the tiny guardrail to be used.

A tiny, robust pattern (binary sensor + graceful fallback)

# Robust 15-min cutoff sensor (with Jinja2 namespace + DST-safe now-slot)
# Paste under your `template:` section (or a split file) and adjust entity IDs.

template:
  - trigger:
      # Recalculate once per minute and on any price sensor update
      - platform: time_pattern
        minutes: "/1"
      - platform: state
        entity_id: sensor.nordpool_fi          # ← your Nordpool entity
    binary_sensor:
      - name: hvac_cutoff_now
        unique_id: hvac_cutoff_now
        state: >-
          {# --- Configuration knobs --- #}
          {% set SRC          = 'sensor.nordpool_fi' %}
          {% set target_slots = 32 %}   {# total cut time = 32Γ—15min = 8h #}
          {% set max_seq      = 4  %}   {# max consecutive slots = 4Γ—15min #}

          {# --- Input: today's 15-min slots as list of {start, end, value} --- #}
          {% set slots = state_attr(SRC, 'raw_today') %}

          {# --- Guardrail: if data is missing/short, do NOT cut (comfort-first) --- #}
          {% if slots is not iterable or slots|length < 20 %}
            false
          {% else %}
            {# We need a place to store values across loop iterations β†’ namespace #}
            {% set ns = namespace(pairs=[], chosen=[], now_idx=None) %}

            {# 1) Build (index, price) pairs for ranking #}
            {% for i in range(slots|length) %}
              {% set v = slots[i].value %}
              {% set ns.pairs = ns.pairs + [{'i': i, 'v': v}] %}
            {% endfor %}

            {# 2) Find the current slot by timestamps (DST/offset 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 %}

            {# Fallback (should rarely trigger): compute by minutes #}
            {% if ns.now_idx is none %}
              {% set ns.now_idx = ((now().hour*60 + now().minute) // 15) %}
            {% endif %}

            {# 3) Rank slots by price (highest first) #}
            {% set ranked = ns.pairs | sort(attribute='v', reverse=true) %}

            {# 4) Greedy select most expensive slots while enforcing max_seq #}
            {% for p in ranked %}
              {% if ns.chosen|length >= target_slots %}{% break %}{% endif %}
              {% set i = p.i %}
              {% set before = [i-1, i-2, i-3] | select('in', ns.chosen) | list %}
              {% set after  = [i+1, i+2, i+3] | select('in', ns.chosen) | list %}
              {# Allow exactly max_seq in a row, but never more #}
              {% if (before|length + 1 + after|length) <= max_seq %}
                {% set ns.chosen = ns.chosen + [i] %}
              {% endif %}
            {% endfor %}

            {# 5) Sensor is ON when we're currently in a chosen (cutoff) slot #}
            {{ ns.now_idx in ns.chosen }}
          {% endif %}

1 Like

One thing still - how to test that the template is working. I lookead at the hvac_cutoff, but is was off the whole day…

1 Like

Hey @0PK, dang friend :pray: β€” I let an AI assistant generate that snippet and I didn’t double-check for the classic Jinja2 scope pitfall. In Jinja, assignments inside a for loop don’t persist unless you use a namespace object. That’s on me. I looked the code after your reply and saw the problem, sorry for that!

I changed the snippet at the last post, the idea is the same, but fixed and commented. It uses a namespace for all list accumulation inside loops and also resolves the current 15-minute slot using timestamps (DST-safe). If Nordpool data is missing, it fails safe to OFF (comfort-first).

BTW, with one of my main AI-coder assistants in β€œHASS project space”, which I did not use this time for simple code snippet, I specifically tell AI to double check this namespace issue everytime we pingpong the codebase. Sorry again for confusion and non working snippet.