Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Next

- **Fixed** batteries resuming automatic charging or discharging when every distribution weight was set to zero; all parked batteries now wind down to zero until their weights are restored ([#646](https://github.com/tomquist/astrameter/pull/646)).

- **Fixed** a battery set to a lower **Efficiency Window Weight** than its peers barely taking a turn in the low-demand rotation, instead of running proportionally less than them ([#647](https://github.com/tomquist/astrameter/issues/647), [#648](https://github.com/tomquist/astrameter/pull/648)). Set it to `0 %` for a battery you want held back whenever the others can cover.

- **Fixed** an unavailable ESPHome native power sensor continuing to report its old reading as healthy while connected, leaving batteries steering against stale grid power until the sensor recovered ([#645](https://github.com/tomquist/astrameter/pull/645)).
Expand Down
9 changes: 9 additions & 0 deletions esphome/components/ct002/balancer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1059,6 +1059,15 @@ std::array<float, 3> LoadBalancer::compute_auto_target_(
this->predict_control_grid_(reports, grid_total, sample_id), trim_fresh);
this->diag_control_grid_ = control_grid;

// Weight zero explicitly parks the battery, even if the entire pool is
// parked. Bypass allocation/probing and wind existing output down.
if (consumer_id && reports.count(*consumer_id) && reports.at(*consumer_id).weight == 0.0f) {
if (this->probe_participants_().count(*consumer_id)) {
this->clear_probe_state_("participant parked");
}
return this->steer_to_zero_(consumer_id, reports, true);
}

std::unordered_map<std::string, float> saturation;
for (const auto &c : this->consumers_)
saturation[c.first] = static_cast<float>(c.second.saturation_score);
Expand Down
8 changes: 8 additions & 0 deletions src/astrameter/ct002/balancer.py
Original file line number Diff line number Diff line change
Expand Up @@ -1870,6 +1870,14 @@ def _compute_auto_target(
control_grid = self._apply_import_trim(control_grid, trim_fresh)
self._diag_control_grid = control_grid

# Weight zero is an explicit park, including when every battery is
# parked. Do this before allocation/probing can fall back to an equal
# share, and wind existing output down rather than merely adding zero.
if consumer_id and consumer_id in reports and reports[consumer_id].weight == 0:
if consumer_id in self._probe_participants():
self._clear_probe_state("participant parked")
return self._steer_to_zero(consumer_id, reports, paced=True)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

saturation = {cid: s.saturation_score for cid, s in self._consumers.items()}
eff_part = {cid: max(0.01, 1.0 - saturation.get(cid, 0.0)) for cid in reports}

Expand Down
72 changes: 72 additions & 0 deletions tests/components/ct002/host_balancer_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ using esphome::ct002::to_grid_reading;
class TestableBalancer : public LoadBalancer {
public:
using LoadBalancer::LoadBalancer;
void stage_probe(double now) {
this->priority_ = {"a", "b"};
this->last_rotation_ = now;
this->begin_probe_("a", {"a"}, {"b"}, {"b"}, now);
}
bool has_probe() const { return this->probe_state_.has_value(); }

void set_saturation(const std::string &consumer_id, double score) {
this->get_consumer_(consumer_id).saturation_score = score;
}
Expand Down Expand Up @@ -231,6 +238,43 @@ TEST(LoadBalancer, ZeroWeightTakesNoShare) {
EXPECT_FLOAT_EQ(b_out[0], 400.0f);
}

TEST(LoadBalancer, AllZeroWeightsParkAndResume) {
// The all-zero pool must not fall back to an equal split, even while
// winding down existing charge/discharge. Mirrors the Python regression.
for (bool fair : {false, true}) {
for (float grid : {-1000.0f, 1000.0f}) {
for (float power : {0.0f, 200.0f, -200.0f}) {
BalancerConfig cfg;
cfg.fair_distribution = fair;
cfg.min_efficient_power = 0.0f;
cfg.pace_base_step = 0.0f;
cfg.grid_predict_trust = 0.0f; // assert allocation against the raw grid
auto b = make_balancer(cfg);
ReportMap reports;
reports["a"] = ConsumerReport{"HMA-2", "A", power, 0.0f};
reports["b"] = ConsumerReport{"HMA-2", "A", power, 0.0f};
for (const auto &cid : {"a", "b"}) {
const auto out = b.compute_target(cid, ConsumerMode{}, reports, grid, {}, {}, {});
EXPECT_FLOAT_EQ(out[0] + out[1] + out[2], -power);
}
reports["a"] = ConsumerReport{"HMA-2", "A", 0.0f, 1.0f};
reports["b"] = ConsumerReport{"HMA-2", "A", 0.0f, 0.0f};
const auto out = b.compute_target("a", ConsumerMode{}, reports, grid, {}, {}, {});
EXPECT_FLOAT_EQ(out[0] + out[1] + out[2], grid);
}
}
}
}

TEST(LoadBalancer, ZeroWeightPreservesManualOverride) {
auto b = make_balancer(BalancerConfig{});
ReportMap reports;
reports["a"] = ConsumerReport{"HMA-2", "A", 0.0f, 0.0f};
const auto out = b.compute_target("a", ConsumerMode{ConsumerModeKind::MANUAL, 300.0f},
reports, 1000.0f, {}, {"a"}, {});
EXPECT_FLOAT_EQ(out[0] + out[1] + out[2], 300.0f);
}

TEST(LoadBalancer, AutoSplitAcrossPhases) {
BalancerConfig cfg;
cfg.fair_distribution = false;
Expand Down Expand Up @@ -755,3 +799,31 @@ TEST(SteerLog, WithNoSinkTheBalancerFormatsNothing) {
}

} // namespace


TEST(LoadBalancer, ParkingProbeParticipantCancelsBeforeResume) {
// Candidate and backup both invalidate the handoff when explicitly parked.
for (const auto &first : {"a", "b"}) {
double now = 1000.0;
BalancerConfig cfg;
cfg.min_efficient_power = 500.0f;
cfg.pace_base_step = 0.0f;
cfg.grid_predict_trust = 0.0f;
auto b = make_testable(&now, cfg);
b.stage_probe(now);
ReportMap reports;
reports["a"] = ConsumerReport{"HMA-2", "A", 0.0f, 0.0f};
reports["b"] = ConsumerReport{"HMA-2", "A", 0.0f, 0.0f};
b.compute_target(first, ConsumerMode{}, reports, 400.0f, {}, {}, {});
EXPECT_FALSE(b.has_probe());
for (const auto &cid : {"a", "b"}) {
const auto out = b.compute_target(cid, ConsumerMode{}, reports, 400.0f, {}, {}, {});
EXPECT_FLOAT_EQ(out[0] + out[1] + out[2], 0.0f);
}
now += 1.0; // Before the old deadline: resume allocation, not the old probe.
reports["a"].weight = 1.0f;
const auto out = b.compute_target("a", ConsumerMode{}, reports, 400.0f, {}, {}, {});
EXPECT_FALSE(b.has_probe());
EXPECT_GT(out[0] + out[1] + out[2], 100.0f);
}
}
75 changes: 75 additions & 0 deletions tests/test_balancer_distribution_weight.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
the unweighted behaviour.
"""

import pytest

from astrameter.ct002.balancer import (
BalancerConfig,
ConsumerMode,
Expand All @@ -24,6 +26,7 @@ def _make_balancer(*, fair_distribution: bool = True) -> LoadBalancer:
min_efficient_power=0,
# Pin the raw weighted-share math; ramp pacing has its own tests.
pace_base_step=0,
grid_predict_trust=0, # assert allocation against the raw grid
),
saturation_alpha=0.15,
saturation_min_target=20,
Expand Down Expand Up @@ -101,3 +104,75 @@ def test_balance_correction_targets_weighted_share() -> None:
# Weighted target for "a" (300) > its 250 reported → pushed up; "b" pushed down.
assert a_out[0] > 250.0
assert b_out[0] < 250.0


@pytest.mark.parametrize("fair_distribution", [False, True])
@pytest.mark.parametrize("grid", [-1000.0, 1000.0])
@pytest.mark.parametrize("power", [0.0, 200.0, -200.0])
def test_all_zero_weights_park_and_resume(
fair_distribution: bool, grid: float, power: float
) -> None:
"""Parking the last eligible battery must not reactivate the whole pool."""
lb = _make_balancer(fair_distribution=fair_distribution)
reports = {"a": _report(power, weight=0), "b": _report(power, weight=0)}
for cid in reports:
out = lb.compute_target(
cid, ConsumerMode("auto"), reports, grid, frozenset(), frozenset()
)
assert sum(out) == -power
assert lb.get_last_intent(cid) == 0
# Restoring a weight lets that battery cover the demand again.
reports["a"] = _report(0, weight=1)
reports["b"] = _report(0, weight=0)
out = lb.compute_target(
"a", ConsumerMode("auto"), reports, grid, frozenset(), frozenset()
)
assert sum(out) == grid


def test_zero_weight_preserves_manual_override() -> None:
"""Weight controls automatic allocation, not an explicit manual target."""
lb = _make_balancer()
reports = {"a": _report(0, weight=0)}
out = lb.compute_target(
"a", ConsumerMode("manual", 300), reports, 1000, frozenset(), frozenset({"a"})
)
assert sum(out) == 300


@pytest.mark.parametrize("park_first", ["a", "b"])
def test_parking_probe_participant_cancels_probe_before_resume(park_first: str) -> None:
"""A parked probe must not resume its old low target when a weight returns."""
from dataclasses import replace

now = 1000.0
lb = _make_balancer()
lb._cfg = replace(lb._cfg, min_efficient_power=500)
lb._clock = lambda: now
lb._priority = ["a", "b"]
lb._last_rotation = now
lb._begin_probe("a", ("a",), ("b",), ("b",), now)
reports = {cid: _report(0, weight=0) for cid in ("a", "b")}
# Either the candidate or its backup can be the first parked participant.
lb.compute_target(
park_first, ConsumerMode("auto"), reports, 400, frozenset(), frozenset()
)
assert lb._probe_state is None
for cid in reports:
assert (
sum(
lb.compute_target(
cid, ConsumerMode("auto"), reports, 400, frozenset(), frozenset()
)
)
== 0
)
now += 1 # Restore before the former probe's deadline.
reports["a"] = _report(0, weight=1)
out = lb.compute_target(
"a", ConsumerMode("auto"), reports, 400, frozenset(), frozenset()
)
assert lb._probe_state is None
# Normal allocation can still fade the efficiency pool, but must not
# restart the old probe at its initial 5 W request.
assert sum(out) > 100