Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
451b387
Add peak shaving feature implementation plan
claude Mar 13, 2026
db8853f
Update peak shaving plan based on review feedback
claude Mar 13, 2026
d27ed4e
Move EVCC charging check from logic layer to core.py
claude Mar 13, 2026
c65bc1a
Restructure plan: independent NextLogic class + EVCC mode/connected t…
claude Mar 13, 2026
99cf4f9
Add nighttime skip and documentation step to plan
claude Mar 13, 2026
f935027
Iteration 1
MaStr Mar 13, 2026
782b379
iteration 2
MaStr Mar 13, 2026
b5f798d
3rd iteration
MaStr Mar 13, 2026
70b6580
Add instruction about ascii characters
MaStr Mar 13, 2026
43d77b4
Iteration 4
MaStr Mar 13, 2026
fb5a585
Add more logging and fix EVCC / evcc spelling
MaStr Mar 13, 2026
8c67bc5
Iteration 5 , improving architecture
MaStr Mar 13, 2026
2294d53
Some more unicode to ascii converts
MaStr Mar 13, 2026
121eb18
Fix logging messages for logic type selection and improve error messa…
MaStr Mar 13, 2026
0700edd
Add peak shaving feature implementation plan
claude Mar 13, 2026
ab31fcd
Update peak shaving plan based on review feedback
claude Mar 13, 2026
7823f95
Move EVCC charging check from logic layer to core.py
claude Mar 13, 2026
7b23f21
Restructure plan: independent NextLogic class + EVCC mode/connected t…
claude Mar 13, 2026
4694303
Add nighttime skip and documentation step to plan
claude Mar 13, 2026
9c50d51
Iteration 1
MaStr Mar 13, 2026
ab99e76
iteration 2
MaStr Mar 13, 2026
14b5342
3rd iteration
MaStr Mar 13, 2026
eaa94cd
Add instruction about ascii characters
MaStr Mar 13, 2026
68cfddd
Iteration 4
MaStr Mar 13, 2026
4da76d7
Add more logging and fix EVCC / evcc spelling
MaStr Mar 13, 2026
4fe2ee8
Iteration 5 , improving architecture
MaStr Mar 13, 2026
77936c6
Some more unicode to ascii converts
MaStr Mar 13, 2026
c23b384
Fix logging messages for logic type selection and improve error messa…
MaStr Mar 13, 2026
11aab91
Merge branch 'feat-peak-shaving' of github.com:muexxl/batcontrol into…
MaStr Mar 17, 2026
a3b971c
Minor comment fix
MaStr Mar 17, 2026
a960513
Fix peak_shaving enable/disable via api
MaStr Mar 19, 2026
4705dbb
Implement minimum PV charge rate enforcement for peak shaving logic
MaStr Mar 20, 2026
4bbb3db
Enhance peak shaving logic to ignore cheap slots beyond production wi…
MaStr Mar 20, 2026
6decfc7
Refine peak shaving charge limit calculation to implement counter-lin…
MaStr Mar 20, 2026
cd8a196
Enhance peak shaving documentation to clarify counter-linear ramp log…
MaStr Mar 20, 2026
87cf2af
Add simulate python scripts
MaStr Mar 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ This is a Python based repository providing an application for controlling batte

### Required Before Each Commit

- Only use ASCII characters in code, log messages, and documentation. Avoid non-ASCII characters to ensure compatibility and readability across different environments.
- Remove excessive whitespaces.
- Follow PEP8 standards. Use autopep8 for that.
- Check against pylint. Target score is like 9.0-9.5, if you can achieve 10, do it.
Expand Down
697 changes: 697 additions & 0 deletions PLAN.md

Large diffs are not rendered by default.

