Skip to content

Commit acd1b1d

Browse files
Console cards: real hourly min/max on 7d/14d charts, drawn as a band
Past 36 h the inverter and battery history charts read hourly long-term statistics but asked only for the mean, so Max was the busiest hour's average (7d load: 435 W against a real 8132 W). Ask for min and max too, shade each hour's min-max band behind the mean line so the plot and the stats row agree, scale the axis to the band, and count Min/Max only from rows that cover the window. Hover shows the hour's range. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent c7c3745 commit acd1b1d

4 files changed

Lines changed: 179 additions & 38 deletions

File tree

‎HomeAssistant/config/www/jkbms-battery-console-card.js‎

Lines changed: 79 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ const CARD = "jkbms-battery-console-card";
9090
import { cardTip } from "./card-tip.js?v=1.0.0";
9191
import { ramp, rampCss } from "./card-ramp.js?v=1.0.0";
9292

93-
const VERSION = "1.6.1";
93+
const VERSION = "1.6.3";
9494

9595
/*
9696
* The design's palette, literal. Names are what the design calls them: two
@@ -324,7 +324,7 @@ const HELP = {
324324
+ "Charging a LiFePO4 pack below 0 °C damages it.",
325325
range: "Sets the chart's window, and brings a dragged or zoomed chart back to live.\n"
326326
+ "E.g. 24h shows the night charge as a climb from 23:00 to 07:00.\n"
327-
+ "Windows starting over 36 h back are drawn from hourly statistics.",
327+
+ "Past 36 h: hourly means, each hour's min–max shaded around them.",
328328
chart: "The series on the chart below; tap the name to open its entity.\n"
329329
+ "E.g. drag the chart back to 23:00, scroll or pinch to zoom, double-click for live.",
330330
s_soc: "Charts the BMS state of charge, in its band colour.\n"
@@ -2378,7 +2378,7 @@ class JkbmsBatteryConsoleCard extends HTMLElement {
23782378
end_time: new Date(end).toISOString(),
23792379
statistic_ids: [entity],
23802380
period: "hour",
2381-
types: ["mean"],
2381+
types: ["mean", "min", "max"],
23822382
});
23832383
raw = this._parseStats(stats, entity, spec);
23842384
fromStats = raw.length > 0;
@@ -2435,6 +2435,12 @@ class JkbmsBatteryConsoleCard extends HTMLElement {
24352435
* Hourly means from recorder/statistics_during_period, in the [ms, value]
24362436
* shape _parse() produces. Plotted at the start of each hour, as the History
24372437
* panel does; `start` is epoch ms on current releases, ISO on older ones.
2438+
*
2439+
* Each row also carries the hour's own min and max as [ms, mean, min, max].
2440+
* Only the mean is drawn, but Min/Max in the stats row read these: the
2441+
* highest hourly MEAN of a week of house load is a few hundred watts, while
2442+
* the kettle that ran inside that hour drew 1.8 kW. Reporting the former as
2443+
* "Max" made every 7d/14d window look calmer than any 24h one inside it.
24382444
*/
24392445
_parseStats(reply, entity, spec) {
24402446
const rows = reply && reply[entity];
@@ -2445,7 +2451,15 @@ class JkbmsBatteryConsoleCard extends HTMLElement {
24452451
const v = parseFloat(r.mean);
24462452
if (!Number.isFinite(v)) continue;
24472453
const t = typeof r.start === "number" ? r.start : Date.parse(r.start);
2448-
if (Number.isFinite(t)) out.push([t, this._scale(spec, v)]);
2454+
if (!Number.isFinite(t)) continue;
2455+
const row = [t, this._scale(spec, v)];
2456+
const lo = parseFloat(r.min), hi = parseFloat(r.max);
2457+
if (Number.isFinite(lo) && Number.isFinite(hi)) {
2458+
// A negative multiplier swaps which end is which.
2459+
const a = this._scale(spec, lo), b = this._scale(spec, hi);
2460+
row.push(Math.min(a, b), Math.max(a, b));
2461+
}
2462+
out.push(row);
24492463
}
24502464
out.sort((a, b) => a[0] - b[0]);
24512465
return out;
@@ -2509,6 +2523,29 @@ class JkbmsBatteryConsoleCard extends HTMLElement {
25092523
return span > 0 ? acc / span : pts[pts.length - 1][1];
25102524
}
25112525

2526+
/**
2527+
* The window's true Min and Max, from the rows that cover it. A raw row
2528+
* holds its value until the next one, so the row just before the window
2529+
* counts and the one just after does not. An hourly statistics row counts
2530+
* when its hour overlaps the window, with the hour's own min and max.
2531+
*/
2532+
_extremes(pts, p, t0, t1) {
2533+
let min = Infinity, max = -Infinity;
2534+
pts.forEach((q, i) => {
2535+
if (q.length >= 4) {
2536+
if (q[0] + 3600000 <= t0 || q[0] >= t1) return;
2537+
if (q[2] < min) min = q[2];
2538+
if (q[3] > max) max = q[3];
2539+
return;
2540+
}
2541+
const next = pts[i + 1];
2542+
if (q[0] > t1 || (next && next[0] <= t0)) return;
2543+
if (q[1] < min) min = q[1];
2544+
if (q[1] > max) max = q[1];
2545+
});
2546+
return min <= max ? { min, max } : { min: p.min, max: p.max };
2547+
}
2548+
25122549
/**
25132550
* Recorder samples, thinned to what the plot can resolve -- KEEPING EVERY
25142551
* EXTREME.
@@ -2579,8 +2616,13 @@ class JkbmsBatteryConsoleCard extends HTMLElement {
25792616
* reading against the time axis under it.
25802617
*/
25812618
_path(pts, w, h, pad, t0, t1) {
2619+
// An hourly statistics row is [t, mean, min, max]; its band is part of
2620+
// what is drawn, so it is part of what the axis has to hold.
2621+
const ranged = pts.some((q) => q.length >= 4);
2622+
const bot = (q) => (q.length >= 4 ? q[2] : q[1]);
2623+
const top = (q) => (q.length >= 4 ? q[3] : q[1]);
25822624
let min = Infinity, max = -Infinity;
2583-
for (const q of pts) { if (q[1] < min) min = q[1]; if (q[1] > max) max = q[1]; }
2625+
for (const q of pts) { if (bot(q) < min) min = bot(q); if (top(q) > max) max = top(q); }
25842626
let lo = min, hi = max;
25852627

25862628
/*
@@ -2595,7 +2637,12 @@ class JkbmsBatteryConsoleCard extends HTMLElement {
25952637
* Max, the window label counts what went off-scale, and hovering the spike
25962638
* gives its exact recorded value.
25972639
*/
2598-
const sorted = pts.map((q) => q[1]).sort((a, b) => a - b);
2640+
const vals = [];
2641+
for (const q of pts) {
2642+
vals.push(q[1]);
2643+
if (q.length >= 4) vals.push(q[2], q[3]);
2644+
}
2645+
const sorted = vals.sort((a, b) => a - b);
25992646
const at = (f) => sorted[Math.min(sorted.length - 1,
26002647
Math.max(0, Math.round(f * (sorted.length - 1))))];
26012648
const band = at(0.99) - at(0.01);
@@ -2609,19 +2656,27 @@ class JkbmsBatteryConsoleCard extends HTMLElement {
26092656
if (hi - lo < 1e-9) { hi += 0.5; lo -= 0.5; }
26102657
const span = hi - lo;
26112658
const tspan = (t1 - t0) || 1;
2612-
const xy = pts.map(([t, v]) => [
2613-
Math.max(0, Math.min(w, (t - t0) / tspan * w)),
2614-
h - pad - (v - lo) / span * (h - pad * 2),
2615-
]);
2659+
const X = (t) => Math.max(0, Math.min(w, (t - t0) / tspan * w));
2660+
const Y = (v) => h - pad - (v - lo) / span * (h - pad * 2);
2661+
const xy = pts.map(([t, v]) => [X(t), Y(v)]);
26162662
const line = xy.map((q, i) => (i ? "L" : "M") + q[0].toFixed(1) + " " + q[1].toFixed(1)).join(" ");
26172663
// Close the area under the data it actually has, not the whole frame.
2618-
const area = xy.length
2619-
? line + " L" + xy[xy.length - 1][0].toFixed(1) + " " + h
2620-
+ " L" + xy[0][0].toFixed(1) + " " + h + " Z"
2621-
: "";
2664+
// Hourly statistics shade each hour's min-max band instead: the line is
2665+
// only the hours' means, and without the band the stats row's Min/Max
2666+
// (the hours' real extremes) would sit far outside anything drawn.
2667+
let area = "";
2668+
if (ranged) {
2669+
const f = (q) => X(q[0]).toFixed(1) + " ";
2670+
area = pts.map((q, i) => (i ? "L" : "M") + f(q) + Y(top(q)).toFixed(1)).join(" ")
2671+
+ " " + pts.slice().reverse().map((q) => "L" + f(q) + Y(bot(q)).toFixed(1)).join(" ")
2672+
+ " Z";
2673+
} else if (xy.length) {
2674+
area = line + " L" + xy[xy.length - 1][0].toFixed(1) + " " + h
2675+
+ " L" + xy[0][0].toFixed(1) + " " + h + " Z";
2676+
}
26222677
let off = 0;
2623-
for (const q of pts) if (q[1] < lo || q[1] > hi) off++;
2624-
return { line, area, lo, hi, min, max, off, xy };
2678+
for (const q of pts) if (bot(q) < lo || top(q) > hi) off++;
2679+
return { line, area, ranged, lo, hi, min, max, off, xy };
26252680
}
26262681

26272682
/**
@@ -2769,6 +2824,7 @@ class JkbmsBatteryConsoleCard extends HTMLElement {
27692824
const p = this._path(pts, CH_W, CH_H, CH_PAD, d.t0, d.t1);
27702825
el.chLine.setAttribute("d", p.line);
27712826
el.chArea.setAttribute("d", p.area);
2827+
el.chArea.setAttribute("fill-opacity", p.ranged ? "0.22" : "0.10");
27722828
this._hv = { spec: spec, span: d.t1 - d.t0, pts: pts, xy: p.xy, col: col };
27732829

27742830
const span = p.hi - p.lo;
@@ -2788,9 +2844,10 @@ class JkbmsBatteryConsoleCard extends HTMLElement {
27882844
const raw = this._num(this._config[spec.cfg]);
27892845
const now = raw === null ? null : this._scale(spec, raw);
27902846
const mean = this._mean(pts);
2847+
const ext = this._extremes(pts, p, d.t0, d.t1);
27912848
el.stats.innerHTML = [
27922849
["Now", now === null ? pts[pts.length - 1][1] : now],
2793-
["Min", p.min], ["Max", p.max], ["Mean", mean],
2850+
["Min", ext.min], ["Max", ext.max], ["Mean", mean],
27942851
].map(([label, v]) => {
27952852
const val = v.toFixed(spec.dec) + " " + spec.unit;
27962853
const tip = this._tip(label + " — " + val, "stat_" + label.toLowerCase());
@@ -2856,7 +2913,11 @@ class JkbmsBatteryConsoleCard extends HTMLElement {
28562913
el.hvDot.classList.add("on");
28572914

28582915
el.tip.innerHTML = "<b>" + this._esc(hv.pts[best][1].toFixed(hv.spec.dec) + " " + hv.spec.unit)
2859-
+ "</b><i>" + this._esc(this._stamp(hv.pts[best][0], hv.span)) + "</i>";
2916+
+ "</b><i>" + this._esc(this._stamp(hv.pts[best][0], hv.span)) + "</i>"
2917+
+ (hv.pts[best].length >= 4
2918+
? "<i>hour " + this._esc(hv.pts[best][2].toFixed(hv.spec.dec) + "–"
2919+
+ hv.pts[best][3].toFixed(hv.spec.dec) + " " + hv.spec.unit) + "</i>"
2920+
: "");
28602921
el.tip.style.color = hv.col;
28612922
// Keep the bubble inside the plot at both edges.
28622923
el.tip.style.left = Math.max(52, Math.min(rect.width - 52, px)) + "px";

‎HomeAssistant/config/www/powmr-inverter-console-card.js‎

Lines changed: 75 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ const CARD = "powmr-inverter-console-card";
7676
import { cardTip } from "./card-tip.js?v=1.0.0";
7777
import { ramp, rampCss } from "./card-ramp.js?v=1.0.0";
7878

79-
const VERSION = "2.11.2";
79+
const VERSION = "2.11.4";
8080

8181
/*
8282
* Brand colours stay literal: they identify a leg of the diagram (amber =
@@ -285,7 +285,7 @@ const HELP = {
285285
// History
286286
range: "Shows this span on the chart, ending now, and keeps it live as readings arrive."
287287
+ "\nE.g. 24h on SOC shows the night charge as the 23:00–07:00 climb."
288-
+ "\nPast 36 h (7d, 14d) it draws hourly statistics, not every reading.",
288+
+ "\nPast 36 h (7d, 14d): hourly means, each hour's min–max shaded around them.",
289289
tab: "Picks the series the chart draws; one with bands takes its tile's colour."
290290
+ "\nE.g. Grid voltage at 243 V draws the line amber, like the Grid tile's HIGH.",
291291
ch_name: "The series on the chart: drag to pan, pinch or scroll to zoom, double-click for live."
@@ -2221,7 +2221,7 @@ ${ch.dots ? "" : `
22212221
end_time: new Date(end).toISOString(),
22222222
statistic_ids: [entity],
22232223
period: "hour",
2224-
types: ["mean"],
2224+
types: ["mean", "min", "max"],
22252225
});
22262226
raw = this._parseStats(stats, entity);
22272227
fromStats = raw.length > 0;
@@ -2278,6 +2278,12 @@ ${ch.dots ? "" : `
22782278
* Hourly means from recorder/statistics_during_period, in the [ms, value]
22792279
* shape _parse() produces. Plotted at the start of each hour, as the History
22802280
* panel does; `start` is epoch ms on current releases, ISO on older ones.
2281+
*
2282+
* Each row also carries the hour's own min and max as [ms, mean, min, max].
2283+
* Only the mean is drawn, but Min/Max in the stats row read these: the
2284+
* highest hourly MEAN of a week of house load is a few hundred watts, while
2285+
* the kettle that ran inside that hour drew 1.8 kW. Reporting the former as
2286+
* "Max" made every 7d/14d window look calmer than any 24h one inside it.
22812287
*/
22822288
_parseStats(reply, entity) {
22832289
const rows = reply && reply[entity];
@@ -2288,7 +2294,11 @@ ${ch.dots ? "" : `
22882294
const v = parseFloat(r.mean);
22892295
if (!Number.isFinite(v)) continue;
22902296
const t = typeof r.start === "number" ? r.start : Date.parse(r.start);
2291-
if (Number.isFinite(t)) out.push([t, v]);
2297+
if (!Number.isFinite(t)) continue;
2298+
const row = [t, v];
2299+
const lo = parseFloat(r.min), hi = parseFloat(r.max);
2300+
if (Number.isFinite(lo) && Number.isFinite(hi)) row.push(lo, hi);
2301+
out.push(row);
22922302
}
22932303
out.sort((a, b) => a[0] - b[0]);
22942304
return out;
@@ -2352,6 +2362,29 @@ ${ch.dots ? "" : `
23522362
return span > 0 ? acc / span : pts[pts.length - 1][1];
23532363
}
23542364

2365+
/**
2366+
* The window's true Min and Max, from the rows that cover it. A raw row
2367+
* holds its value until the next one, so the row just before the window
2368+
* counts and the one just after does not. An hourly statistics row counts
2369+
* when its hour overlaps the window, with the hour's own min and max.
2370+
*/
2371+
_extremes(pts, p, t0, t1) {
2372+
let min = Infinity, max = -Infinity;
2373+
pts.forEach((q, i) => {
2374+
if (q.length >= 4) {
2375+
if (q[0] + 3600000 <= t0 || q[0] >= t1) return;
2376+
if (q[2] < min) min = q[2];
2377+
if (q[3] > max) max = q[3];
2378+
return;
2379+
}
2380+
const next = pts[i + 1];
2381+
if (q[0] > t1 || (next && next[0] <= t0)) return;
2382+
if (q[1] < min) min = q[1];
2383+
if (q[1] > max) max = q[1];
2384+
});
2385+
return min <= max ? { min, max } : { min: p.min, max: p.max };
2386+
}
2387+
23552388
/**
23562389
* Recorder samples, thinned to what the plot can resolve -- KEEPING EVERY
23572390
* EXTREME.
@@ -2422,8 +2455,13 @@ ${ch.dots ? "" : `
24222455
* reading against the time axis under it.
24232456
*/
24242457
_path(pts, w, h, pad, t0, t1) {
2458+
// An hourly statistics row is [t, mean, min, max]; its band is part of
2459+
// what is drawn, so it is part of what the axis has to hold.
2460+
const ranged = pts.some((q) => q.length >= 4);
2461+
const bot = (q) => (q.length >= 4 ? q[2] : q[1]);
2462+
const top = (q) => (q.length >= 4 ? q[3] : q[1]);
24252463
let min = Infinity, max = -Infinity;
2426-
for (const q of pts) { if (q[1] < min) min = q[1]; if (q[1] > max) max = q[1]; }
2464+
for (const q of pts) { if (bot(q) < min) min = bot(q); if (top(q) > max) max = top(q); }
24272465
let lo = min, hi = max;
24282466

24292467
/*
@@ -2438,7 +2476,12 @@ ${ch.dots ? "" : `
24382476
* Max, the window label counts what went off-scale, and hovering the spike
24392477
* gives its exact recorded value.
24402478
*/
2441-
const sorted = pts.map((q) => q[1]).sort((a, b) => a - b);
2479+
const vals = [];
2480+
for (const q of pts) {
2481+
vals.push(q[1]);
2482+
if (q.length >= 4) vals.push(q[2], q[3]);
2483+
}
2484+
const sorted = vals.sort((a, b) => a - b);
24422485
const at = (f) => sorted[Math.min(sorted.length - 1,
24432486
Math.max(0, Math.round(f * (sorted.length - 1))))];
24442487
const band = at(0.99) - at(0.01);
@@ -2452,19 +2495,27 @@ ${ch.dots ? "" : `
24522495
if (hi - lo < 1e-9) { hi += 0.5; lo -= 0.5; }
24532496
const span = hi - lo;
24542497
const tspan = (t1 - t0) || 1;
2455-
const xy = pts.map(([t, v]) => [
2456-
Math.max(0, Math.min(w, (t - t0) / tspan * w)),
2457-
h - pad - (v - lo) / span * (h - pad * 2),
2458-
]);
2498+
const X = (t) => Math.max(0, Math.min(w, (t - t0) / tspan * w));
2499+
const Y = (v) => h - pad - (v - lo) / span * (h - pad * 2);
2500+
const xy = pts.map(([t, v]) => [X(t), Y(v)]);
24592501
const line = xy.map((q, i) => (i ? "L" : "M") + q[0].toFixed(1) + " " + q[1].toFixed(1)).join(" ");
24602502
// Close the area under the data it actually has, not the whole frame.
2461-
const area = xy.length
2462-
? line + " L" + xy[xy.length - 1][0].toFixed(1) + " " + h
2463-
+ " L" + xy[0][0].toFixed(1) + " " + h + " Z"
2464-
: "";
2503+
// Hourly statistics shade each hour's min-max band instead: the line is
2504+
// only the hours' means, and without the band the stats row's Min/Max
2505+
// (the hours' real extremes) would sit far outside anything drawn.
2506+
let area = "";
2507+
if (ranged) {
2508+
const f = (q) => X(q[0]).toFixed(1) + " ";
2509+
area = pts.map((q, i) => (i ? "L" : "M") + f(q) + Y(top(q)).toFixed(1)).join(" ")
2510+
+ " " + pts.slice().reverse().map((q) => "L" + f(q) + Y(bot(q)).toFixed(1)).join(" ")
2511+
+ " Z";
2512+
} else if (xy.length) {
2513+
area = line + " L" + xy[xy.length - 1][0].toFixed(1) + " " + h
2514+
+ " L" + xy[0][0].toFixed(1) + " " + h + " Z";
2515+
}
24652516
let off = 0;
2466-
for (const q of pts) if (q[1] < lo || q[1] > hi) off++;
2467-
return { line, area, lo, hi, min, max, off, xy };
2517+
for (const q of pts) if (bot(q) < lo || top(q) > hi) off++;
2518+
return { line, area, ranged, lo, hi, min, max, off, xy };
24682519
}
24692520

24702521
/**
@@ -2613,6 +2664,7 @@ ${ch.dots ? "" : `
26132664
const p = this._path(pts, CH_W, CH_H, CH_PAD, d.t0, d.t1);
26142665
el.chLine.setAttribute("d", p.line);
26152666
el.chArea.setAttribute("d", p.area);
2667+
el.chArea.setAttribute("fill-opacity", p.ranged ? "0.22" : "0.08");
26162668
this._hv = { spec: spec, span: d.t1 - d.t0, pts: pts, xy: p.xy, col: col };
26172669

26182670
const span = p.hi - p.lo;
@@ -2632,9 +2684,10 @@ ${ch.dots ? "" : `
26322684

26332685
const now = this._num(this._config[spec.cfg]);
26342686
const mean = this._mean(pts);
2687+
const ext = this._extremes(pts, p, d.t0, d.t1);
26352688
el.stats.innerHTML = [
26362689
["Now", now === null ? pts[pts.length - 1][1] : now],
2637-
["Min", p.min], ["Max", p.max], ["Mean", mean],
2690+
["Min", ext.min], ["Max", ext.max], ["Mean", mean],
26382691
].map(([label, v]) =>
26392692
"<div class='stat' data-tip=\"" + this._esc(tip(label + " — " + v.toFixed(spec.dec) + " " + spec.unit, "stat"))
26402693
+ "\"><b>" + label + "</b><span>"
@@ -2698,7 +2751,11 @@ ${ch.dots ? "" : `
26982751
el.hvDot.classList.add("on");
26992752

27002753
el.tip.innerHTML = "<b>" + this._esc(hv.pts[best][1].toFixed(hv.spec.dec) + " " + hv.spec.unit)
2701-
+ "</b><i>" + this._esc(this._stamp(hv.pts[best][0], hv.span)) + "</i>";
2754+
+ "</b><i>" + this._esc(this._stamp(hv.pts[best][0], hv.span)) + "</i>"
2755+
+ (hv.pts[best].length >= 4
2756+
? "<i>hour " + this._esc(hv.pts[best][2].toFixed(hv.spec.dec) + "–"
2757+
+ hv.pts[best][3].toFixed(hv.spec.dec) + " " + hv.spec.unit) + "</i>"
2758+
: "");
27022759
el.tip.style.color = hv.col;
27032760
// Keep the bubble inside the plot at both edges.
27042761
el.tip.style.left = Math.max(52, Math.min(rect.width - 52, px)) + "px";

‎HomeAssistant/dashboards/lovelace_resources.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,12 @@
1111
},
1212
{
1313
"id": "504a7eef6aab46e38dae85aab34e7ea7",
14-
"url": "/local/powmr-inverter-console-card.js?v=2.11.1",
14+
"url": "/local/powmr-inverter-console-card.js?v=2.11.4",
1515
"type": "module"
1616
},
1717
{
1818
"id": "7005a1469b8546f2b0f05a4e7ab9c216",
19-
"url": "/local/jkbms-battery-console-card.js?v=1.6.1",
19+
"url": "/local/jkbms-battery-console-card.js?v=1.6.3",
2020
"type": "module"
2121
},
2222
{

0 commit comments

Comments
 (0)