Skip to content

Commit e0afef6

Browse files
deadl0ckclaude
andauthored
Add graduated Amber headroom target to reduce solar clipping on Amber days (#23)
Amber forecast days previously received no pre-period headroom export, leaving the battery full when morning solar arrives. Now a configurable AMBER_HEADROOM_FRACTION (default 0.25) drives a 6 kWh headroom target for Amber periods, sitting between Red (none) and Green (50% / 12 kWh). All headroom checks in the decision engine, period handlers, and mid-period safety exports are status-aware. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 0df1d0f commit e0afef6

7 files changed

Lines changed: 59 additions & 32 deletions

File tree

‎README.md‎

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ see exactly what values were used and why each decision was made.
104104
| `CHEAP_RATE_END_HOUR` | `8` | Hour your cheap-rate electricity ends (local time) |
105105
| `ESTIMATED_HOME_LOAD_KW` | `0.8` | Your average household draw in kW when solar is low |
106106
| `HEADROOM_TARGET_KWH` | `BATTERY_KWH * 0.5` | Free battery space to maintain before a Green forecast. The alternative physics formula is `(SOLAR_PV_KW - INVERTER_KW) * 3`; use whichever fits your clipping risk tolerance |
107+
| `AMBER_HEADROOM_FRACTION` | `0.25` | Fraction of battery capacity to keep free before an Amber forecast (e.g. `0.25` = 25%). Set to `0.0` to disable Amber headroom entirely |
107108

108109
All solar trigger thresholds (`MID_PERIOD_SAFETY_SOLAR_TRIGGER_KW`, `LIVE_CLIPPING_RISK_SOLAR_TRIGGER_KW`) and discharge rate estimates (`PRE_CHEAP_RATE_NIGHT_EXPORT_ASSUMED_DISCHARGE_KW`, `EVENING_EXPORT_ASSUMED_DISCHARGE_KW`) are automatically derived from your hardware specs — you do not need to touch them.
109110

@@ -286,6 +287,7 @@ These settings are **not** derived from hardware and need manual review:
286287
| `CHEAP_RATE_START_HOUR` / `CHEAP_RATE_END_HOUR` | Varies by energy provider and tariff plan |
287288
| `ESTIMATED_HOME_LOAD_KW` | Household draw varies; affects the evening battery-bridge rule |
288289
| `HEADROOM_TARGET_KWH` | Currently `BATTERY_KWH * 0.5`. The physics alternative is `(SOLAR_PV_KW - INVERTER_KW) * 3` (surplus kW × 3 h reserve). For systems with a large battery relative to array surplus, the physics formula is more appropriate |
290+
| `AMBER_HEADROOM_FRACTION` | Fraction of battery capacity to maintain as free headroom before an Amber period. Default `0.25` (25% of battery). Set to `0.0` to disable Amber pre-period exports entirely |
289291
| `PRE_SUNRISE_DISCHARGE_MONTHS` | Months where pre-sunrise discharge is useful — adjust for your latitude and climate |
290292

291293
### Scheduler and decision thresholds
@@ -305,6 +307,7 @@ FORECAST_SOLAR_POWER_MULTIPLIER = 1.53
305307
CHEAP_RATE_START_HOUR = 23
306308
CHEAP_RATE_END_HOUR = 8
307309
HEADROOM_TARGET_KWH = 10.2
310+
AMBER_HEADROOM_FRACTION = 0.25
308311
ENABLE_PRE_CHEAP_RATE_BATTERY_BRIDGE = True
309312
ESTIMATED_HOME_LOAD_KW = 0.8
310313
BRIDGE_BATTERY_RESERVE_KWH = 1.0
@@ -337,7 +340,8 @@ Meaning:
337340
- `QUARTZ_RED_CAPACITY_FRACTION`: lower Quartz status threshold as a fraction of configured array capacity
338341
- `QUARTZ_GREEN_CAPACITY_FRACTION`: upper Quartz status threshold as a fraction of configured array capacity
339342
- `FORECAST_SOLAR_POWER_MULTIPLIER`: scalar applied to Forecast.Solar watts before period status/value normalization; use this to correct persistent local bias (for example, historical under-forecasting)
340-
- `HEADROOM_TARGET_KWH`: fixed battery headroom target (10.2 kWh = surplus capacity × 3 hours)
343+
- `HEADROOM_TARGET_KWH`: fixed battery headroom target for Green periods (10.2 kWh = surplus capacity × 3 hours)
344+
- `AMBER_HEADROOM_FRACTION`: fraction of battery capacity to maintain as free headroom before Amber periods (default `0.25`); `AMBER_HEADROOM_TARGET_KWH` is derived as `BATTERY_KWH * AMBER_HEADROOM_FRACTION`. Set to `0.0` to disable Amber headroom
341345
- `ENABLE_PRE_CHEAP_RATE_BATTERY_BRIDGE`: when enabled, Evening decisions avoid charge-oriented behavior before cheap-rate starts if battery can bridge the expected load
342346
- `ESTIMATED_HOME_LOAD_KW`: average household load estimate used to calculate whether current battery energy can cover consumption until cheap-rate begins
343347
- `BRIDGE_BATTERY_RESERVE_KWH`: safety buffer to keep in battery when evaluating bridge sufficiency
@@ -650,19 +654,27 @@ $$
650654

651655
The system exports to grid under these conditions.
652656

653-
#### Rule 1: Insufficient headroom before a Green period
657+
#### Rule 1: Insufficient headroom before a Green or Amber period
654658

655-
The target free headroom is derived from hardware surplus capacity multiplied by a 3-hour reserve window:
659+
The headroom target varies by forecast status:
660+
661+
| Forecast | Target | Setting |
662+
|---|---|---|
663+
| Green | `BATTERY_KWH × 0.5` (e.g. 12 kWh) | `HEADROOM_TARGET_KWH` |
664+
| Amber | `BATTERY_KWH × 0.25` (e.g. 6 kWh) | `AMBER_HEADROOM_TARGET_KWH` (derived from `AMBER_HEADROOM_FRACTION`) |
665+
| Red | — (no headroom export) | — |
666+
667+
The Green target is derived from hardware surplus capacity multiplied by a 3-hour reserve window:
656668

657669
$$
658-
ext{headroom target} = (\text{solar PV kW} - \text{inverter kW}) \times 3.0 = (8.9 - 5.5) \times 3.0 = 10.2 \text{ kWh}
670+
\text{headroom target (Green)} = (\text{solar PV kW} - \text{inverter kW}) \times 3.0 = (8.9 - 5.5) \times 3.0 = 10.2 \text{ kWh}
659671
$$
660672

661-
This fixed target ensures the battery can absorb 3 hours of maximum surplus generation (3.4 kW) without clipping, independent of forecast quality or period.
673+
The Amber target is a reduced fraction of the battery, reflecting that Amber days carry real but moderate solar risk — enough to warrant partial headroom, but not the full Green reserve. Setting `AMBER_HEADROOM_FRACTION = 0.0` disables Amber headroom entirely.
662674

663675
If:
664676
$$
665-
ext{headroom} < \text{headroom target}
677+
\text{headroom} < \text{headroom target}
666678
$$
667679

668680
then the system selects `GRID_EXPORT` to create battery space ahead of the solar period.

‎config/settings.py‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,11 @@
193193
HEADROOM_TARGET_KWH = BATTERY_KWH * 0.5 # e.g. 24 × 0.5 = 12.0 kWh
194194
# Alternative physics-based formula: (SOLAR_PV_KW - INVERTER_KW) * 3 = surplus kW × 3 h reserve.
195195
# For this system that gives 10.2 kWh. BATTERY_KWH * 0.5 adds extra margin; adjust to taste.
196+
# Target free battery headroom before an Amber period: 25% of total battery capacity.
197+
# Amber days carry moderate solar risk — less than Green but enough to warrant partial headroom.
198+
# Set to 0.0 to disable Amber headroom entirely (reverts to pre-2026-05 behaviour).
199+
AMBER_HEADROOM_FRACTION = 0.25
200+
AMBER_HEADROOM_TARGET_KWH = BATTERY_KWH * AMBER_HEADROOM_FRACTION # e.g. 24 × 0.25 = 6.0 kWh
196201
# Enable bridge-to-cheap-rate rule: before cheap-rate starts, prefer self-powered
197202
# if current battery energy is sufficient to cover expected household demand.
198203
ENABLE_PRE_CHEAP_RATE_BATTERY_BRIDGE = True
@@ -208,10 +213,9 @@
208213
PRE_CHEAP_RATE_NIGHT_EXPORT_MIN_SOC_PERCENT = 15.0
209214
# Conservative assumed net battery discharge power used to size the export window.
210215
PRE_CHEAP_RATE_NIGHT_EXPORT_ASSUMED_DISCHARGE_KW = round(INVERTER_KW * 0.36, 2) # ~36% of inverter capacity
211-
# High-SOC protection: force GRID_EXPORT for Green periods when battery is very
212-
# full and headroom is below target, preventing wasted solar due to a full battery.
213-
# Amber is excluded: moderate solar is unlikely to cause significant clipping, and
214-
# exporting stored energy overnight on an Amber day wastes more than it saves.
216+
# High-SOC protection: force GRID_EXPORT for Green or Amber periods when battery is very
217+
# full and headroom is below the status-appropriate target, preventing wasted solar.
218+
# Green uses HEADROOM_TARGET_KWH (50%); Amber uses AMBER_HEADROOM_TARGET_KWH (25%).
215219
MORNING_HIGH_SOC_PROTECTION_ENABLED = True
216220
# SOC threshold for the mid-period high-SOC safety export check (combined with solar).
217221
# When SOC >= this AND solar >= MID_PERIOD_SAFETY_SOLAR_TRIGGER_KW, export is triggered.

‎logic/afternoon.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from datetime import timedelta
1313

1414
from config.settings import (
15+
AMBER_HEADROOM_TARGET_KWH,
1516
BATTERY_KWH,
1617
DAYTIME_TIMED_EXPORT_MIN_SOC_PERCENT,
1718
HEADROOM_TARGET_KWH,
@@ -137,7 +138,9 @@ async def handle_afternoon_period(ctx: PeriodHandlerContext) -> bool:
137138
and mid_period_solar_kw >= MID_PERIOD_SAFETY_SOLAR_TRIGGER_KW
138139
):
139140
mid_period_headroom_kwh = calc_headroom_kwh(BATTERY_KWH, mid_period_soc)
140-
mid_period_headroom_target_kwh = HEADROOM_TARGET_KWH
141+
mid_period_headroom_target_kwh = (
142+
AMBER_HEADROOM_TARGET_KWH if status.upper() == "AMBER" else HEADROOM_TARGET_KWH
143+
)
141144
mid_period_headroom_deficit = max(0.0, mid_period_headroom_target_kwh - mid_period_headroom_kwh)
142145
if mid_period_headroom_deficit > 0:
143146
mid_period_effective_battery_export_kw = get_effective_battery_export_kw(

‎logic/decision_logic.py‎

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
from config.enums import ForecastStatus, Period
1818
from config.settings import (
19+
AMBER_HEADROOM_TARGET_KWH,
1920
BATTERY_KWH,
2021
ENABLE_PRE_CHEAP_RATE_BATTERY_BRIDGE,
2122
FORECAST_TO_MODE,
@@ -128,7 +129,7 @@ def decide_operational_mode(ctx: DecisionContext) -> tuple[int, str]:
128129

129130
if (
130131
ctx.soc is not None
131-
and status_key == ForecastStatus.GREEN
132+
and status_key in (ForecastStatus.GREEN, ForecastStatus.AMBER)
132133
and ctx.headroom_kwh is not None
133134
and ctx.headroom_kwh < ctx.headroom_target_kwh
134135
):
@@ -141,18 +142,13 @@ def decide_operational_mode(ctx: DecisionContext) -> tuple[int, str]:
141142

142143
if (
143144
MORNING_HIGH_SOC_PROTECTION_ENABLED
144-
and status_key == ForecastStatus.GREEN
145+
and status_key in (ForecastStatus.GREEN, ForecastStatus.AMBER)
145146
and ctx.soc is not None
146147
and ctx.soc >= MORNING_HIGH_SOC_THRESHOLD_PERCENT
147148
and ctx.headroom_kwh is not None
148149
and ctx.headroom_kwh < ctx.headroom_target_kwh
149150
):
150151
mode = SIGEN_MODES["GRID_EXPORT"]
151-
period_label = {
152-
Period.MORN: "Morning",
153-
Period.AFTN: "Afternoon",
154-
Period.EVE: "Evening",
155-
}.get(period_key, ctx.period)
156152
reason = (
157153
f"Battery is high ({ctx.soc:.1f}%) with only {ctx.headroom_kwh:.2f} kWh headroom "
158154
f"(needs {ctx.headroom_target_kwh:.2f} kWh) — exporting to make room for incoming solar."

‎logic/morning.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from datetime import timedelta
1313

1414
from config.settings import (
15+
AMBER_HEADROOM_TARGET_KWH,
1516
BATTERY_KWH,
1617
DAYTIME_TIMED_EXPORT_MIN_SOC_PERCENT,
1718
HEADROOM_TARGET_KWH,
@@ -137,7 +138,9 @@ async def handle_morning_period(ctx: PeriodHandlerContext) -> bool:
137138
and mid_period_solar_kw >= MID_PERIOD_SAFETY_SOLAR_TRIGGER_KW
138139
):
139140
mid_period_headroom_kwh = calc_headroom_kwh(BATTERY_KWH, mid_period_soc)
140-
mid_period_headroom_target_kwh = HEADROOM_TARGET_KWH
141+
mid_period_headroom_target_kwh = (
142+
AMBER_HEADROOM_TARGET_KWH if status.upper() == "AMBER" else HEADROOM_TARGET_KWH
143+
)
141144
mid_period_headroom_deficit = max(0.0, mid_period_headroom_target_kwh - mid_period_headroom_kwh)
142145
if mid_period_headroom_deficit > 0:
143146
mid_period_effective_battery_export_kw = get_effective_battery_export_kw(

‎logic/period_handler_shared.py‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from typing import Any
2020

2121
from config.settings import (
22+
AMBER_HEADROOM_TARGET_KWH,
2223
BATTERY_KWH,
2324
BRIDGE_BATTERY_RESERVE_KWH,
2425
ESTIMATED_HOME_LOAD_KW,
@@ -152,15 +153,19 @@ def _evaluate_period_mode_decision(
152153
period, status, soc, solar_avg_kw_3
153154
)
154155
headroom_kwh = calc_headroom_kwh(BATTERY_KWH, soc)
155-
headroom_target_kwh = HEADROOM_TARGET_KWH
156+
headroom_target_kwh = (
157+
AMBER_HEADROOM_TARGET_KWH
158+
if decision_status.upper() == "AMBER"
159+
else HEADROOM_TARGET_KWH
160+
)
156161
headroom_deficit_kwh = max(0.0, headroom_target_kwh - headroom_kwh)
157162
mode, reason = decide_operational_mode(
158163
DecisionContext(
159164
period=period,
160165
status=decision_status,
161166
soc=soc,
162167
headroom_kwh=headroom_kwh,
163-
headroom_target_kwh=HEADROOM_TARGET_KWH,
168+
headroom_target_kwh=headroom_target_kwh,
164169
live_solar_kw=solar_avg_kw_3,
165170
hours_until_cheap_rate=get_hours_until_cheap_rate(now_utc),
166171
estimated_home_load_kw=ESTIMATED_HOME_LOAD_KW,

‎tests/test_decision_logic.py‎

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,15 @@ def test_cheap_rate_window_is_false_during_evening_shoulder_hours():
2222

2323

2424
def test_peak_tariff_overrides_default_forecast_mode_to_self_powered():
25+
# headroom_target_kwh=6.0 reflects the Amber target; headroom=8.0 is sufficient
26+
# so the headroom export rule does not fire and peak tariff override takes effect.
2527
mode, reason = decide_operational_mode(
2628
DecisionContext(
2729
period="Aftn",
2830
status="Amber",
2931
soc=45,
3032
headroom_kwh=8.0,
31-
headroom_target_kwh=10.2,
33+
headroom_target_kwh=6.0,
3234
live_solar_kw=None,
3335
hours_until_cheap_rate=None,
3436
estimated_home_load_kw=None,
@@ -101,16 +103,16 @@ def test_evening_red_falls_back_to_ai_when_bridge_energy_is_insufficient():
101103
assert "Forecast is Red" in reason
102104

103105

104-
def test_morning_high_soc_protection_does_not_export_for_amber_when_headroom_is_low():
105-
# Amber forecast: high SOC should not trigger protective export — moderate solar
106-
# is unlikely to cause significant clipping, and exporting stored energy wastes more.
106+
def test_amber_exports_when_headroom_below_amber_target():
107+
# Amber forecast with high SOC: headroom (0.6 kWh) is below the Amber target
108+
# (6.0 kWh), so protective export should trigger to make room for solar.
107109
mode, reason = decide_operational_mode(
108110
DecisionContext(
109111
period="Morn",
110112
status="Amber",
111113
soc=97.0,
112114
headroom_kwh=0.6,
113-
headroom_target_kwh=10.2,
115+
headroom_target_kwh=6.0,
114116
live_solar_kw=None,
115117
hours_until_cheap_rate=None,
116118
estimated_home_load_kw=None,
@@ -119,18 +121,20 @@ def test_morning_high_soc_protection_does_not_export_for_amber_when_headroom_is_
119121
)
120122
)
121123

122-
assert mode == SIGEN_MODES["SELF_POWERED"]
123-
assert "Forecast is Amber" in reason
124+
assert mode == SIGEN_MODES["GRID_EXPORT"]
125+
assert "headroom" in reason.lower()
124126

125127

126-
def test_morning_high_soc_protection_does_not_trigger_below_threshold():
128+
def test_amber_does_not_export_when_headroom_meets_amber_target():
129+
# Amber forecast: headroom (8.0 kWh) already exceeds the Amber target (6.0 kWh),
130+
# so no export is needed regardless of SOC.
127131
mode, reason = decide_operational_mode(
128132
DecisionContext(
129133
period="Morn",
130134
status="Amber",
131-
soc=45.0,
132-
headroom_kwh=0.6,
133-
headroom_target_kwh=10.2,
135+
soc=75.0,
136+
headroom_kwh=8.0,
137+
headroom_target_kwh=6.0,
134138
live_solar_kw=None,
135139
hours_until_cheap_rate=None,
136140
estimated_home_load_kw=None,

0 commit comments

Comments
 (0)