Repository navigation
OpenEMS Bug Report / PR — Negative energy history after restart (cumulated-energy base reset to 0) - #4051
Open
sthhs wants to merge 1 commit into
Open
OpenEMS Bug Report / PR — Negative energy history after restart (cumulated-energy base reset to 0)#4051sthhs wants to merge 1 commit into
sthhs wants to merge 1 commit into
Conversation
…ue query Fixes negative energy values in history after an Edge restart: a transient rrd4j read error was swallowed to Optional.empty() and interpreted as "no history", resetting baseCumulatedEnergy to 0 and breaking monotonicity. - Rrd4jReadHandler.getLatestValue: propagate read errors instead of swallowing - CalculateEnergyFromPower: on give-up keep base undefined and retry (no 0) - Hardening: never lower an established base; warn on negative history delta
Codecov Report✅ All modified and coverable lines are covered by tests.
Additional details and impacted files@@ Coverage Diff @@
## develop #4051 +/- ##
==============================================
- Coverage 55.46% 26.19% -29.26%
==============================================
Files 3931 548 -3383
Lines 180759 16530 -164229
Branches 15077 2916 -12161
==============================================
- Hits 100239 4329 -95910
+ Misses 76550 12013 -64537
+ Partials 3970 188 -3782 🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Title
Timedata: cumulated energy channels reset their base to 0 on restart when the latest-value query fails → negative values in historyLabels
bug,edge,timedataSummary
On an Edge (re)start, every cumulated energy channel (
_sum/*ActiveEnergy, meter/ESS energychannels, …) re-initializes its base value
baseCumulatedEnergyby querying the latest storedvalue from Timedata. If that query fails transiently (DB/I-O error, contention during boot),
the value is silently treated as "no history" and the base is set to
0L. The monotoniccumulated counter then jumps down to ~0 and continues counting up from there.
Because the history calculates a period's energy as
last − first, any period that contains such adownward jump becomes negative (a full day can show e.g. −1,649.5 kWh of "production").
The failure is non-deterministic per channel per restart:
getLatestValue()runs onCompletableFuture.supplyAsync(...)without an explicit executor, i.e. on the sharedForkJoinPool.commonPool(). At boot, dozens of energy channels query rrd4j in parallel; undercontention some reads throw, get swallowed, and only those channels reset — so a different subset
of channels is corrupted on each restart.
Impact
downward step survives in the stored series and keeps poisoning every query that spans it.
CalculateEnergyFromPowerwith the rrd4j Timedata backend.Affected code
io.openems.edge.timedata.rrd4j·Rrd4jReadHandler.getLatestValue(...)— thetry (…rrd4j read…) { } catch (Exception e)swallowsany DB/I-O error into
return Optional.empty();(around line 577 ondevelop).Note: the channel-not-found branch above it already rethrows a
CompletionException— only thedatabase read branch still swallows.
io.openems.edge.timedata.api·CalculateEnergyFromPower.initializeCumulatedEnergyFromTimedata()— on the retry-handler GIVE-UPbranch it does
this.baseCumulatedEnergy = 0L;(around line 172 ondevelop). An empty result(whether genuine or produced by config.json is cleared #1) is likewise mapped to
0L.Steps to reproduce
(
CalculateEnergyFromPower), e.g._sumproduction/consumption energy._sum/ProductionActiveEnergyin the MWhrange).
observe on a system with many energy channels initializing concurrently at boot).
timestamp, and a negative day-value in the history for that day.
Root cause
Two independent weaknesses combine: (1) an error is indistinguishable from "no history", and
(2) "no history" is treated as "start from 0" even for a channel that demonstrably had a large
history a moment earlier.
Fix
Two minimal, independent changes. (A) makes transient read errors propagate into the existing
retry path instead of masquerading as "no data"; (B) makes the ultimate give-up safe by never
destroying a counter to 0.
Both diffs apply cleanly to
develop@79ba3fad.A)
Rrd4jReadHandler.getLatestValue— propagate read errors instead of swallowingCompletionExceptionis already imported in this file, and the channel-not-found branch abovealready uses this exact pattern — this aligns the database-read branch with it.
After this change,
Optional.empty()fromgetLatestValuemeans only genuinely-no-stored-value(truly new channel → base
0is correct there), and a transient failure surfaces as a throwablethat
CalculateEnergyFromPower'sTimedataQueryRetryHandleralready knows how to retry.B)
CalculateEnergyFromPower— do not fall back to 0 on give-upWith (A) in place, the give-up branch is reached only for a persistent failure. Keeping the base
nullmakescalculateEnergy()publishnull(a gap) instead of a destructive0, and returningto
TIMEDATA_QUERY_NOT_STARTEDre-queries on later cycles so the channel self-heals once Timedatais readable again. The extra async query per cycle is negligible and stops the moment it succeeds.
Optional hardening (defense-in-depth, not required for the fix)
The core fix is (A)+(B). The following two changes are complements, not replacements — they add
monotonicity safeguards and observability, and apply cleanly on top of (A)+(B). Note: a pure
in-memory monotonicity guard cannot catch the restart reset, because at fresh startup there is no
trusted in-memory reference — the trusted reference is exactly the read that (A) makes reliable.
C)
CalculateEnergyFromPower— never lower an already-established baseCumulated energy is monotonic by design, so a queried base must never reduce one already known in
this run. Also stop resetting to
0on a type-parse error.D)
Rrd4jReadHandler.queryHistoricEnergy— warn on a negative period (do not hide it)A negative
last − firston a monotonic channel is a definitive corruption indicator. Log it forobservability; do not clamp it to 0 (clamping would silently serve wrong-but-positive numbers).
Why not a
value >= 0check? It would never fire: the stored cumulated value stays>= 0(itis reset to 0 and counts up). The negativity only appears in the derived
last − firstof thehistory, so a non-negative guard on the channel value does not match this failure mode.
Further option: run
getLatestValueon a dedicated executor instead ofForkJoinPool.commonPool()to remove the boot-time contention that triggers the transient failuresin the first place. Reduces how often the retry path is exercised; not necessary for correctness.
Suggested tests
CalculateEnergyFromPower: simulategetLatestValuecompleting exceptionally after the retrywindow → assert the channel value stays
nullandbaseCumulatedEnergyis not set to0,and that a subsequent successful query adopts the stored base.
CalculateEnergyFromPower:getLatestValue→Optional.of(1_000_000L)→ base adopts 1,000,000,not 0.
Rrd4jReadHandler.getLatestValue: a read that throws → the returned future completesexceptionally (not
Optional.empty()); a database with only NaN / no DB →Optional.empty().Generated with Claude Code