Skip to content

Solar charge delay: Calculating the charging duration based on the forecasted yield - #448

Open
gojux wants to merge 19 commits into
ffunes:release/v1.5.0from
gojux:main
Open

gojux wants to merge 19 commits into
ffunes:release/v1.5.0from
gojux:main

Conversation

@gojux

@gojux gojux commented Sep 11, 2026

Copy link
Copy Markdown

What

When calculating the required charging duration, the maximum rated charging power was used, which resulted in unrealistically short charging estimates and caused the charging process to start too late.

With this change, the forecasted solar yield is now used to determine the charging duration.

Unifies the calculation of charging efficiency

Approved roadmap item

Discussion / issue: #

Tests

Tested on my setup at home

ffunes and others added 19 commits September 1, 2026 18:27
The open quarter only holds the energy accumulated so far, so it has to be
completed before it is comparable with the closed 15-minute cells. Scaling it
(value * 900 / seconds) divides by a small number early in the quarter -- an
energy sensor reporting every 30 s leaves ~0 kWh over the first minute, which
collapsed the point and made the forecast hand-off read as a spike -- and
dropping it below a minute of coverage punched a 30-minute hole straddling
the now marker, since both the observed and the forecast path lose that index.

Complete it from the previous closed quarter instead: energy already seen plus
that quarter's share of the time still to come. Continuous at both ends (all of
the previous quarter at 0 s of coverage, all of the observed value at 900 s),
and it never divides by a small number.

Measured against a live snapshot at 18:20 with 332 s of coverage: the anchor
goes from 0.245 to 0.337 kWh against a next forecast point of 0.331, turning a
+35% step into -2%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The driver derived min_charge/discharge_power_w from the max_charge_power
register's min (800 W on v2/v3). That is only the lowest ceiling the user
may configure, not a power the hardware refuses to deliver: the setpoint
registers (set_charge_power/set_discharge_power) accept 0 on every model.

Read the floor from the setpoint registers instead, so it is 0 for all
Marstek versions. Predictive charging falls back to its designed floor
(user PD minimum, else the 100 W relay hold) and the thermal derate is no
longer pinned at 800 W. Peak shaving does not read this capability and is
untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Venus A/D fills coupled packs sequentially, so the aggregate SOC and top-cell voltage can reach their ceiling while later packs are still charging. Discover and poll populated pack SOC registers, use the least-full pack for charge completion and BMS-cutoff arming, and the fullest pack for discharge limits while preserving aggregate behavior when pack telemetry is absent.

Correct the Venus A/D aggregate SOC and stride-100 per-pack cell layout in registers.md, add translated diagnostic sensors, and cover pack discovery and control behavior with regression tests.
The 28-day quarter-hour profile was discarded whenever its configuration
fingerprint moved. Two ways that hurt:

- The fingerprint hashed the raw excluded-device fields, so the options
  form rewriting its own defaults (`ev_charger_no_telemetry` absent vs
  `false`) read as a source change. Walking the excluded-devices section
  without touching anything wiped a month of capture, and only what
  Recorder still held came back.
- Even a real source change discarded everything, which costs far more
  than the small bias of mixing derivations.

Learned days now survive every integration setting. A source change only
breaks sample continuity — so the first reading under the new sources is
a baseline, not a trapezoid spanning both — and starts a backfill of what
is missing. The fingerprint stays, normalized by meaning rather than key
presence, purely to detect that.

A timezone change no longer discards the days either: `rebin_days_to_timezone`
re-expresses each stored bin in the new zone through the same
`_local_segments` splitter the capture path uses, carrying energy across
the local-date boundary. The two edge days keep only part of their hours
and are left incomplete on purpose, so Recorder backfill re-fetches them.
An autumn repeated hour was already merged into one bin by capture, so it
moves an hour early once a year; only a stored zone name that no longer
exists still forces a fresh learn.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two independent gates kept the learned quarter-hour curve unusable.

Maturity demanded two samples of the *same* weekday for 75% of the
requested intervals, which a seven-day history cannot hold: the curve
took a fortnight to appear and then vanished on whichever weekday held a
single sample. Matching weekday/weekend samples now satisfy the gate,
which the per-interval blend already fell back to.

