Skip to content

Commit f9274c6

Browse files
committed
Fix production curve overshoot on coarse meters and flicker on low-end GPUs
Past-production curve: the recorder energy of each report is now spread across the real interval since the previous report, capped, so a meter that reports its energy less often (or unevenly) than the graph bucket can no longer land a whole interval's energy in one short slice and plot an average power above what the array can produce. Energy-conserving, so totals still match the Energy dashboard. Reported in discussion #371. Rendering: detect a GPU whose max texture size is smaller than the ground canvas edge and switch that device to the projected compat ground path (a card-sized canvas, no oversized CSS 3D layer), the same remedy as the old half-3D iPad fix, keeping the 2.5D look. This clears the whole-view flicker and black kiosk screenshots on entry-level tablets, automatically and per-device (no shared card option). Also drop mix-blend-mode from the storm flash. Fixes #370.
1 parent dbe2f52 commit f9274c6

7 files changed

Lines changed: 96 additions & 42 deletions

File tree

CHANGELOG.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ and the project follows a date-based versioning scheme (`YEAR.MONTH.PATCH`).
1111

1212
The weather release, "Your real sky": the scene now reflects the weather over
1313
your home, and the outdoor temperature and humidity join the card. Currently in
14-
beta (`2026.9.0-b0`).
14+
beta (`2026.9.0-b1`).
1515

1616
### Added: the terrain horizon
1717

