Skip to content

Add MIN_DC_OUTPUT floor to keep B2500 inverter awake#431

Merged
tomquist merged 3 commits into
developfrom
claude/laughing-noether-JUU1N
Jun 6, 2026
Merged

Add MIN_DC_OUTPUT floor to keep B2500 inverter awake#431
tomquist merged 3 commits into
developfrom
claude/laughing-noether-JUU1N

Conversation

@tomquist

@tomquist tomquist commented Jun 6, 2026

Copy link
Copy Markdown
Owner

Summary

Implements a configurable minimum discharge floor (MIN_DC_OUTPUT) to prevent DC batteries with external inverters (Marstek B2500 family) from sleeping at 0 W under high PV surplus. This solves issue #425 where a lone B2500 would deadlock the grid at full feed-in because its inverter would switch off.

Key Changes

  • Device capabilities model: Replaces ad-hoc prefix checks with a unified DeviceCapabilities dataclass that classifies batteries into three independent capabilities (built-in inverter, AC input, DC input). This is the single source of truth for all device-type decisions.

  • AC-charge eligibility reclassified: Unknown/empty device_type strings are now assumed to be modern AC-coupled batteries (issue Feature Request: Mindest-Leistungslimit (Min Target) für Inverter bei hohem PV-Überschuss #425), intentionally dropping the former fail-closed-to-DC default (issue falsche Werte bei emulierten Smartmeter #338). This prevents deadlock when device types can't be identified.

  • MIN_DC_OUTPUT floor: New global min_dc_output config option (default 0 = disabled) that holds B2500-family batteries at a minimum discharge to keep their external inverter awake. Also supports per-device overrides via MQTT.

  • Floor eligibility: Only applies to batteries with no built-in inverter and no AC input (B2500 family: HMA*, HMJ*, HMK*). Venus and Jupiter are unaffected because they have built-in inverters.

  • Respects user intent: The floor does not override explicit user choices—batteries parked via distribution_weight=0, manual mode, or inactive mode stay at 0.

  • Home Assistant integration: New "Min DC Output" number entity in MQTT discovery, only shown for B2500-family batteries where it has an effect.

Implementation Details

  • Python and C++ implementations are kept in parity (both src/astrameter/ct002/balancer.py and esphome/components/ct002/balancer.cpp).
  • The floor is applied after the auto-path target computation, wrapping the result to enforce the minimum while preserving phase distribution.
  • Configuration validation warns if MIN_DC_OUTPUT is set below the saturation min_target, which would prevent saturation detection on floored units.
  • Comprehensive test coverage including parity tests between Python and C++ implementations, and scenario tests for mixed Venus/B2500 setups.

https://claude.ai/code/session_01BE7uMJDKMJNRPZTquxuTyM

Summary by CodeRabbit

  • New Features

    • Added a global and per-battery configurable Min DC Output to keep DC-only batteries awake during high PV surplus.
    • Home Assistant gained a per-battery Min DC Output control that overrides the global setting for DC batteries.
  • Bug Fixes

    • Fixed an MQTT/Home Assistant phantom "Unnamed Device" by publishing a properly grouped AstraMeter device.
  • Documentation

    • Updated README, examples, and UI docs for DC battery keep-alive and HA controls.

Some DC batteries (e.g. Marstek B2500) feed a separate inverter from their
DC output. Under high PV surplus the balancer drives the target to 0 W, the
inverter's DC input drops below its wake threshold, and it switches off and
sleeps without recovering on its own.

Add a configurable minimum net discharge floor:
- Global MIN_DC_OUTPUT in [CT002]/[CT003] (default 0 = off), warned when set
  below the saturation min target.
- Per-device override via MQTT Insights, surfaced as a Home Assistant "Min DC
  Output" number — only on batteries where it has an effect.

The floor is applied at a single chokepoint in the auto path, holding an
eligible battery at the floor whenever its commanded net output drops below
it (recovering the consumer's full intended reading via the phase-split total),
while leaving manual/inactive/weight-0 batteries untouched.

Introduce a device-capabilities classifier (built-in inverter / AC input / DC
input) as the single source of truth for device-type decisions. The floor
applies only to batteries with no AC input and no built-in inverter (B2500
family); Venus and Jupiter are excluded. _is_ac_chargeable now derives from
this classifier; unrecognized/empty types are treated as modern AC-coupled
batteries.

Mirrored 1:1 in the ESPHome C++ component (classifier, config, floor, and the
per-device MQTT/discovery wiring) with parity-test coverage across device types.
@coderabbitai

coderabbitai Bot commented Jun 6, 2026

Copy link
Copy Markdown

Ready to act? Review this PR in Change Stack to turn feedback into patch suggestions you can inspect and refine.

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a050fd74-b2de-4545-80d2-2bb7182199ee

📥 Commits

Reviewing files that changed from the base of the PR and between 340ae0d and 97fe4a9.

📒 Files selected for processing (3)
  • esphome/components/ct002/ct002.cpp
  • src/astrameter/main.py
  • tests/components/ct002/fixtures/balancer_parity_harness.cpp
🚧 Files skipped from review as they are similar to previous changes (3)
  • tests/components/ct002/fixtures/balancer_parity_harness.cpp
  • esphome/components/ct002/ct002.cpp
  • src/astrameter/main.py

Walkthrough

This PR introduces a Min DC Output keep-alive: global and per-consumer minimum DC discharge thresholds, a DeviceCapabilities-based device classification, enforcement in the load balancer (auto targets floored when applicable), propagation to snapshots/MQTT, conditional HA discovery/command handling, UI/generator/config wiring, and tests.

Changes

Min DC Output Keep-Alive Feature

Layer / File(s) Summary
Device classification abstraction
esphome/components/ct002/balancer.h, esphome/components/ct002/balancer.cpp, src/astrameter/ct002/balancer.py
New DeviceCapabilities and device_capabilities() derive has_ac_input/has_dc_input/has_builtin_inverter from device_type; AC-chargeability and DC-floor eligibility follow from capabilities.
Configuration schema & examples
web/ts/schema.ts, web/ts/generate.ts, web/ts/app.ts, esphome/components/ct002/__init__.py, config.ini.example, esphome.example.yaml, README.md, CHANGELOG.md
Added CT_DC_KEEPALIVE/MIN_DC_OUTPUT to web schema, generators, UI, esphome schema, INI/YAML examples, README, and changelog; wired default into firmware codegen and test hooks.
Balancer config & enforcement
src/astrameter/ct002/balancer.py, esphome/components/ct002/balancer.h, esphome/components/ct002/balancer.cpp
BalancerConfig.min_dc_output added and clamped; auto-mode target is post-processed by apply_min_dc_output_/_apply_min_dc_output() which selects effective per-consumer floor (global or override), respects parked/manual/inactive, updates last_target, and returns floored phase targets.
Per-consumer override & propagation
esphome/components/ct002/ct002.h, esphome/components/ct002/ct002.cpp, src/astrameter/ct002/ct002.py
Consumer state gains optional min_dc_output; CT002.set_consumer_min_dc_output() validates/stores overrides; per-consumer reports and snapshots include the override for balancer and MQTT consumption.
Home Assistant & MQTT integration
esphome/components/ct002/ha_discovery.cpp, esphome/components/ct002/mqtt_insights.cpp, src/astrameter/mqtt_insights/discovery.py, src/astrameter/mqtt_insights/service.py
Discovery conditionally emits a min_dc_output HA number entity for eligible device types; MQTT Insights publishes min_dc_output in CT002 consumer state and accepts validated min_dc_output commands routed to registered handlers.
Application entry & MQTT wiring
src/astrameter/main.py
Loads MIN_DC_OUTPUT from CT config (default 0), warns on low positive values, passes into CT002 constructor, and registers MQTT Insights handler for remote updates.
Parity harness & tests
tests/components/ct002/fixtures/balancer_parity_harness.cpp, tests/components/ct002/test_balancer_parity.py, tests/test_balancer_mixed_battery_charging.py, tests/components/ct002/host_balancer_test.cpp, tests/test_ct002_active_control.py, src/astrameter/mqtt_insights/mqtt_insights_test.py
Extended parity harness and python driver protocol to carry global and per-consumer MIN_DC_OUTPUT; added deterministic and randomized parity tests; expanded mixed-charging tests for device classification and MIN_DC_OUTPUT scenarios; added MQTT discovery/state/command unit tests.

Sequence Diagram

sequenceDiagram
  participant Client as HA/MQTT Client
  participant MqttSvc as MqttInsightsService
  participant CT002 as CT002 Component
  participant Balancer as LoadBalancer

  Client->>MqttSvc: publish min_dc_output command
  MqttSvc->>CT002: call set_consumer_min_dc_output(device_id, value)
  CT002->>Balancer: include min_dc_output in ConsumerReport
  Balancer->>Balancer: compute_auto_target -> apply_min_dc_output_ -> floored targets
  Balancer-->>CT002: return phase targets
  CT002->>MqttSvc: publish consumer state including min_dc_output
  MqttSvc-->>Client: retained state message
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • tomquist/AstraMeter#360: Related changes to CT002 balancer auto-target computation and gating; overlaps code-area with min-DC-output post-processing.
  • tomquist/AstraMeter#342: Related device-type classification/refactor affecting AC vs DC handling used by this PR's DeviceCapabilities model.
  • tomquist/AstraMeter#406: Related parity harness and test-infrastructure work; this PR extends the parity protocol with min_dc_output values.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 44.71% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically identifies the main change: adding a MIN_DC_OUTPUT configuration to prevent B2500 inverter sleep, which is the primary feature across the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/laughing-noether-JUU1N

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 Infer (1.2.0)
tests/components/ct002/fixtures/balancer_parity_harness.cpp

tests/components/ct002/fixtures/balancer_parity_harness.cpp:37:10: fatal error: 'esphome/components/ct002/balancer.h' file not found
37 | #include "esphome/components/ct002/balancer.h"
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1 error generated.
Error: the following clang command did not run successfully:
/opt/infer-linux-x86_64-v1.2.0/lib/infer/facebook-clang-plugins/clang/install/bin/clang-18
@/tmp/coderabbit-infer/97fe4a93fdfa053949f57261b125fa3c006d33a2-1f634b2056f4195e/tmp/clang_command_.tmp.f62ab2.txt
++Contents of '/tmp/coderabbit-infer/97fe4a93fdfa053949f57261b125fa3c006d33a2-1f634b2056f4195e/tmp/clang_command_.tmp.f62ab2.txt':
"-cc1" "-load"
"/opt/infer-linux-x86_64-v1.2.0/lib/infer/infer/bin/../../facebook-clang-plugins/libtooling/build/FacebookClangPlugin.dylib"
"-add-plugin" "BiniouASTExporter" "-plugin-arg-BiniouASTExporter" "-"
"-plugin-arg-BiniouASTExporter" "PREPEND_CURRENT_DIR=1"
"-plugin-arg-BiniouASTExporter" "MAX_STRING_

... [truncated 1217 characters] ...

internal-isystem" "/usr/local/include" "-internal-isystem"
"/usr/lib/gcc/x86_64-linux-gnu/12/../../../../x86_64-linux-gnu/include"
"-internal-externc-isystem" "/usr/include/x86_64-linux-gnu"
"-internal-externc-isystem" "/include" "-internal-externc-isystem"
"/usr/include" "-Wno-ignored-optimization-argument" "-Wno-everything"
"-fdeprecated-macro" "-ferror-limit" "19" "-fgnuc-version=4.2.1"
"-fskip-odr-check-in-gmf" "-fcxx-exceptions" "-fexceptions"
"-D__GCC_HAVE_DWARF2_CFI_ASM=1" "-o"
"/tmp/coderabbit-infer/1f634b2056f4195e/file.o" "-x" "c++"
"tests/components/ct002/fixtures/balancer_parity_harness.cpp" "-O0"
"-fno-builtin" "-include"
"/opt/infer-linux-x86_64-v1.2.0/lib/infer/infer/bin/../lib/clang_wrappers/global_defines.h"
"-Wno-everything"

esphome/components/ct002/ct002.cpp

In file included from esphome/components/ct002/ct002.cpp:1:
esphome/components/ct002/ct002.h:13:10: fatal error: 'esphome/core/component.h' file not found
13 | #include "esphome/core/component.h"
| ^~~~~~~~~~~~~~~~~~~~~~~~~~
1 error generated.
esphome/components/ct002/ct002.cpp:99:30-162:1: ERROR translating statement 'CompoundStmt'
Aborting translation of method 'esphome::ct002::CT002Component::setup' in file 'esphome/components/ct002/ct002.cpp': "Assert_failure src/clang/cAst_utils.ml:249:53"
Uncaught Internal Error: "Assert_failure src/clang/cAst_utils.ml:249:53"
Error backtrace:
Raised at ClangFrontend__CAst_utils.get_decl_from_typ_ptr in file "src/clang/cAst_utils.ml", line 249, characters 53-65
Called from ClangFrontend__CTrans.CTrans_funct.get_destructor_decl_ref in file "src/clang/cTrans.ml", line 658, characters 12-59
Called from ClangFrontend__CTrans.CTrans_funct.destructor_calls.(fun) in file "src/clang/cTrans.ml", line 2048, characters 12-69
Called from Ba

... [truncated 2200 characters] ...

6-141
Called from ClangFrontend__CFrontend_decl.CFrontend_decl_funct.add_method in file "src/clang/cFrontend_decl.ml" (inlined), line 54, characters 4-52
Called from ClangFrontend__CFrontend_decl.CFrontend_decl_funct.process_method_decl.add_method_if_create_procdesc in file "src/clang/cFrontend_decl.ml" (inlined), line 123, characters 16-158
Called from ClangFrontend__CFrontend_decl.CFrontend_decl_funct.process_method_decl in file "src/clang/cFrontend_decl.ml", line 126, characters 17-97
Called from ClangFrontend__CFrontend_decl.CFrontend_decl_funct.process_methods in file "src/clang/cFrontend_decl.ml" (inlined), line 270, characters 8-122
Called from Stdlib__List.iter in file "list.ml" (inlined), line 110, characters 12-15
Called from Stdlib__List.iter in file "list.ml" (inlined), line 10


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 6, 2026

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

MIN_DC_OUTPUT applies to each DC-only battery individually (including a
single-battery setup) and is independent of multi-battery balancing. Move it
out of the "Fair distribution / Multi-battery balancing" grouping in the docs
and the web config generator into its own "DC battery keep-alive" section.

No behavior change — the balancer already applies the floor per consumer. The
ESPHome YAML key stays under the `balancer:` block.
@tomquist tomquist marked this pull request as ready for review June 6, 2026 08:28

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
tests/components/ct002/fixtures/balancer_parity_harness.cpp (1)

11-18: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Make md truly optional in target parsing (or make it explicitly required).

The parser at Line 99 always reads md, but the documented row format at Line 17 is still 4 fields. With multi-row inputs using 4-field rows, token alignment can shift and corrupt report parsing.

Proposed fix (accept both 4-field and 5-field row formats deterministically)
@@
-    } else if (cmd == "target") {
+    } else if (cmd == "target") {
       std::string cid, mode_str;
       float manual = 0.0f, grid = 0.0f;
       int n = 0;
       in >> cid >> mode_str >> manual >> grid >> n;
+      std::vector<std::string> tokens;
+      for (std::string tok; in >> tok;) tokens.push_back(tok);
+      const bool has_md = (tokens.size() == static_cast<size_t>(n) * 5U);
+      if (!has_md && tokens.size() != static_cast<size_t>(n) * 4U) {
+        continue;  // malformed line
+      }
       ReportMap reports;
-      for (int i = 0; i < n; ++i) {
-        std::string rc, dev, phase;
-        float power = 0.0f, md = -1.0f;
-        in >> rc >> dev >> phase >> power >> md;
+      size_t idx = 0;
+      for (int i = 0; i < n; ++i) {
+        std::string rc = tokens[idx++];
+        std::string dev = tokens[idx++];
+        std::string phase = tokens[idx++];
+        float power = std::stof(tokens[idx++]);
+        float md = -1.0f;
+        if (has_md) md = std::stof(tokens[idx++]);
         ConsumerReport r{dev, phase, power};
         if (md >= 0.0f) r.min_dc_output = md;
         reports[rc] = r;
       }
-//   target <cid> <mode> <manual> <grid> <n> [<cid> <dev> <phase> <power>]xN
+//   target <cid> <mode> <manual> <grid> <n>
+//          [<cid> <dev> <phase> <power> [<md>]]xN

Also applies to: 96-103

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/components/ct002/fixtures/balancer_parity_harness.cpp` around lines 11
- 18, The target-row parser in balancer_parity_harness.cpp currently
unconditionally reads the optional md token, which corrupts alignment when rows
use the documented 4-field form; modify the parsing logic in the target handling
code (the routine that tokenizes each target row and uses variables like md,
mode, manual, grid, n and the subsequent per-device tuple parsing) to first
count tokens for the row and deterministically branch: if token_count == 5 read
md then shift subsequent reads, else if token_count == 4 set md to a
default/empty value and parse the remaining fields normally; apply the same
deterministic token-count check to the duplicate parsing block referenced in the
nearby section (the other target-parsing lines) so both variants (4-field and
5-field) are supported without misalignment.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@esphome/components/ct002/ct002.cpp`:
- Around line 655-660: The C++ setter CT002Component::set_consumer_min_dc_output
currently rejects values >1000 while the Python CT002 contract accepts any
finite value >= 0; update the validation in
CT002Component::set_consumer_min_dc_output to only reject non-finite or negative
values (remove the upper bound check against 1000) so behavior matches
src/astrameter/ct002/ct002.py, leaving assignment to
this->get_consumer_(consumer_id).min_dc_output unchanged.

In `@src/astrameter/main.py`:
- Around line 196-205: The code reads MIN_DC_OUTPUT with cfg.getint but
BalancerConfig.min_dc_output is a float and ESPHome accepts non-integer values,
so change the call to use cfg.getfloat (e.g., replace cfg.getint(...) with
cfg.getfloat(...)) and ensure the fallback is a float (0.0) so min_dc_output is
a float; keep the existing comparisons against min_target_for_saturation and the
logger.warning text (referencing min_dc_output, min_target_for_saturation, and
BalancerConfig.min_dc_output) unchanged.

---

Outside diff comments:
In `@tests/components/ct002/fixtures/balancer_parity_harness.cpp`:
- Around line 11-18: The target-row parser in balancer_parity_harness.cpp
currently unconditionally reads the optional md token, which corrupts alignment
when rows use the documented 4-field form; modify the parsing logic in the
target handling code (the routine that tokenizes each target row and uses
variables like md, mode, manual, grid, n and the subsequent per-device tuple
parsing) to first count tokens for the row and deterministically branch: if
token_count == 5 read md then shift subsequent reads, else if token_count == 4
set md to a default/empty value and parse the remaining fields normally; apply
the same deterministic token-count check to the duplicate parsing block
referenced in the nearby section (the other target-parsing lines) so both
variants (4-field and 5-field) are supported without misalignment.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 49c10205-c2d9-4819-8f65-db2ee9055444

📥 Commits

Reviewing files that changed from the base of the PR and between 9db4045 and 340ae0d.

📒 Files selected for processing (26)
  • CHANGELOG.md
  • README.md
  • config.ini.example
  • esphome.example.yaml
  • esphome/components/ct002/__init__.py
  • esphome/components/ct002/balancer.cpp
  • esphome/components/ct002/balancer.h
  • esphome/components/ct002/ct002.cpp
  • esphome/components/ct002/ct002.h
  • esphome/components/ct002/ha_discovery.cpp
  • esphome/components/ct002/mqtt_insights.cpp
  • esphome/components/ct002/test_hooks.cpp
  • src/astrameter/ct002/balancer.py
  • src/astrameter/ct002/ct002.py
  • src/astrameter/main.py
  • src/astrameter/mqtt_insights/discovery.py
  • src/astrameter/mqtt_insights/mqtt_insights_test.py
  • src/astrameter/mqtt_insights/service.py
  • tests/components/ct002/fixtures/balancer_parity_harness.cpp
  • tests/components/ct002/host_balancer_test.cpp
  • tests/components/ct002/test_balancer_parity.py
  • tests/test_balancer_mixed_battery_charging.py
  • tests/test_ct002_active_control.py
  • web/ts/app.ts
  • web/ts/generate.ts
  • web/ts/schema.ts

Comment thread esphome/components/ct002/ct002.cpp
Comment thread src/astrameter/main.py Outdated
- main.py: read MIN_DC_OUTPUT with getfloat (the field is a float and is
  exposed as float per-device/ESPHome) so e.g. 12.5 doesn't fail at startup;
  log with %g.
- ct002.cpp: drop the >1000 upper bound in set_consumer_min_dc_output to match
  the Python setter (finite, >=0); the MQTT handler still enforces 0..1000 on
  both stacks.
- balancer_parity_harness.cpp: update the stale command-format comment to
  document the optional global min_dc_output and the per-report <md> token.
@tomquist tomquist merged commit 1b00b26 into develop Jun 6, 2026
27 checks passed
@tomquist tomquist deleted the claude/laughing-noether-JUU1N branch June 6, 2026 09:26
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