Learning itself was gated on the runtime switches: the 23:55 snapshot
and the Recorder backfill of the consumption, legacy and solar histories
all checked predictive_charging_enabled, so a user who enabled the
feature later started from DEFAULT_BASE_CONSUMPTION_KWH sentinels with
an unseeded profile. The caller already gates by configured presence;
the inner value gates are gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Days recorded at a wrong scale held the relative-energy median so high
that every correct day after them was rejected. That also hid the new
scale from the capacity-regime detector meant to retire the old one,
which only looks at days that pass the very gate the old days were
failing, so the profile expired as stale and could never heal itself.

The relative-energy reference is now the newest days only, so a changed
scale ages out. The regime change then carries the whole trailing run of
days already matching the new peak into the new generation and marks
only the boundary day as a transition: marking all three confirming days
excluded their bins and left the new generation with no shape at all.

Solar diagnostics gain a per-day admission verdict, so a rejected day
names its own reason instead of only showing up as a lower count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The SOC setpoint is a sub-feature of Charge Delay: is_charge_delayed()
returns early and refresh_setpoint_blocks() clears its blocks when the
delay switch is off. The Daily Operation timeline instead read
_delay_soc_setpoint_enabled on its own, so an install with the delay off
and "minimum SOC before delay" still on painted CONTEXT_SETPOINT (the
mdi:target mark) on the live cell, on the projected cells and in the
published setpoint metadata.

Gate it once in _daily_operation_setpoint_enabled(), used by all three.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Resolve the .gitignore conflict by preserving local Codex directory exclusions and upstream generated-report exclusions.
Document Huawei SUN2000 and LUNA2000 setup in English and Spanish, including Modbus proxy and control-path requirements.

Add Huawei to the site navigation, supported-hardware listings, installation requirements, and README.
A battery that did not answer during setup raised ConfigEntryNotReady, which
takes the whole config entry down: every other battery, the controller and the
dashboard disappeared with it, and Home Assistant retried setup in a loop for
as long as the device stayed off. A battery left on its side switch was enough.

The unreachable battery is now set up unreachable instead. It is created with
an empty telemetry snapshot and a seeded failure counter, so it shows up in
non_responsive_battery_names, the non_responsive_batteries sensor and
diagnostics from the first moment rather than looking healthy.

Adopting it later cannot be done from the coordinator alone: the hardware
configuration write, the driver's connect-time entity definitions (model
detection, pack discovery) and the first telemetry read all happen in setup,
and entities cannot be added to a platform that already finished setting up.
The coordinator therefore schedules one entry reload the first time the battery
answers, from either recovery path (a fresh reconnection or a plain successful
poll). The flag is one-shot; the reloaded runtime re-arms it only if the
battery is unreachable again.

Two adjacent failure modes come with it:

- A driver whose connect() raises rather than returning False (huawei reads
  telemetry, esphome resolves entities, zendure/sessy do aiohttp work) escaped
  the setup try block and left the entry in ERROR with no retry. It is now
  treated as "not there", like a refused connection.
- The interrupted-active-balance migration hands off through the hardware. On
  an unreachable battery that path fails and latches manual mode into the entry
  data, freezing the battery once it returns. It now waits for the reload.
Feature PRs must link an approved discussion or issue; bug fixes need no
prior approval.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix: keep the system up when a battery is switched off at start-up
…ry-setup"

This reverts commit 9206f6f, reversing
changes made to 73ee0a83f8f5d4c7b0e3e0a5b8e8f0d3f4e5a6b7.

Not a rejection of the change: it targeted main, but feature work lands on
the release branch here. It now lives on release/v1.5.0 (9979a81) together
with its follow-up fixes, and main picks both up with the release merge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…arging power was used, which resulted in unrealistically short charging durations and caused the charging process to start too late.

With this change, the forecasted solar yield is now used to determine the charging duration.
@ffunes
ffunes changed the base branch from main to release/v1.5.0 September 19, 2026 06:33
@ffunes

ffunes commented Sep 21, 2026

Copy link
Copy Markdown
Owner