24 changes: 24 additions & 0 deletions config/batcontrol_config_dummy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ log_everything: false # if false debug messages from fronius.auth and urllib3.co
max_logfile_size: 200 #kB
logfile_path: logs/batcontrol.log
battery_control:
type: default # Logic type: 'default' (standard) or 'next' (includes peak shaving)
# min_price_difference is the absolute minimum price difference in Euro to justify charging your battery
# if min_price_difference_rel results in a higher price difference, that will be used
min_price_difference: 0.05 # minimum price difference in Euro to justify charging your battery
Expand All @@ -32,6 +33,29 @@ battery_control_expert:
production_offset_percent: 1.0 # Adjust production forecast by a percentage (1.0 = 100%, 0.8 = 80%, etc.)
# Useful for winter mode when solar panels are covered with snow

#--------------------------
# Peak Shaving
# Manages PV battery charging rate to limit PV charging before cheap-price
# or high-production hours so the battery can absorb as much PV as possible.
# Requires logic type 'next' in battery_control section.
#
# mode:
# 'time' - limit by target hour only (allow_full_battery_after)
# 'price' - reserve capacity for cheap-price slots (price_limit required)
# 'combined' - both active, stricter limit wins [default]
#
# price_limit: slots where price (Euro/kWh) is at or below this value are
# treated as cheap PV windows. Battery capacity is reserved so the PV
# surplus during those cheap slots can be fully absorbed.
# Use -1 to disable the price component without changing mode.
# Required for mode 'price' and 'combined'; ignored for mode 'time'.
#--------------------------
peak_shaving:
enabled: false
mode: combined # 'time' | 'price' | 'combined'
allow_full_battery_after: 14 # Hour (0-23) - battery should be full by this hour
price_limit: 0.05 # Euro/kWh - keep battery empty for slots at or below this price

#--------------------------
# Inverter
# See more Details in: https://github.com/MaStr/batcontrol/wiki/Inverter-Configuration
Expand Down
16 changes: 8 additions & 8 deletions docs/15-min-transform.md
Original file line number Diff line number Diff line change
Expand Up @@ -533,15 +533,15 @@ class FCSolar(ForecastSolarBase):
from .baseclass import ForecastSolarBase

class EvccSolar(ForecastSolarBase):
"""EVCC solar provider - returns 15-minute data."""
"""evcc solar provider - returns 15-minute data."""

def __init__(self, config: dict):
super().__init__(config)
self.native_resolution = 15 # Declares: "I provide 15-min data"
# ... rest of init ...

def _fetch_forecast(self) -> Dict[int, float]:
"""Fetch 15-minute forecast from EVCC API."""
"""Fetch 15-minute forecast from evcc API."""
# Existing API call logic
# Returns: {0: 250, 1: 300, 2: 350, ...} # Wh per 15-min
response = self._call_evcc_api()
Expand Down Expand Up @@ -712,7 +712,7 @@ from .baseclass import ForecastSolarBase
class EvccSolar(ForecastSolarBase):
def __init__(self, config: dict):
super().__init__(config)
self.native_resolution = 15 # EVCC provides 15-min data
self.native_resolution = 15 # evcc provides 15-min data

def _fetch_forecast(self) -> dict[int, float]:
# Return native 15-minute data
Expand Down Expand Up @@ -976,10 +976,10 @@ class Tibber(DynamicTariffBase):
class EvccTariff(DynamicTariffBase):
def __init__(self, config: dict):
super().__init__(config)
self.native_resolution = 15 # EVCC provides 15-min data
self.native_resolution = 15 # evcc provides 15-min data

def _fetch_prices(self) -> Dict[int, float]:
"""Fetch 15-minute prices from EVCC API."""
"""Fetch 15-minute prices from evcc API."""
# Existing API logic
# Old code averaged to hourly - now returns native 15-min
return self._parse_15min_data()
Expand Down Expand Up @@ -1032,7 +1032,7 @@ class Energyforecast(DynamicTariffBase):
**API Documentation**:
- **Awattar API**: https://www.awattar.de/services/api
- **Tibber API**: https://developer.tibber.com/docs/guides/pricing
- **EVCC API**: https://docs.evcc.io/docs/reference/configuration/messaging#grid-tariff
- **evcc API**: https://docs.evcc.io/docs/reference/configuration/messaging#grid-tariff
- **Energyforecast API**: https://www.energyforecast.de/api/v1/predictions/next_48_hours