@@ -122,6 +122,27 @@ anchored to a fixed reference regardless of where you view the card from, so the
122122
curve always aligns with the moment it belongs to. Thanks to @m1chaelmichael for
123123
the precise report (#366).
124124

125+
### Fixed: the production curve could read above your real power
126+
127+
On installations whose production meter reports its energy less often than the
128+
graph's detail (or at an uneven pace), the past-production curve could briefly
129+
climb **above the real power your panels can produce** — a 2.5 kW-capped array
130+
plotting more than 2.5 kW for a moment. The curve is reconstructed from the
131+
Energy dashboard's energy, and a long report landing in a short slice made the
132+
computed average too high. Helios now spreads each reading across the interval it
133+
actually covers, so the curve never resolves finer than your meter reports and
134+
can no longer overshoot — while the totals still match the Energy dashboard
135+
exactly. Thanks to @ritonbrunis-lab for the clear screenshots (#371).
136+
137+
### Fixed: flicker and black screen on some entry-level tablets
138+
139+
On a few low-end wall tablets the whole view could shimmer or recompose between
140+
frames, and a kiosk screenshot came back black. The cause was the same one behind
141+
the old half-3D iPad issue: the device's graphics chip could not hold the scene's
142+
ground as a single layer. Helios now detects that limit and switches those devices
143+
to a lighter drawing path automatically — no setting to touch, and the 2.5D look
144+
is kept. Thanks to @Richaaldo and @charleslales for the detailed reports (#370).
145+
125146
---
126147

127148
## 2026.8.3

dist/helios.js

Lines changed: 13 additions & 11 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "helios",
3-
"version": "2026.9.0-b0",
3+
"version": "2026.9.0-b1",
44
"description": "HELIOS - real-time solar exposure, cloud cover and PV production card for Home Assistant",
55
"license": "GPL-3.0-or-later",
66
"type": "module",

src/core/config/constants.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,11 +101,10 @@ export const OTHER_ERROR_BACKOFF_MS: readonly number[] = [1 * 60_000, 5 * 60_000
101101
export const CHANGE_REFRESH_MS = 60_000;
102102
export const COARSE_PROBE_MS = 15 * 60_000;
103103
export const DENSE_FRACTION = 0.6;
104-
//Store-curve coarse-meter smoothing: the largest report cadence (in store buckets) still spread back into a
105-
//smooth curve, and the fraction of gaps that must sit at that cadence to treat a meter as regularly coarse
106-
//(below it, the non-zero buckets read as genuine intermittent flow and are left untouched).
104+
//Store-curve coarse-meter smoothing: the largest report interval (in store buckets) a single report is spread back
105+
//across. Past it, the buckets before a report keep their zero, so a long genuine gap (an overnight lull before the
106+
//first daytime reading) is never smeared into the reading that follows it.
107107
export const COARSE_MAX_SPREAD_BUCKETS = 6;
108-
export const COARSE_REGULARITY = 0.6;
109108

110109

111110
//=== Grid mis-scope guard ===

src/css/helios-card-scene-css.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -958,12 +958,14 @@ export const heliosCardStyles = css`
958958
}
959959
.helios-corner-chip.is-curve-on ha-icon { color: var(--ha-card-background, var(--card-background-color, #fff)); }
960960
961-
/* THUNDERSTORM lightning: a top-weighted blue-white flash pushed by the storm controller via --wx-flash. */
961+
/* THUNDERSTORM lightning: a top-weighted blue-white flash pushed by the storm controller via --wx-flash.
962+
Plain opacity compositing (no mix-blend-mode): screen-blend forces a stacking-context blend group that some
963+
Android WebViews flicker/recompose the whole view under, and the near-white flash reads almost the same
964+
through straight opacity. */
962965
.helios-wx-flash
963966
{
964967
z-index: 7;
965968
opacity: var(--wx-flash, 0);
966-
mix-blend-mode: screen;
967969
background:
968970
radial-gradient(120% 80% at 50% -10%, rgba(226, 236, 255, 0.9), rgba(200, 216, 255, 0.35) 40%, transparent 75%);
969971
}

src/data/sources/energy-stats.ts

Lines changed: 20 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
//unit conversion (`units: { energy: 'kWh' }` normalises Wh/kWh/MWh server-side). The only math here is
88
//kWh-per-bucket / bucket-duration = average watts, so where HA has a number the card shows the same number.
99

10-
import { CHANGE_REFRESH_MS, COARSE_PROBE_MS, DENSE_FRACTION, COARSE_MAX_SPREAD_BUCKETS, COARSE_REGULARITY, HOUR_MS, DAY_MS } from '../../core/config/constants';
10+
import { CHANGE_REFRESH_MS, COARSE_PROBE_MS, DENSE_FRACTION, COARSE_MAX_SPREAD_BUCKETS, HOUR_MS, DAY_MS } from '../../core/config/constants';
1111
import { callWS } from '../ha-gateway';
1212
import { RequestCache } from '../request-cache';
1313
import { loadDurable, saveDurable } from '../durable-cache';
@@ -187,36 +187,33 @@ export function outlierCapKwh(buckets: ChangeBucket[] | null): number
187187
return mags[idx] * OUTLIER_CAP_FACTOR;
188188
}
189189

190-
//Coarse-meter smoothing on the binned per-bucket energy (sums/hit). A meter that reports its cumulative energy
191-
//less often than a store bucket lands each delta in one bucket and leaves 0 in the buckets between reports, which
192-
//draws a sawtooth (production looks spiky while a dense grid meter stays smooth). When the non-zero buckets are
193-
//REGULARLY spaced (a real report cadence, not intermittent flow), spread each delta back over its report interval,
194-
//capped so a night-long gap before the first daytime reading is never smeared. Energy-conserving: the sum over each
195-
//interval is unchanged, so totals still match the Energy dashboard. No-op for dense meters (cadence 1) and for
196-
//irregular series (export that only flows sometimes).
190+
//Floor the reconstructed power to the meter's real report cadence, on the binned per-bucket energy (sums/hit). A
191+
//meter that reports its cumulative energy less often than a store bucket lands each delta in one bucket and leaves 0
192+
//in the buckets between reports; dividing that lump by the (shorter) bucket then reads an average power ABOVE what
193+
//the site actually produced (a 2.5 kW-capped array can plot >2.5 kW), and draws a sawtooth. So spread each report
194+
//back over the interval since the previous report, its true accumulation window: no bucket can then hold more than
195+
//one interval's energy, so no bucket over-reads. Capped so a long genuine gap (overnight before the first daytime
196+
//reading) is never smeared. Energy-conserving (each report's total is unchanged, so totals still match the Energy
197+
//dashboard) and inert for dense meters (consecutive reports are one bucket apart, so span 1 leaves the value as-is).
197198
function smoothCoarseReports(sums: number[], hit: boolean[]): void
198199
{
199200
const reports: number[] = [];
200201
for (let i = 0; i < sums.length; i++)
201202
{
202203
if (hit[i] && sums[i] !== 0) { reports.push(i); }
203204
}
204-
if (reports.length < 3) { return; }
205-
const gaps: number[] = [];
206-
for (let k = 1; k < reports.length; k++) { gaps.push(reports[k] - reports[k - 1]); }
207-
const cadence = [...gaps].sort((a, b) => a - b)[Math.floor(gaps.length / 2)]; //median gap
208-
if (cadence <= 1 || cadence > COARSE_MAX_SPREAD_BUCKETS) { return; }
209-
const regular = gaps.filter(g => Math.abs(g - cadence) <= 1).length / gaps.length;
210-
if (regular < COARSE_REGULARITY) { return; }
211-
//Spread each report back over its own interval (capped at the cadence), evenly. Left to right; a report's range
212-
//never reaches the previous report (span <= cadence <= gap), so the in-place writes never collide, and the
213-
//genuine-gap buckets before a capped range keep their zero.
214-
let prev = -1;
215-
for (const i of reports)
205+
//Need two reports to know an interval; a lone spike (fully intermittent flow) is left untouched.
206+
if (reports.length < 2) { return; }
207+
//The first report keeps its own bucket (its accumulation start is unknown, so it is never spread backward);
208+
//every later report spreads over the capped gap to its predecessor. span <= gap means a report's range never
209+
//reaches the previous report, so the in-place writes never collide and genuine-gap buckets keep their zero.
210+
let prev = reports[0];
211+
for (let r = 1; r < reports.length; r++)
216212
{
217-
const span = prev < 0 ? cadence : Math.min(i - prev, cadence);
218-
const start = Math.max(0, i - span + 1);
219-
const share = sums[i] / (i - start + 1);
213+
const i = reports[r];
214+
const span = Math.min(i - prev, COARSE_MAX_SPREAD_BUCKETS);
215+
const start = i - span + 1;
216+
const share = sums[i] / span;
220217
for (let j = start; j <= i; j++)
221218
{
222219
sums[j] = share;

src/scene/renderer.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,33 @@ import {
1616
GROWTH_RISE_MS,
1717
HOME_SQUASH_MS,
1818
HOME_GROW_MS,
19+
GROUND_RADIUS,
20+
TILE_PX,
1921
} from '../core/config/constants';
2022

23+
//Edge of the square basemap canvas (px): (2*radius+1) tiles across. On the normal path this canvas is CSS
24+
//3D-transformed, so the compositor backs it as one layer of this size.
25+
const GROUND_CANVAS_EDGE_PX = (2 * GROUND_RADIUS + 1) * TILE_PX;
26+
27+
//The GPU's max texture edge (px), or 0 when it can't be read. A throwaway WebGL context, released at once.
28+
function gpuMaxTextureSize(): number
29+
{
30+
if (typeof document === 'undefined') { return 0; }
31+
try
32+
{
33+
const canvas = document.createElement('canvas');
34+
const gl = (canvas.getContext('webgl') ?? canvas.getContext('experimental-webgl')) as WebGLRenderingContext | null;
35+
if (!gl) { return 0; }
36+
const max = gl.getParameter(gl.MAX_TEXTURE_SIZE) as unknown;
37+
gl.getExtension('WEBGL_lose_context')?.loseContext();
38+
return typeof max === 'number' && Number.isFinite(max) ? max : 0;
39+
}
40+
catch
41+
{
42+
return 0;
43+
}
44+
}
45+
2146
//Old iOS/iPadOS WebKit half-composites a flat layer over a CSS 3D-transformed one, clipping the whole scene to
2247
//its top half. Those devices render the ground on the projected compat path instead of a 3D
2348
//transform. It cannot be feature-detected (no API reads composited pixels), so we sniff: an Apple touch device
@@ -26,6 +51,14 @@ import {
2651
//positive only swaps in the near-equivalent compat render, so erring is cheap.
2752
function needsProjectedGround(): boolean
2853
{
54+
//Platform-agnostic capability gate, checked first: the normal path composites the whole basemap canvas as one
55+
//CSS 3D-transformed layer. A GPU whose max texture edge is smaller than that canvas cannot back the layer and
56+
//drops it to black / flickers the whole view (the entry-level Android wall tablets that flicker under a
57+
//2048-cap GPU with a 2816 px canvas). The projected compat path paints a card-sized canvas instead, so it clears the
58+
//cap while keeping the 2.5D look. A GPU that can't be read (maxTex 0) falls through to the Apple sniff below.
59+
const maxTex = gpuMaxTextureSize();
60+
if (maxTex > 0 && maxTex < GROUND_CANVAS_EDGE_PX) { return true; }
61+
2962
if (typeof navigator === 'undefined') { return false; }
3063
const ua = navigator.userAgent || '';
3164
const appleTouch = /iPad|iPhone|iPod/.test(ua)

0 commit comments

Comments
 (0)