Thanks for digging into this — the diagnosis in the first commit is real: charge_time_h = energy_needed / (P_max × CHARGE_EFFICIENCY) assumes rated power, which is nothing like the pace of solar-following PD. But I can't merge this as it stands, and I'd like the two commits split.

I checked both commits in a worktree against the test suite (64 tests green on your branch).

1. The branch needs a rebase

The PR is opened from your fork's main, which is 161 commits behind release/v1.5.0. The diff therefore shows 47 files / 364 deletions and would revert v1.5.0 work (including manifest.json back to 1.4.0). Please rebase onto release/v1.5.0 — the diff should touch 2 files.

2. "Unify the calculation of charging efficiency" — please drop this commit

CHARGE_EFFICIENCY is not an AC-to-DC energy ratio. const/integration_const.py:558-564 documents it as a power derate used to size a charging window, and explicitly says it is distinct from the AC-to-AC energy ratio — which already exists as DEFAULT_ROUND_TRIP_EFFICIENCY, and is user-tunable.

DELAY_SAFETY_FACTOR = 1.3 is the empirical cushion that already absorbs conversion loss. Dividing by 0.85 on top makes the effective factor 1.53, and that is a behaviour change for every user, not a refactor. Measured on a sunny day (forecast 12 kWh, 5 kWh needed, net 7.42 kWh, default 1 h margin):

hour before with this commit
07:00 delay held (est. 10:06) unlocks (energy_balance)

The delay breaks at dawn on a day the sun would have covered easily. The tell is already in the PR: four existing cushion tests had to be retuned (_cushion_mgr(4.85)5.5).

The new test doesn't cover the change either. With the AC conversion disabled, test_energy_balance_gate_accounts_for_charge_efficiency still passes (only the 3 retuned tests fail) — with forecast = needed = 3.0 the pre-change code already fell into the cushion branch and unlocked with energy_balance.

If the forecast looks too optimistic in practice, that's a tuning discussion on DELAY_SAFETY_FACTOR, not a second derate stacked on the first.

3. The time-backup commit is a no-op at the default safety margin

I replaced min(nominal_time_backup_unlock_h, solar_feasible_unlock_h) with nominal_time_backup_unlock_h and all 64 tests still pass, including the new ones. An hour-by-hour sweep with the default 1 h margin produces output identical to the baseline.

The reason: energy_insufficient (net over [now, t_end] vs needed × 1.3) is evaluated first and always fires before the new bare × 1.0 test over [now, deadline], because the last safety-margin hours of the cosine curve deliver far less than 0.3 × needed. It only bites at large margins (with 4 h: unlock at 11:00 instead of 13:00).

So the improvement you saw at home most likely came from the efficiency commit, by accident. test_solar_feasible_unlock_pulls_time_backup_earlier_than_full_power_assumption asserts unlock_h <= nominal, which was already true before the change, since est_unlock_h = min(time_backup_unlock_h, energy_balance_unlock_h).

Two more things on that commit:

  • Missing <= now_h guard. energy_balance_unlock_h is discarded when it lands at or before now_h while the measured balance disagrees (charge_delay.py:781-786). solar_feasible_unlock_h has no such guard and feeds time_limit_reached = now_h >= time_backup_unlock_h directly. _estimate_energy_balance_unlock_h returns now_h as soon as net_solar_at(now_h) < threshold, and the sinusoid can disagree with the measured remaining_solar_kwh (forecast − produced_today), so this is a path to an immediate same-day unlock with reason time_backup.
  • Edge case: horizon = max(t_start, min(horizon_h, t_end)) collapses to t_start when t_end − margin <= t_start (short winter day, large margin) — net goes negative and the delay unlocks on the spot.

Also, status["energy_needed_kwh"] stays battery-side while every comparison moves to AC, so the sensor attributes and net_solar_kwh no longer explain the decision shown on the dashboard.

What I'd need to merge

  1. Rebase onto release/v1.5.0.
  2. Drop the efficiency commit.
  3. Keep the time-backup commit with: a test that fails without the min(), the <= now_h guard, and a real-world trace of the case that motivated the PR — because at the default margin this change currently does nothing.

Minor: the new comment blocks run ~20 lines for 6 lines of code, well above the density of the rest of the file. Happy to keep the reasoning, just shorter.

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.

4 participants