Skip to content

Energy demand profile: to take heating into account#28232

Draft
daniel309 wants to merge 112 commits into
evcc-io:masterfrom
daniel309:feature/temperature-correction
Draft

Energy demand profile: to take heating into account#28232
daniel309 wants to merge 112 commits into
evcc-io:masterfrom
daniel309:feature/temperature-correction

Conversation

@daniel309

@daniel309 daniel309 commented Mar 15, 2026

Copy link
Copy Markdown
Contributor

Temperature-Based Household Load Correction with Heater Profile Separation

Base PR: #27780 (Weather Tariff)
This PR: Heater Profile Separation for Temperature Correction

note: this PR doesn't change the database schema. It is based on, and uses the "history for meters" work merged via #23185.

TL/DR

With this PR optimizer forecasts for the household load are significantly improved when you have a heatpump device registered in evcc.

Before that they were barely useable because:

  1. heater loadpoints got subtracted from profile gt, and
  2. no temperature correction applied.

evidence of the effects of this PR: #28232 (comment)
explanation of the mathematical function to estimate corrections: #28232 (comment)

Problem

The evopt energy optimizer uses a household load profile (gt) to predict how much energy the home will consume in each future 15-minute slot. This profile is currently computed as a 30-day historical average — the same flat pattern is repeated regardless of weather conditions.

This is a significant blind spot: heating and cooling loads are strongly temperature-dependent. On a cold winter day, a home with a heat pump or electric heating can consume 30–80% more energy than on a mild day. When the optimizer doesn't know this, it:

  • Under-estimates household demand on cold days → schedules EV charging at times when grid power is actually needed for heating
  • Over-estimates household demand on warm days → unnecessarily avoids cheap/green charging windows
  • Misses opportunities to pre-charge the battery before a cold night when heating demand will spike

Important: The evcc optimizer is a mathematical solver that optimizes EV charging and battery schedules based on given inputs (solar forecast, grid prices, household load). It cannot forecast household load itself—it requires accurate future household load predictions as input. Without temperature-corrected heat pump load forecasts, the optimizer receives inaccurate household demand predictions, leading to suboptimal charging decisions that conflict with actual heating needs.

Solution Overview

This PR builds on #27780 (which added the open-meteo weather tariff) and implements temperature-based correction of the household load profile with a critical improvement: the correction is applied only to heating device loads that are explicitly marked as temperature-sensitive.

Key Innovation: Selective Temperature Correction

Not all heating devices have temperature-dependent loads. For example:

  • Heat pumps are highly temperature-dependent
  • Auxiliary electric heaters may be temperature-dependent (more electricity because its colder outside) or not when used for warm water.

This PR implements a four-step process:

  1. Identify: Detect which heating devices are temperature-sensitive via the OutdoorTemperatureSensitive feature flag
  2. Separate: Extract temperature-sensitive and non-sensitive heater profiles separately
  3. Correct: Apply temperature adjustment only to temperature-sensitive heaters
  4. Merge: Combine base load + corrected temp-sensitive heaters + uncorrected non-sensitive heaters
Total Household = Base Load + Temp-Sensitive Heaters + Non-Sensitive Heaters
                  (gt_base)   (gt_temp_corrected)      (gt_non_sensitive)
                      ↓                ↓                        ↓
                 [unchanged]   [temp corrected]           [unchanged]
                      ↓                ↓                        ↓
                  Final Profile = gt_base + gt_temp_corrected + gt_non_sensitive

Note on Historical Data Periods:

  • Base load profile: 30-day historical average (excludes all loadpoints)
  • Heater profiles: 7-day historical data (for both temp-sensitive and non-sensitive)
  • Temperature averaging: 7-day historical average per hour-of-day for correction calculation

Temperature Correction Algorithm

The correction algorithm uses a physics-based model that relates heating load to the temperature difference between indoor and outdoor conditions:

image

which becomes:

load[i] = load_avg[i] × ((T_room − T_forecast[i]) / (T_room − T_past_avg[h]))

where:

  • T_room = 21°C (constant room temperature)
  • T_past_avg[h] = average temperature at hour-of-day h over the past 7 days
  • T_forecast[i] = forecast temperature at the wall-clock time of slot i

This formula models heating load as proportional to the temperature difference that must be maintained. The correction factor represents the ratio of future heating demand to historical average demand.

