Skip to content

Add DC anti-sleep floor (min_dc_output) for DC-coupled batteries#428

Closed
tomquist wants to merge 3 commits into
developfrom
claude/festive-mayer-5vpvz
Closed

Add DC anti-sleep floor (min_dc_output) for DC-coupled batteries#428
tomquist wants to merge 3 commits into
developfrom
claude/festive-mayer-5vpvz

Conversation

@tomquist

@tomquist tomquist commented Jun 5, 2026

Copy link
Copy Markdown
Owner

Summary

Implements a configurable anti-sleep floor for DC-coupled batteries (e.g. Marstek B2500) that prevents their inverters from shutting off and getting stuck asleep when commanded to 0 W under PV surplus. When enabled, the balancer holds such batteries at a small charge-direction output instead of letting them idle at 0 W.

Key Changes

  • Balancer configuration: Added min_dc_output parameter (watts, 0–100, default 0 = disabled) to BalancerConfig in both Python and C++
  • Per-battery override: Added min_dc_output field to consumer reports, allowing per-battery overrides of the global floor via MQTT
  • Floor application logic: Implemented _apply_dc_floor() (Python) and apply_dc_floor_() (C++) methods that:
    • Only affect DC-coupled batteries (skips AC-chargeable models like HMG-50/Venus)
    • Only fire in charge territory (grid negative or zero with AC charging)
    • Respect weight-0 parking (intentionally parked batteries stay at 0 W)
    • Gate on actual reported output, not the implied command (a DC battery can't AC-charge, so a large negative target is un-actionable and must still be floored)
    • Preserve last_target unchanged to avoid false saturation scoring
  • MQTT integration: Added min_dc_output field to consumer snapshots and Home Assistant discovery (published for DC batteries only)
  • Configuration: Added MIN_DC_OUTPUT setting to config.ini.example and web config schema
  • Testing: Comprehensive test coverage in both Python (test_balancer_min_dc_output.py) and C++ (LoadBalancer.AutoFloor* tests), plus parity tests comparing both stacks

Implementation Details

The floor is applied at two points in the balancer pipeline:

  1. After _steer_to_zero() for charge-blind batteries
  2. After _split_by_phase() for fair-share targets

This ensures the floor catches both cases where a DC battery would be commanded toward 0 W. The implementation intentionally does NOT update last_target to avoid making the saturation tracker think a DC battery (which physically cannot AC-charge) is "unable to follow" its target.

Mirrors the C++ host test LoadBalancer.AutoFloor* behavior exactly; parity verified via differential fuzzing.

https://claude.ai/code/session_016kDeCqYZ9nAKCsAVxT99uS

claude added 2 commits June 5, 2026 10:28
Some DC-coupled batteries (e.g. Marstek B2500) shut their inverter off
and get stuck asleep when AstraMeter commands their output to 0 W under
PV surplus. Add a configurable floor that, when such a battery would be
steered to 0 W in charge territory, instead holds it at a small
charge-direction target so the inverter stays awake. A DC battery can't
actually AC-charge, so no energy is moved.

The floor only ever affects non-AC-chargeable batteries (reuses
_is_ac_chargeable); AC-chargeable Venus models absorb surplus by
charging and are never touched. It also respects intentional parking
(efficiency deprioritization and distribution_weight=0), and gates its
skip on the battery's actual reported output so a B2500 that can't
follow a large negative command is still floored.

Exposed two ways:
- global [CT002]/[CT003] MIN_DC_OUTPUT config value (default 0 = off);
- per-battery override via a new "Min DC Output" MQTT/HA number entity,
  published for DC batteries only (mirrors distribution_weight).

Mirrored across the Python and ESPHome C++ stacks (balancer, CT002
consumer state, MQTT insights, HA discovery, config schema, test hooks)
with Python and C++ unit tests, a differential parity scenario, and a
byte-identical wire e2e for the cross-talk fields.

https://claude.ai/code/session_016kDeCqYZ9nAKCsAVxT99uS
- Fix a Python↔C++ divergence: `_apply_dc_floor` now early-returns when
  the queried consumer is absent from the report snapshot, matching the
  C++ `apply_dc_floor_` (which already returned the unfloored value).
  Previously Python fabricated a `-floor` reading on phase A for a
  consumer it had no device_type/phase for. Add a regression test.
- Clamp the global `min_dc_output` to [0, 100] in both BalancerConfig
  implementations, matching the per-consumer setter, MQTT handler, HA
  entity, ESPHome schema and web cap (was unbounded on the Python side).
- Close the per-consumer-override parity gap: the differential balancer
  harness wire format now carries a per-report `min_dc_output` token, and
  the DC-floor scenario exercises an override, a global fallback and a
  per-battery disable across both stacks.
- Clarify the README wording for the per-battery "Min DC Output" entity.

https://claude.ai/code/session_016kDeCqYZ9nAKCsAVxT99uS
@coderabbitai

coderabbitai Bot commented Jun 5, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3e30e307-ea15-4a29-b76c-f2352dba2372

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/festive-mayer-5vpvz

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

github-actions Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-06-05 16:52 UTC

The anti-sleep floor must keep the battery's *output/discharge* inverter
running, so it has to command a small DISCHARGE (positive target =
feed-in), not a charge. A negative (charge) command to a DC-coupled
battery that can't AC-charge leaves its output at 0, so the inverter
still sleeps — which is the exact failure mode #425 reports.

Sign convention (verified via test_balancer_distribution_weight and the
simulator's `new_output = current + grid_reading`): positive grid reading
= discharge. The floor now emits `grid_reading = floor - reported`, which
lands net output at exactly +floor and naturally settles to a 0 reading
once there (holds, no oscillation back to 0 W). Dropped the charge-side
early-return accordingly.

Updated both stacks (balancer.py / balancer.cpp), all unit tests, the
differential parity scenario, and the config/README/CHANGELOG wording
(it's a small feed-in, with the minor battery-drain trade-off, not a
zero-energy charge nudge).

https://claude.ai/code/session_016kDeCqYZ9nAKCsAVxT99uS
@tomquist tomquist closed this Jun 5, 2026
@tomquist tomquist deleted the claude/festive-mayer-5vpvz branch June 6, 2026 12:19
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.

2 participants