**Price Handling Note**:
Expand Down Expand Up @@ -2328,7 +2328,7 @@ def upsample_forecast(hourly_data: dict, interval_minutes: int,
- **Q**: Which electricity tariff provider(s) do you currently use?
- [ ] Awattar (Germany/Austria)
- [ ] Tibber (Nordic countries, Germany, Netherlands)
- [ ] EVCC integration
- [ ] evcc integration
- [ ] Other: _______________

- **Q**: What is your tariff's price update frequency?
Expand Down Expand Up @@ -2754,7 +2754,7 @@ Instead of each provider implementing upsampling/downsampling logic, we use a **
│ │ │
├─────┬─────┬───┼───┬───────┬───┼────┬────┐
│ │ │ │ │ │ │ │ │
FCSolar │ EVCC│ Awattar Tibber EVCC│ CSV │ HA
FCSolar │ evcc│ Awattar Tibber evcc│ CSV │ HA
Prognose Solar Tariff Profile Forecast
```

Expand Down
163 changes: 163 additions & 0 deletions docs/WIKI_peak_shaving.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
# Peak Shaving

## Overview

Peak shaving manages PV battery charging rate so the battery fills up gradually, reaching full capacity by a configurable target hour (`allow_full_battery_after`). This prevents the battery from being full too early in the day.

**Problem:** All PV systems produce peak power around midday. Most batteries are full by then, causing excess PV to be fed into the grid at a time when grid prices are lowest - and for newer installations, feed-in may not be compensated at all. Peak shaving spreads battery charging over time so the system absorbs as much solar energy as possible.

## Configuration

### Enable Peak Shaving

Peak shaving requires two configuration changes:

1. Set the logic type to `next` in the `battery_control` section:

```yaml
battery_control:
type: next # Use 'next' to enable peak shaving logic (default: 'default')
```

2. Configure the `peak_shaving` section:

```yaml
peak_shaving:
enabled: false
mode: combined # 'time' | 'price' | 'combined'
allow_full_battery_after: 14 # Hour (0-23) - battery should be full by this hour
price_limit: 0.05 # Euro/kWh - keep battery empty for slots at or below this price
```

### Parameters

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `enabled` | bool | `false` | Enable/disable peak shaving |
| `mode` | string | `combined` | Algorithm mode: `time`, `price`, or `combined` |
| `allow_full_battery_after` | int | `14` | Target hour (0-23) for the battery to be full |
| `price_limit` | float | `null` | Price threshold (Euro/kWh); required for modes `price` and `combined` |

**`mode`** selects which algorithm components are active:
- **`time`** - time-based only: spread free capacity evenly until `allow_full_battery_after`. `price_limit` not required.
- **`price`** - price-based only: reserve capacity for cheap-price slots (in-window surplus overflow handled). Requires `price_limit`.
- **`combined`** (default) - both components; stricter limit wins. Requires `price_limit`.

**`allow_full_battery_after`** controls when the battery is allowed to be 100% full:
- **Before this hour:** PV charge rate may be limited
- **At/after this hour:** No limit for all modes (target-hour check applies globally)

## How It Works

### Algorithm

Peak shaving uses one or two components depending on `mode`. The stricter (lower non-negative) limit wins when both are active.

**Component 1: Time-Based** (modes `time` and `combined`)

Uses a **counter-linear ramp** so the allowed charge rate rises as the target hour approaches. Slot 0 (now) gets the smallest allocation; each later slot gets progressively more, reaching its largest value in the last slot before the target. This mirrors actual PV production curves that rise towards midday.

```
slots_remaining = n (slots until allow_full_battery_after)
pv_surplus = sum of max(production - consumption, 0) for remaining slots

if pv_surplus > free_capacity:
# Weight of current slot = 1, weight of slot k = k+1
# Total weight = n*(n+1)/2
wh_current = 2 * free_capacity / (n * (n+1))
charge_limit = wh_current / interval_hours (Wh -> W)
```

Example with free_capacity = 2000 Wh and 1 h intervals:

| Hours to target (n) | Allowed rate |
|---|---|
| 8 | 55 W |
| 4 | 200 W |
| 2 | 666 W |
| 1 | 2000 W (full send) |

**Component 2: Price-Based** (modes `price` and `combined`)

Only slots within the **production window** are considered. The production window ends at the first slot where forecast production is zero; nighttime cheap slots beyond that point are ignored because there is no PV to charge from. This prevents reserving capacity for e.g. a cheap slot at 03:00 that would never produce energy.

Before cheap window - reserves free capacity so cheap-slot PV surplus fills battery completely:

```
production_end = index of first slot with production = 0
cheap_slots = slots where price <= price_limit AND slot < production_end
target_reserve = min(sum of PV surplus in cheap slots, max_capacity)
additional_allowed = free_capacity - target_reserve

if additional_allowed <= 0: -> block charging (rate = 0)
else: -> spread additional_allowed evenly over slots before window
```

Inside cheap window - if total PV surplus in the window exceeds free capacity, the battery cannot fully absorb everything. Charging is spread evenly over the cheap slots so the battery fills gradually instead of hitting 100% in the first slot:

```
if total_cheap_surplus > free_capacity:
charge_limit = free_capacity / num_cheap_slots (Wh/slot -> W)
else:
no limit (-1)
```

The charge limit is applied using **MODE 8** (`limit_battery_charge_rate`). Peak shaving only applies when discharge is already allowed by the main price-based logic.

### Skip Conditions

Peak shaving is automatically skipped when:

1. **`price_limit` not configured** for mode `price` or `combined` - price component disabled
2. **No PV production** - nighttime, no action needed
3. **Past the target hour** (`allow_full_battery_after`) - applies to all modes; no limit
4. **Battery in always_allow_discharge region** - SOC is already high
5. **Grid charging active (MODE -1)** - force charge takes priority
6. **Discharge not allowed** - battery is being preserved for upcoming high-price hours
7. **evcc is actively charging** - EV consumes the excess PV
8. **EV connected in PV mode** - evcc will absorb PV surplus

The price-based component also returns no limit when:
- No cheap slots exist in the forecast
- Inside cheap window and total surplus fits in free capacity (absorb freely)

### evcc Interaction

When an EV charger is managed by evcc:

- **EV actively charging** (`charging=true`): Peak shaving is disabled - the EV consumes the excess PV
- **EV connected in PV mode** (`connected=true` AND `mode=pv`): Peak shaving is disabled - evcc will naturally absorb surplus PV when the threshold is reached
- **EV disconnects or mode changes**: Peak shaving is re-enabled

The evcc integration derives `mode` and `connected` topics automatically from the configured `loadpoint_topic` by replacing `/charging` with `/mode` and `/connected`.

## MQTT API

### Published Topics

| Topic | Type | Retained | Description |
|-------|------|----------|-------------|
| `{base}/peak_shaving/enabled` | bool | Yes | Peak shaving enabled status |
| `{base}/peak_shaving/allow_full_battery_after` | int | Yes | Target hour (0-23) |
| `{base}/peak_shaving/charge_limit` | int | No | Current charge limit in W (-1 if inactive) |

### Settable Topics

| Topic | Accepts | Description |
|-------|---------|-------------|
| `{base}/peak_shaving/enabled/set` | `true`/`false` | Enable/disable peak shaving |
| `{base}/peak_shaving/allow_full_battery_after/set` | int 0-23 | Set target hour |

### Home Assistant Auto-Discovery

The following HA entities are automatically created:

- **Peak Shaving Enabled** - switch entity
- **Peak Shaving Allow Full After** - number entity (0-23, step 1)
- **Peak Shaving Charge Limit** - sensor entity (unit: W)

## Known Limitations

1. **No intra-day adjustment:** If clouds reduce PV significantly, the limit stays as calculated until the next evaluation cycle (every 3 minutes). The counter-linear ramp self-corrects automatically: high free capacity at the next cycle produces a higher allowed rate.

2. **Code duplication:** `NextLogic` is a copy of `DefaultLogic` with peak shaving added. Once stable, the two could be merged or refactored.
Loading
Loading