Safety Check: If historical temperature is within 0.5°C of room temperature (indicating heating was likely off), the correction is skipped for that time slot.

Clamping: Correction factors are clamped to the range [0.5, 2.0] to prevent extreme corrections from bad data.

Example: With a 7-day historical average of 8°C at a given hour:

  • Forecast 8°C → no correction (factor = (21-8)/(21-8) = 1.0)
  • Forecast 3°C → +38% heater load (factor = (21-3)/(21-8) = 1.38)
  • Forecast −2°C → +77% heater load (factor = (21-(-2))/(21-8) = 1.77)
  • Forecast 13°C → −38% heater load (factor = (21-13)/(21-8) = 0.62)

Configuration

The feature is fully opt-in — no changes needed for existing setups. Temperature correction is only active for heating devices explicitly marked as temperature-sensitive.

Basic Setup (requires base PR #27780)

tariffs:
  temperature:
    type: template
    template: open-meteo-temperature
    latitude: 48.1
    longitude: 11.6

chargers:
  - name: heatpump
    type: template
    template: luxtronik
    host: 192.168.1.10
    # Luxtronik template includes outdoortemperaturesensitive by default

  - name: water_heater
    type: custom
    features:
      - heating                    # Mark as heating device
      # No outdoortemperaturesensitive - no correction applied

Configuration Details

Temperature Tariff (required for correction):

  • Must be configured as shown above
  • Provides historical and forecast temperature data from Open-Meteo

Heating Device Features:

  • heating - Marks the device as a heating system (required for all heating devices)
  • outdoortemperaturesensitive - Enables temperature correction for this specific device (optional)

Important:

  • Devices with only heating feature will have their consumption included in forecasts but without temperature correction
  • Devices with both heating and outdoortemperaturesensitive will have temperature correction applied
  • This allows mixing temperature-dependent devices (space heating heat pumps) with schedule-based devices (water heaters, pool heaters)

Backward Compatibility

The feature is completely opt-in and requires explicit configuration. Existing setups are unaffected.

Multiple Heater Support

The implementation fully supports multiple heating devices with selective correction:

  1. Automatic Detection: All loadpoints with api.Heating feature are automatically identified
  2. Selective Correction: Only devices with api.OutdoorTemperatureSensitive get temperature correction
  3. Individual Tracking: Each heater's consumption is tracked separately in the database using its loadpoint ID
  4. Slot-by-Slot Aggregation: Multiple heater profiles are summed together for each 15-minute slot
  5. Separate Processing: Temperature-sensitive and non-sensitive heaters are processed independently

This approach ensures that:

  • Each heater's historical pattern is preserved
  • Only temperature-dependent loads are adjusted for weather
  • Schedule-based heaters remain predictable
  • Total heating load is accurately represented
  • Works with any number and combination of heating devices

Database Schema

Uses the new entity and meters tables introduced by PR #23185.

Dependencies

  • Requires base PR Tariffs: add temperature type and OpenMeteo #27780 (Weather Tariff) to be merged (makes lots of changed files in this PR go away)
  • No new external dependencies
  • Uses existing api.Heating feature flag for device identification
  • Adds new api.OutdoorTemperatureSensitive feature flag for selective correction

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hey - I've found 4 issues, and left some high level feedback:

  • Access to loadpointEnergy and loadpointSlotStart maps happens from multiple goroutines in updateLoadpoints/updateLoadpointConsumption without synchronization, which risks concurrent map access panics; consider guarding these maps (and their contained state) with a mutex or encapsulating per-loadpoint state in a concurrency-safe structure.
  • In applyTemperatureCorrection, the loop for h := range 24 is invalid in Go and will not compile; it should be replaced with an index loop like for h := 0; h < 24; h++ when building pastTempAvg.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- Access to `loadpointEnergy` and `loadpointSlotStart` maps happens from multiple goroutines in `updateLoadpoints`/`updateLoadpointConsumption` without synchronization, which risks concurrent map access panics; consider guarding these maps (and their contained state) with a mutex or encapsulating per-loadpoint state in a concurrency-safe structure.
- In `applyTemperatureCorrection`, the loop `for h := range 24` is invalid in Go and will not compile; it should be replaced with an index loop like `for h := 0; h < 24; h++` when building `pastTempAvg`.

## Individual Comments

### Comment 1
<location path="core/site_optimizer.go" line_range="658-659" />
<code_context>
+			pastTempCount[h]++
+		}
+	}
+	pastTempAvg := make([]float64, 24)
+	for h := range 24 {
+		if pastTempCount[h] > 0 {
+			pastTempAvg[h] = pastTempSum[h] / float64(pastTempCount[h])
</code_context>
<issue_to_address>
**issue (bug_risk):** The `for h := range 24` loop is invalid Go and will not compile.

Use a standard indexed `for` loop here instead of ranging over an `int`, for example:

```go
pastTempAvg := make([]float64, 24)
for h := 0; h < 24; h++ {
    if pastTempCount[h] > 0 {
        pastTempAvg[h] = pastTempSum[h] / float64(pastTempCount[h])
    }
}
```
</issue_to_address>

### Comment 2
<location path="core/site.go" line_range="927-928" />
<code_context>
 	)

-	for _, lp := range site.loadpoints {
+	for i, lp := range site.loadpoints {
+		lpID := i // capture loop variable for goroutine
 		wg.Go(func() {
 			power := lp.UpdateChargePowerAndCurrents()
</code_context>
<issue_to_address>
**issue (bug_risk):** The goroutine still closes over the loop variable `lp`, which can cause data races and incorrect behavior.

You correctly capture `lpID`, but `lp` is still shared across iterations due to `range` semantics. Capture `lp` in a new local variable before starting the goroutine:

```go
for i, lp := range site.loadpoints {
    lpID := i
    lp := lp // capture lp for goroutine
    wg.Go(func() {
        power := lp.UpdateChargePowerAndCurrents()
        // ...
    })
}
```
This prevents the goroutine from seeing a different loadpoint than intended and avoids subtle concurrency bugs in the update methods.
</issue_to_address>

### Comment 3
<location path="core/site_optimizer.go" line_range="687-688" />
<code_context>
+			continue
+		}
+
+		h := ts.UTC().Hour()
+		tPastAvg := pastTempAvg[h]
+
+		// delta > 0: tomorrow colder than historical average → load increases
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Using a zero default for `tPastAvg` when there is no historical data for a given hour can skew the correction significantly.

When `pastTempCount[h] == 0`, `pastTempAvg[h]` stays at its zero value, which makes `tFuture` look much warmer than "history" and can drive overly strong negative corrections for those hours.

Consider skipping correction or using a safer fallback (e.g. overall past 24h average) when there’s no data:

```go
h := ts.UTC().Hour()
if pastTempCount[h] == 0 {
    continue // or use a fallback average
}
tPastAvg := pastTempAvg[h]
```

This prevents over-correcting when the historical baseline is unknown.

```suggestion
		h := ts.UTC().Hour()
		if pastTempCount[h] == 0 {
			// no historical data for this hour; skip correction (or use a fallback if available)
			continue
		}
		tPastAvg := pastTempAvg[h]
```
</issue_to_address>

### Comment 4
<location path="core/site_optimizer.go" line_range="515" />
<code_context>
 func (site *Site) homeProfile(minLen int) ([]float64, error) {
-	// kWh over last 30 days
-	profile, err := metrics.Profile(now.BeginningOfDay().AddDate(0, 0, -30))
+	from := now.BeginningOfDay().AddDate(0, 0, -7)
+	
+	// kWh average over last 7 days - base load (excludes loadpoints)
</code_context>
<issue_to_address>
**issue (complexity):** Consider refactoring the new home profile and temperature-correction logic into small reusable helpers and pre-indexed data structures to keep the core control flow simple and readable.

The new logic is valid but it does increase complexity in a few focused places. You can reduce it without changing behavior by extracting small helpers and pre-indexing data.

### 1) Repeated “repeat profile until minLen then trim” logic

This pattern appears multiple times in `homeProfile` for both base and heater profiles:

```go
slots := make([]float64, 0, minLen+1)
for len(slots) <= minLen+24*4 { // allow for prorating first day
	slots = append(slots, gt_base[:]...)
}
res := profileSlotsFromNow(slots)
if len(res) < minLen {
	return nil, fmt.Errorf("minimum home profile length %d is less than required %d", len(res), minLen)
}
if len(res) > minLen {
	res = res[:minLen]
}
```

and similarly for `heaterSlots`.

This can be encapsulated in a reusable helper:

```go
func repeatAndTrimProfile(profile []float64, minLen int) ([]float64, error) {
	slots := make([]float64, 0, minLen+1)
	for len(slots) <= minLen+24*4 {
		slots = append(slots, profile...)
	}

	res := profileSlotsFromNow(slots)
	if len(res) < minLen {
		return nil, fmt.Errorf("minimum home profile length %d is less than required %d", len(res), minLen)
	}
	if len(res) > minLen {
		res = res[:minLen]
	}
	return res, nil
}
```

Then `homeProfile` becomes simpler:

```go
gtBaseSlots, err := repeatAndTrimProfile(gt_base, minLen)
if err != nil {
	return nil, err
}

var gtHeaterSlots []float64
if len(gt_heater_raw) > 0 {
	if heater, err := repeatAndTrimProfile(gt_heater_raw, len(gtBaseSlots)); err == nil {
		gtHeaterSlots = heater
	}
}
```

This removes duplicated branching and makes the length-normalization logic easier to reason about.

### 2) Split `homeProfile` into clearer orchestration steps

`homeProfile` is currently doing: read base, read heater, normalize both, correct heater by temperature, merge, convert units. Extracting those as small helpers keeps the control flow readable:

```go
func (site *Site) buildBaseProfile(from time.Time, minLen int) ([]float64, error) {
	base, err := metrics.Profile(from)
	if err != nil {
		return nil, err
	}
	return repeatAndTrimProfile(base, minLen)
}

func (site *Site) buildHeaterProfile(from time.Time, minLen int) []float64 {
	raw := site.extractHeaterProfile(from, time.Now())
	if len(raw) == 0 {
		return nil
	}
	heater, err := repeatAndTrimProfile(raw, minLen)
	if err != nil {
		return nil
	}
	return site.applyTemperatureCorrection(heater)
}
```

`homeProfile` then reduces to an orchestrator:

```go
func (site *Site) homeProfile(minLen int) ([]float64, error) {
	from := now.BeginningOfDay().AddDate(0, 0, -7)

	base, err := site.buildBaseProfile(from, minLen)
	if err != nil {
		return nil, err
	}

	heater := site.buildHeaterProfile(from, len(base))

	final := make([]float64, len(base))
	for i := range base {
		final[i] = base[i]
		if heater != nil && i < len(heater) {
			final[i] += heater[i]
		}
	}

	return lo.Map(final, func(v float64, _ int) float64 { return v * 1e3 }), nil
}
```

Same behavior, but the main function reads as a high-level description of the steps.

### 3) Avoid O(N²) search in `applyTemperatureCorrection`

The inner loop that finds `tFuture` completely dominates the function’s complexity and obscures the main idea:

```go
for i := range profile {
	ts := slotStart.Add(time.Duration(i) * tariff.SlotDuration)

	var tFuture float64
	found := false
	for _, r := range rates {
		if r.Start.Equal(ts) {
			tFuture = r.Value
			found = true
			break
		}
	}
	if !found {
		continue
	}
	// ...
}
```

You can pre-index the forecast into a map and keep the slot loop single-level:

```go
func indexRatesByStart(rates api.Rates) map[time.Time]float64 {
	m := make(map[time.Time]float64, len(rates))
	for _, r := range rates {
		m[r.Start] = r.Value
	}
	return m
}
```

Use it in `applyTemperatureCorrection`:

```go
forecastByTime := indexRatesByStart(rates)

slotStart := currentTime.Truncate(tariff.SlotDuration)
for i := range profile {
	ts := slotStart.Add(time.Duration(i) * tariff.SlotDuration)

	tFuture, ok := forecastByTime[ts]
	if !ok {
		continue
	}

	h := ts.UTC().Hour()
	tPastAvg := pastTempAvg[h]
	delta := tPastAvg - tFuture
	result[i] = profile[i] * (1 + coeff*delta)
}
```

This both improves performance and reduces nesting, making the correction logic easier to follow.

### 4) Separate guard/validation from the core correction

The top of `applyTemperatureCorrection` is mostly guards and configuration checks. Extracting them into a small helper can clarify the “happy path”:

```go
func (site *Site) getWeatherRates() (api.Rates, float64, float64, error) {
	weatherTariff := site.GetTariff(api.TariffUsageTemperature)
	if weatherTariff == nil {
		return nil, 0, 0, fmt.Errorf("no weather tariff")
	}

	rates, err := weatherTariff.Rates()
	if err != nil || len(rates) == 0 {
		return nil, 0, 0, fmt.Errorf("no weather rates")
	}

	threshold := site.HeatingThreshold
	coeff := site.HeatingCoefficient
	if threshold == 0 || coeff == 0 {
		return nil, 0, 0, fmt.Errorf("heating config missing")
	}

	return rates, threshold, coeff, nil
}
```

Then:

```go
func (site *Site) applyTemperatureCorrection(profile []float64) []float64 {
	rates, threshold, coeff, err := site.getWeatherRates()
	if err != nil {
		return profile
	}

	// ... 24h avg, hourly history, forecastByTime, main loop ...
}
```

This keeps `applyTemperatureCorrection` focused on the actual correction math rather than the early-exit conditions.

These refactors maintain all functionality but reduce branching and nesting, making the new behavior easier to understand and maintain.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread core/site_optimizer.go
Comment thread core/site.go Outdated
Comment thread core/site_optimizer.go
Comment thread core/site_optimizer.go Outdated
@andig

andig commented Mar 15, 2026

Copy link
Copy Markdown
Member

First thing is to get #23185 in.

@naltatis

Copy link
Copy Markdown
Member

This contribution does not appear to meet our AI contribution guidelines.

@andig
andig marked this pull request as draft March 15, 2026 09:54
daniel309 added a commit to daniel309/evcc that referenced this pull request Mar 15, 2026
- Fix goroutine loop variable capture for loadpoint
- Skip temperature correction when no historical data for hour
- Add debug logging for missing historical temperature data

Addresses review comments from PR evcc-io#28232
@andig

andig commented Mar 15, 2026

Copy link
Copy Markdown
Member

@naltatis this PR shows general understanding of evcc and has prior PRs. Fine for me.

@daniel309

daniel309 commented Mar 15, 2026

Copy link
Copy Markdown
Contributor Author

ill add more evidence for local testing and benefits once I have the build green. should be there is a bit.

ai reviews addressed as follows
✅ Comment 2: Fixed goroutine loop variable capture
✅ Comment 3: Fixed zero default temperature handling with debug logging
✅ Comment 4: Implemented map-based rate lookup for clarity
✅ Linter: Fixed all gci formatting issues
❌ Comment 1: Not applicable (Go 1.22+ syntax valid)
❌ Overall: Not applicable (no concurrent map access issue)

@daniel309

daniel309 commented Mar 15, 2026

Copy link
Copy Markdown
Contributor Author

ok, ready for review.

because Base PR: #27780 (Weather Tariff) is open,

only look at these 3 files below. The rest are the temperature tariff code changes that go away once that PR is merged.

image

ill continue local testing and add evidence from the sqllite db, about migration (using my evcc.db file from latest) and most importantly, about the added precision of household load forecasts.

With this PR optimizer forecasts are finally useable when you have a heatpump device registered in evcc.

Before that they were unuseable because:

  1. heater loadpoints got substracted from profile gt, and
  2. no temperature correction applied.

@daniel309
daniel309 marked this pull request as ready for review March 15, 2026 10:28

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hey - I've found 2 issues, and left some high level feedback:

  • In applyTemperatureCorrection, the loop for h := range 24 will not compile in Go; it should be replaced with a standard indexed loop such as for h := 0; h < 24; h++ { ... }.
  • The temperature correction currently relies on an exact time.Time match between slotStart.Add(i*SlotDuration) and ratesByTime keys; to avoid missed corrections due to timezone or truncation mismatches, consider deriving future slots directly from the tariff rates or using a nearest-slot lookup instead of exact equality.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In applyTemperatureCorrection, the loop `for h := range 24` will not compile in Go; it should be replaced with a standard indexed loop such as `for h := 0; h < 24; h++ { ... }`.
- The temperature correction currently relies on an exact `time.Time` match between `slotStart.Add(i*SlotDuration)` and `ratesByTime` keys; to avoid missed corrections due to timezone or truncation mismatches, consider deriving future slots directly from the tariff rates or using a nearest-slot lookup instead of exact equality.

## Individual Comments

### Comment 1
<location path="core/site_optimizer.go" line_range="659-660" />
<code_context>
+			pastTempCount[h]++
+		}
+	}
+	pastTempAvg := make([]float64, 24)
+	for h := range 24 {
+		if pastTempCount[h] > 0 {
+			pastTempAvg[h] = pastTempSum[h] / float64(pastTempCount[h])
</code_context>
<issue_to_address>
**issue (bug_risk):** The `for h := range 24` loop does not compile and should iterate over a slice or a numeric range explicitly.

`range` requires an array, slice, map, string, or channel. Here you probably want either `for h := range pastTempAvg { ... }` or `for h := 0; h < 24; h++ { ... }`. Given the fixed size, iterating over `pastTempAvg` is likely the most idiomatic option.
</issue_to_address>

### Comment 2
<location path="core/site.go" line_range="96-97" />
<code_context>
 	householdEnergy    *meterEnergy
 	householdSlotStart time.Time

+	// per-loadpoint energy tracking for heating devices
+	loadpointEnergy    map[int]*meterEnergy
+	loadpointSlotStart map[int]time.Time
+
</code_context>
<issue_to_address>
**issue (bug_risk):** Concurrent access to `loadpointEnergy` and `loadpointSlotStart` maps is not synchronized and can cause data races.

These maps are written in `updateLoadpointConsumption`, which runs in goroutines spawned by `updateLoadpoints`. Even if goroutines touch different keys, Go maps are not safe for concurrent writes and can panic (`concurrent map writes`) or introduce data races. Please either switch to a slice indexed by loadpoint ID or protect map access with synchronization (e.g., a shared mutex or a per-loadpoint struct with an embedded mutex).
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread core/site_optimizer.go
Comment thread core/site.go Outdated
@daniel309

daniel309 commented Mar 15, 2026

Copy link
Copy Markdown
Contributor Author

update: db schema changes removed. see PR description. no more additional loadpoint column.

image image

@andig andig changed the title Feature/Optimizer to take heating into account Energy demand profile: to take heating into account Mar 15, 2026
@andig andig added the heating Heating label Mar 15, 2026
@daniel309

daniel309 commented Mar 15, 2026

Copy link
Copy Markdown
Contributor Author

@andig I removed any db schema change from this PR, so this is now independent of #23185.

see
image

Just tested compatibility with v0.303. works fine without issues.

also, table "meter" remains lean which is a good thing. adding a varchar added significant volume to the file (20 bytes per each row...)

the offset between meter ids and loadpoint ids is now 1000 to give enough space between the two for all practical numbers of meters and loadpoints.

@daniel309
daniel309 force-pushed the feature/temperature-correction branch from a5764b2 to 16203c8 Compare March 15, 2026 20:50
@daniel309

daniel309 commented Mar 16, 2026

Copy link
Copy Markdown
Contributor Author

evidence of the entire process working and about the impact on optimizer forecast and corrections.

log messages of a working system:

[site  ] DEBUG 2026/03/16 21:57:28 optimizer: optimizing 105 slots until 2026-03-18 00:00:00 +0100 CET: grid=105, feedIn=585, solar=193, first slot: 2m31s
[site  ] DEBUG 2026/03/16 21:57:28 heater profile: querying 1 heating loadpoint(s)
[site  ] DEBUG 2026/03/16 21:57:28 heater profile: loadpoint 0 has 96 slots of data
[site  ] DEBUG 2026/03/16 21:57:28 heater profile: aggregated 1 heating loadpoint(s) into 96 slots
[site  ] DEBUG 2026/03/16 21:57:28 home profile: extracted heater profile with 96 slots
[site  ] DEBUG 2026/03/16 21:57:28 home profile: attempting temperature correction on heater profile
[site  ] DEBUG 2026/03/16 21:57:28 temperature correction: slot 21:45 (hour 20): forecast=4.3°C, hist_avg=6.3°C, delta=2.0°C, load: 200Wh -> 220Wh (9.9%)
[site  ] DEBUG 2026/03/16 21:57:28 temperature correction: slot 22:00 (hour 21): forecast=4.3°C, hist_avg=6.1°C, delta=1.8°C, load: 182Wh -> 198Wh (8.8%)
[site  ] DEBUG 2026/03/16 21:57:28 temperature correction: slot 22:15 (hour 21): forecast=4.3°C, hist_avg=6.1°C, delta=1.8°C, load: 150Wh -> 163Wh (8.8%)

-> see how the heater profile slots got adjusted based on temperature forecast

here is the result in the optimizer. see the wavy pattern of a modulating heatpump, how de-icing was running around 5am and how warm water was produced starting around noon.

image

now here is the same optimizer profile without the temperature adjustments (you see differences in the range of 10% as per log messages above temperature didnt change that much between profile avg and tomorrow)

[site ] DEBUG 2026/03/16 22:06:43 temperature correction: heatingThreshold or heatingCoefficient not configured, skipping correction

image

and finally, here is the exact same optimizer picture from vanilla evcc v0.303. Completely different household load and honestly not very useable/realistic.

image

#######################

@andig relevant code is in these 3 files. the remaining changes are all from the base PR (temperature tariff)

image

Adds optional temperature correction to household load forecasts for the
optimizer, allowing more accurate predictions when heating/cooling loads
vary with outdoor temperature.

Configuration (optional, in site config):
- heatingThreshold: °C 24h avg above which corrections are disabled (default 12°C)
- heatingCoefficient: fractional load change per °C delta (default 0.05 = 5%)

Algorithm:
1. Gates on past 24h avg temperature vs threshold (heating active if below)
2. For each future slot, compares forecast temp to historical avg at same hour
3. Adjusts load: load *= (1 + coeff * (T_historical_avg - T_forecast))
   - Colder forecast → higher load estimate
   - Warmer forecast → lower load estimate

Requires the Temperature tariff (TariffUsageTemperature) to be configured.

Changes household profile lookback from 30 days to 7 days because:
- 7 days follows household rhythms more closely (weekly patterns)
- Temperature changes too much over 30 days, making older data less relevant
- Separate heater load from base household consumption
- Apply temperature correction only to heating devices (heat pumps, electric heaters)
- Base loads (lighting, appliances) remain unchanged
- Backward compatible: falls back to old behavior if no heating devices

Changes:
- Extended metrics DB to track per-loadpoint consumption
- Added loadpoint energy tracking infrastructure in Site
- Implemented profile extraction and aggregation functions
- Modified homeProfile() to separate, correct, and merge profiles

Addresses feedback on PR evcc-io#27780 that temperature adjustment should only
apply to heating devices, not entire household consumption.
@andig

andig commented Jul 8, 2026

Copy link
Copy Markdown
Member

the original PR solved finding a forecacst model for warm water by just using the past 7 days of load (all 96 slots per day for 7 days) as forecast. The rationale is that warm water power usage is cyclic and repeating, with a period of a week (a family life is a repeatable pattern).

room heating is different, as discussed. As it largely dependends on outdoor temperature, and the period (cold at night, warm at day, peaks and valleys around 6am and 2pm) is 1 day (24h), not 7 days.

@daniel309 coming from evcc-io/optimizer#85 (comment): I understand this PR covers different classes of heating scenarios- temperature dependent (room heating) and schedule dependent.

We already have schedule dependent in terms of averaged daily profile. Does this PR add more capabilities to that (week profile rather than day profile)?

It seems to me that we should further split this PR into

  • demand profile strategy: the existing daily profile, weekly profile (if I understand correctly) and temperature-dependent profile
  • two more PRs implementing these profiles

Does that make sense?

@daniel309

daniel309 commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

We already have schedule dependent in terms of averaged daily profile. Does this PR add more capabilities to that (week profile rather than day profile)?

yes, this PR adds a weekly profile for heating. and then does outdoor temperature-dependent scaling (based on the weather tariff PR) of each slot's power usage based on a model that simulates house insulation.

It seems to me that we should further split this PR into

demand profile strategy: the existing daily profile, weekly profile (if I understand correctly) and temperature-dependent profile
two more PRs implementing these profiles
Does that make sense?

yes, splitting into daily, weekly and temperature dependant (i.e. scaling power on top of daily or weekly profile) is exactly whats needed imo.

@andig

andig commented Jul 8, 2026

Copy link
Copy Markdown
Member

...and that would include a mechanism for selecting the demand profile strategy

@daniel309

daniel309 commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

...and that would include a mechanism for selecting the demand profile strategy

Yes. This is why I added the

features: - scaleloadbytemperatureforecast

flag in this PR.

we would probably need 3 features on top of "heating" or to replace "heating":

  1. warmwater
  2. room heating
  3. scaleloadbytemperatureforecast

@andig

andig commented Jul 8, 2026

Copy link
Copy Markdown
Member

No ;). Heating is just that- changes the loadpoint appearance. I think these are new DemandProfileXYZ features. Orthogonal and giving much more flexibility?

@daniel309

daniel309 commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

I think these are new DemandProfileXYZ features. Orthogonal and giving much more flexibility?

right. And it would allow combination, e.g. a loadpoint is a "heating", and has demand profile "weekly"/room heater and "scalebyoutdoortemp" (heatpump or AC for room heating). whereas another one is heating, and only of demandprofile daily/warmwater (heating rod).

…ekly/Temperature

Introduce two orthogonal demand profile feature flags for heating devices:
- DemandProfileWeekly: uses same-weekday-last-week actual load (warm water)
- DemandProfileTemperature: uses daily-avg load scaled by outdoor temp (room heating)

Adds energyProfileWeekday() DB query and Collector.EnergyProfileWeekday() method.
Extracts tileAndTrim() helper. Removes getHeatingLoadpoints().

Addresses andig feedback on PR evcc-io#28232.
@daniel309

daniel309 commented Jul 19, 2026

Copy link
Copy Markdown
Contributor Author

@andig I merged all changes to latest master, now the diff is much cleaner (only 5 files changed).

Also, I implemented the two demand profiles in the last commit:

DemandProfileWeekly — for warm water heaters (boilers, heating rods)
The optimizer fetches the actual 96 fifteen-minute consumption slots from exactly 7 days ago (same weekday). No averaging, no scaling — last Tuesday is the forecast for this Tuesday. The rationale is that warm water consumption is driven by household routine which repeats on a weekly period and is largely independent of weather.

DemandProfileTemperature — for room heating heat pumps or any other electrical room heating system (AC heaters, IR heaters, ...)
The optimizer fetches the average daily consumption pattern (96 slots, averaged over the last 7 days) and then scales each slot using the outdoor temperature forecast.

Comment thread core/site_optimizer.go Outdated
// base load (excludes loadpoints) - averaged over last 30 days
gt_base, err := site.collectors[metrics.Home].EnergyProfile(now.BeginningOfDay().AddDate(0, 0, -30))
// base load (excludes loadpoints) - averaged over last 7 days
gt_base, err := site.collectors[metrics.Home].EnergyProfile(now.BeginningOfDay().AddDate(0, 0, -7))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I don‘t think we‘d want to do that

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

right, this is household base load. avg across 30 days is probably fine here. I dont see periodic behavior that would be hidden/removed by averaging.

ill fix in next commit

@andig andig Jul 21, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I thought this PR added detecting weekly patterns (DemandProfileWeekly)- those would be hidden?

@daniel309 daniel309 Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I thought this PR added detecting weekly patterns (DemandProfileWeekly)

yes, but not for the base load, only for heating "loadpoints".

@andig

andig commented Jul 20, 2026

Copy link
Copy Markdown
Member

DemandProfileWeekly — for warm water heaters (boilers, heating rods)
The optimizer fetches the actual 96 fifteen-minute consumption slots from exactly 7 days ago (same weekday). No averaging, no scaling — last Tuesday is the forecast for this Tuesday. The rationale is that warm water consumption is driven by household routine which repeats on a weekly period and is largely independent of weather.

That sounds to me like an argument for averaging over each weekday, not for taking only the last week as authorative?

@daniel309

daniel309 commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

DemandProfileWeekly — for warm water heaters (boilers, heating rods)
The optimizer fetches the actual 96 fifteen-minute consumption slots from exactly 7 days ago (same weekday). No averaging, no scaling — last Tuesday is the forecast for this Tuesday. The rationale is that warm water consumption is driven by household routine which repeats on a weekly period and is largely independent of weather.

That sounds to me like an argument for averaging over each weekday, not for taking only the last week as authorative?

no ;-) if you average over each weekday you lose the weekly periodic behaviour. If you want to average, you can create same slot avg for the same weekday, e.g. take slot 12:00 to 12:15 of the past 4 fridays and avg load across those 4 slots as the new forecast for 12:00 to 12:15 of the coming friday.

... and yes, as discussed in the other PR: a proper time-series prediction model (tiny time mixers, googles TimesFM or more traditional methods like ARIMA) is in order longer term for the DemandProfileWeekly type. Its best predictor (I think) is the inherent periodic (repeating weekly) behaviour of a household's warm water demand plus the (approx. linear) heat loss of the storage tank.

This completely differs from DemandProfileTemperature, where the best predictor for future load is the outdoor temperature via weather forecast and the period is 24h.

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

Labels

backlog Things to do later enhancement New feature or request heating Heating

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants