Skip to content

[#101] Add phase protection logic + offline test suites - #147

Open
nickles-lee wants to merge 20 commits into
gitcodebob:mainfrom
nickles-lee:101-phase-protection-peak-shaving
Open

[#101] Add phase protection logic + offline test suites#147
nickles-lee wants to merge 20 commits into
gitcodebob:mainfrom
nickles-lee:101-phase-protection-peak-shaving

Conversation

@nickles-lee

@nickles-lee nickles-lee commented Jun 20, 2026

Copy link
Copy Markdown

Summary

Closes #101.

This PR adds optional per-phase peak-shaving protection on top of the existing aggregate import/export peak-shaving behavior.

When enabled and usable phase data is available, the controller can react when an individual phase exceeds the configured per-phase power limit, even if the aggregate grid value still looks safe.

What changed

  • Adds per-phase command-limit calculation from grid_power_phase.L1/L2/L3.
  • Adds a global phase-protection guard before strategy execution so direct strategies like Charge, Sell, and Dynamic 2 can be preempted into Standby / peak shave.
  • Extends peak-shaving logic so per-phase overloads can trigger import/export shaving.
  • Applies phase-aware command limits in Charge, Sell, and Self-consumption.
  • Adds water-fill allocation so batteries on the same phase share constrained power more fairly.
  • Preserves priority-battery behavior during phase-throttled charging.
  • Redistributes unused phase charge allowance when a battery cannot use its full share.
  • Adds an offline Node-RED flow test harness and integration/unit tests for phase-protection behavior.

Stacked PR note

This PR is stacked on top of #139.

#139 contains the phase assignment / phase exposure groundwork. This PR intentionally focuses only on the #101 phase-protection logic and offline test coverage. Please avoid reviewing #102-scoped changes here; review this PR as the delta from the head of 102-phase-assignments-for-batteries to the head of 101-phase-protection-peak-shaving.

Suggested review compare:

git diff origin/102-phase-assignments-for-batteries..origin/101-phase-protection-peak-shaving

Range-diff from the #139 stack branch to this branch:

git range-diff origin/main..origin/102-phase-assignments-for-batteries origin/main..origin/101-phase-protection-peak-shaving

 1:  6ced552 =  1:  6ced552 Feat: Add battery phase assignment support
 2:  ba1fad7 =  2:  ba1fad7 Expose per-phase info from meter
 3:  cb7e37a =  3:  cb7e37a Hide phase interaction when no phases assigned
 4:  b37c8da =  4:  b37c8da Remove tracked macOS metadata
 -:  ------- >  5:  c66abcd Add per-phase peak shaving protection
 -:  ------- >  6:  8e81b23 Apply phase protection before strategies
 -:  ------- >  7:  da9c313 Stabilize phase command limits
 -:  ------- >  8:  1606757 Add phase water-fill allocator
 -:  ------- >  9:  d5ce48f Deduplicate phase allocator logic
 -:  ------- > 10:  9154abf Preserve charge priority under phase throttling
 -:  ------- > 11:  caf4f55 Redistribute unused phase charge allowance
 -:  ------- > 12:  faab292 test: add Node-RED flow harness and strategy integration tests
 5:  5b85367 = 13:  5b85367 improve .gitignore + switch power reference
 -:  ------- > 14:  852f1a1 test: harden phase-protection tests across personas; fix harness init & command_limits_available handling

Compatibility

Existing behavior remains unchanged when phase protection is disabled, phase sensors are unavailable, or no batteries are assigned to phases.

Per-phase protection only acts when all of the following are true:

  • Phase protection is enabled.
  • L1/L2/L3 readings are available.
  • A valid per-phase limit is configured.
  • At least one battery is assigned to the overloaded phase.

Full stop is intentionally not preempted. Full stop should mean that the battery is fully in standby / offlined.

Testing

Offline Node-RED flow test coverage was added for phase-protection behavior.

Validated locally on this branch:

npm test
60 tests
60 pass

Also verified that the phase-protection tests fail when run against main, where the phase-protection logic does not exist:

60 tests
16 pass
44 fail

This confirms the tests are not simply passing vacuously on branches without the feature.

Regression checks were also run against earlier draft commits in this branch to make sure the test suite catches prior unwanted behavior, including missing guard logic, unfair first-fit allocation, missing priority preservation, and missing allocator helpers.

Real-world testing / safety notes

This is an experimental control feature. It does not come with any warranty, guarantee, or promise that your phase fuse will not blow. If you're worried about this, set a wider safety margin on the per-phase limit to account for any spikes that will take some time for the batteries to respond to.

This logic is software-based peak shaving, not a replacement for correct electrical design, breaker sizing, phase-aware charger limiting, inverter limits, or professional installation checks.

If you test this branch on a real installation:

  • Understand that peak shaving reduces and shortens excessive load but it does not eliminate or prevent it. In testing, charging took roughly 10 seconds to fully throttle to a safe power level when overcurrent was observed. Wear/progressive failure can occur if the spikes are large enough, and instantaneous failure can happen if the spikes are massive.
  • Start with conservative phase limits to test behavior and responsiveness when your fuse is not at risk.
  • Actively monitor L1/L2/L3 power and current while testing.
  • Test alongside a controllable, interruptible & instantly stoppable load such as ovens or induction stoves.
  • Keep the Home Battery Control master switch accessible as well.
  • Be prepared to disable control immediately if behavior looks wrong.
  • Do not rely on this as the only protection for your fuses.
  • If your phase fuse blows, your grid operator may charge you to replace it; for example, Liander charges around €100.

This improves the safety of your system & lets you run "hotter", but it does not make the system safe in the way a 25A circuit breaker on each phase would.

Real-World Testing

I have been running this branch in my house for around 1 to 2 weeks without incident, with no PV + heavier loading on Phase 3. If I ran the Charge strategy with the power limits I now have set, one of the main fuses would have cooked.

Some graphs:

Approx. 1 day view:
Image

A more zoomed-in view of a period where phase 3 is running at full load:
Image

Start of a brief overload to 32A, caused by an appliance switching on while the batteries had Phase 3 fully loaded:
Image

End of the overload, 10s later
Image

Note that I've validated phase protection during the time period where the batteries are charging. I haven't tested phase protection when the batteries are on standby / not actively charging.

@nickles-lee nickles-lee changed the title [#101] Add phase protection logic [#101] Add phase protection logic + test suites Jun 20, 2026
@nickles-lee nickles-lee changed the title [#101] Add phase protection logic + test suites [#101] Add phase protection logic + offline test suites Jun 20, 2026
nickles-lee added 5 commits August 14, 2026 16:48
main re-exported both `01 start-flow.json` and `all-flows-in-one-file.json`
from Node-RED with entirely new node IDs (only 3 of 620 base IDs survive in
the combined file), so the textual conflicts were not meaningful. Both flow
files were resolved by taking main's export as the new baseline and
re-applying this branch's changes against the remapped IDs:

  - 6 new nodes: Phase template, Phase, Max phase power,
    Normalize phase limit, Phase grid power, Normalize phase power
  - "Mapping": normalize + emit battery.phase
  - "Loop end": clean up msg.battery_phase / msg.template
  - "Energy max" -> Phase template (phase lookup ahead of Mapping)
  - "Export limit" -> Max phase power (phase chain ahead of Battery Strategy)
  - group "Peak shave settings": +4 members, widened to 1642

Verified main made no semantic change to any node this branch touches, so
the remap is the whole conflict.

house_battery_control.yaml: kept the new phase-assignment binary sensor and
accepted main's removal of the dynamic-v1 "Is below threshold cheapest
period tariff" sensor (nothing references it anymore).

.gitignore: union of both sides, de-duplicated .DS_Store.

contribute/check.ps1 passes.
Brings in the merged 102 branch, which already carries main v4.15.0.

As with 102, main re-exported `01 start-flow.json` and
`all-flows-in-one-file.json` with entirely new node IDs, so both were
resolved by re-applying this branch's changes onto main's export with the
IDs remapped (mappings verified unique by type+name, tab-scoped for the
combined file, groups disambiguated by coordinates and membership):

  - 3 new nodes: Phase protection?, Phase protection guard,
    Phase command limits
  - "Strategy selection" keeps main's new EV-stop-trigger body and is
    rewired through the phase protection guard
  - "Normalize phase power" now feeds the phase protection chain
  - Mapping / Set Batteries / Update battery order / Custom logger bodies
  - groups "Strategy selection" (+guard) and "Peak shave settings"
    (+2 members, widened to 2106)

The one genuine semantic conflict was "Mode selection" in strategy-partials:
main added peak-shave direction/target latching (so the battery's own
response flipping P1 import/export cannot flip an import shave into an
export shave mid-timeout) while this branch rewrote the same function for
per-phase protection. Hand-merged so both apply: 101's phase-aware
selectedDirection/selectedTarget now feed main's latched
peak_shave_direction / peak_shave_power_limit flow state.

strategy-sell.json auto-merged despite main's re-export; verified the
function body landed on the correct node and main had not changed it.

Validated: contribute/check.ps1 passes, all 60 tests pass, and the merged
"Mode selection" was exercised directly for both latching and per-phase
behaviour.

Known pre-existing issue (not introduced here): on this branch the
"Max power solution" body in `02 strategy-charge.json` / `02 strategy-sell.json`
is newer than the copy in all-flows-in-one-file.json — commit 852f1a1's
command_limits_available fix was never re-exported into the combined file.
Both sides were carried forward unchanged; the combined file still needs a
re-export.
"Phase template" and "Phase" carried g="Loop" (the battery information loop)
but were never listed in that group's nodes[], and sat outside its bounds.
Node-RED drives the runtime from the wires, so this was editor-only: the
nodes did not move or collapse with the group.

Add both to the group and widen it from 1612 to 1942, keeping the group's
existing 96px right padding. The parent "Get batteries information" group
grows 1678 -> 2008 so it still contains the nested group with its original
margin; no other group on the tab is overlapped.

Applied to both 01 start-flow.json and all-flows-in-one-file.json.
Commit 852f1a1 gated the phase charge/discharge limits on
`phase_protection.command_limits_available` in the individual strategy
flows, but all-flows-in-one-file.json was never re-exported, so it still
carried the older ungated bodies for Strategy Charge and Strategy Sell.

Since docs/08-how-to-update.md tells users to import the combined file,
users following the documented path were getting the pre-fix logic: the
phase limits were read even when the upstream node had reported them
unavailable.

Copy both bodies verbatim from `02 strategy-charge.json` and
`02 strategy-sell.json`. Every individual flow file's function bodies now
match the combined file's copies; 60/60 tests pass and check.ps1 passes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Improvement] Extend import/export peak-shaving logic to support per-phase peak shaving

1 participant