From 28c62a190d246837bc5997fbeab3f1973a013def Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 26 Oct 2026 06:09:06 +0100 Subject: [PATCH 1/3] fix(web): the charts show sizes, never a raw minus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FTW's rule is that the UI never shows a raw minus: it shows a size and says the direction in a word or a mark (Fredrik, 26 October 2026). - formatW returns a size. A new flowMark adds ↑ for a power into the home (import, charge) and ↓ for one out of it (export, discharge), as the charts draw them; formatFlowW joins the two. - Live chart: axis labels are sizes, with "↓ export · discharge · solar" under the zero line. The headroom strip below zero in the Energy view has no labels. Tooltip rows for the grid and batteries, and their targets, carry the mark. - 24 h battery chart: both edge labels are sizes, with "↑ charge ↓ discharge" under the chart. - Plan and diagnose power axes are sizes (powerAxisLabel). The plan's legend reads "↑ import / charge ↓ export / discharge / solar", and diagnose writes its two sides beside the zero line. The plan tooltip shows the battery's size with its word. - Readouts that showed a signed power without a word take the mark: the live strip's grid and battery, the battery and grid targets, the grid target slider, V2X send status, driver battery and meter rows, dispatch targets and the plan brief's safety adjustment. No sign the code computes with changes, only the text on screen. Co-Authored-By: Claude Opus 5.5 --- .changeset/no-raw-minus-charts.md | 5 ++ web/app.js | 73 +++++++++++++++++-------- web/diagnose.js | 17 +++++- web/no-raw-minus.test.mjs | 88 +++++++++++++++++++++++++++++++ web/plan-brief.js | 9 ++-- web/plan.js | 21 +++++--- 6 files changed, 180 insertions(+), 33 deletions(-) create mode 100644 .changeset/no-raw-minus-charts.md create mode 100644 web/no-raw-minus.test.mjs diff --git a/.changeset/no-raw-minus-charts.md b/.changeset/no-raw-minus-charts.md new file mode 100644 index 000000000..7a6de2749 --- /dev/null +++ b/.changeset/no-raw-minus-charts.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +The dashboard's charts no longer print a minus sign. Axis labels show sizes, and a short line on each chart says what sits below zero, such as export, discharge and solar. Tooltips and readouts for the grid and the battery add an arrow: ↑ into the home, ↓ out of it. diff --git a/web/app.js b/web/app.js index 6903f25b1..b1c88be0e 100644 --- a/web/app.js +++ b/web/app.js @@ -310,14 +310,29 @@ const lastUpdate = $("last-update"); const versionEl = $("version"); // ---- Formatting ---- + // The UI never shows a raw minus. formatW gives the size of a power; the + // caller says which way it flows, in a word or with flowMark. function formatW(w) { if (w == null || !isFinite(w)) return "—"; const abs = Math.abs(w); if (abs >= 1000) { - return (w / 1000).toFixed(1) + " kW"; + return (abs / 1000).toFixed(1) + " kW"; } - return Math.round(w) + " W"; + return Math.round(abs) + " W"; } + // Which side of zero a power sits on, as the charts draw it: ↑ into the + // home (import, charge), ↓ out of it (export, discharge). Nothing for a + // reading within 10 W of zero. + function flowMark(w) { + if (w == null || !isFinite(w)) return ""; + return w > 10 ? " ↑" : w < -10 ? " ↓" : ""; + } + function formatFlowW(w) { + return formatW(w) + flowMark(w); + } + // What the charts draw on each side of zero, where their labels are sizes. + var BELOW_ZERO_LIVE = "↓ export · discharge · solar"; + var BATTERY_SIDES = "↑ charge ↓ discharge"; // Fuse bar color. The bar is grid power, so it takes the grid colour, as // on the phone; from 90 % it takes the alarm, because operators just need @@ -384,7 +399,8 @@ function updateLiveStat(key, w, cls) { var el = document.getElementById("live-stat-" + key); if (!el) return; - el.textContent = formatW(w); + // Grid and battery go both ways; the mark says which. + el.textContent = key === "grid" || key === "bat" ? formatFlowW(w) : formatW(w); el.className = "live-stat-value " + (cls || "is-neutral"); } function updateLiveSocStat(soc) { @@ -397,8 +413,8 @@ function batteryTargetLine(targetW) { if (targetW == null || !isFinite(targetW)) return ""; // Just the target — the "· charging/discharging" suffix overflowed the - // node circle; the live W value + SoC% already convey direction. - return "target " + formatW(targetW); + // node circle; the mark says charge (↑) or discharge (↓). + return "target " + formatFlowW(targetW); } function hasControllableBatteryDrivers(data) { @@ -651,7 +667,7 @@ var targetDisp = document.getElementById("grid-target-display"); if (targetDisp) { var t = data.grid_target_w || 0; - targetDisp.textContent = t === 0 ? "target 0" : "target " + formatW(t); + targetDisp.textContent = t === 0 ? "target 0" : "target " + formatFlowW(t); } var slotBadge = document.getElementById("grid-slot-badge"); if (slotBadge) { @@ -836,7 +852,7 @@ // dragged then mouse-released doesn't revert before Save is clicked. if (gridTargetSlider && !gridTargetDirty) { gridTargetSlider.value = data.grid_target_w; - gridTargetValue.textContent = formatW(data.grid_target_w); + gridTargetValue.textContent = formatFlowW(data.grid_target_w); } // Peak import ceiling. Backed by the new peak_import_ceiling_w // (hard rule across modes); 0 = disabled. Falls back to the legacy @@ -1449,8 +1465,18 @@ for (var i2 = 0; i2 <= steps; i2++) { var yVal = yMin + (yRange * i2 / steps); var ly = pad.top + plotH - (plotH * i2 / steps); + // Today's totals never go below zero; that strip is only headroom. + if (chartView === "energy" && yVal < 0) continue; ctx.fillText(chartView === "energy" ? yVal.toFixed(1) + " kWh" : formatW(yVal), 2, ly + 4); } + // Below zero the labels are sizes; this says what sits there. + if (chartView !== "energy" && yMin < 0 && yMax > 0) { + var zeroLabelY = pad.top + plotH * (1 - (0 - yMin) / yRange); + ctx.fillStyle = C.muted; + ctx.font = chartFontTooltipS; + ctx.fillText(BELOW_ZERO_LIVE, pad.left + 4, zeroLabelY + 12); + ctx.font = chartFontAxis; + } // Time labels ctx.fillStyle = C.muted; @@ -1643,16 +1669,18 @@ ctx.fillStyle = C.text; ctx.fillText(lab.data[i].toFixed(2) + " kWh", boxX + boxW - 6, y); } else { - var actual = formatW(lab.data[i]); + // Grid and batteries go both ways; the mark says which. + var both = lab.name === "Grid" || "target" in lab; + var actual = both ? formatFlowW(lab.data[i]) : formatW(lab.data[i]); ctx.fillStyle = C.text; ctx.fillText(actual, boxX + boxW - 6, y); - // Inline target as dim "(→ -674 W)" so user sees commanded vs actual + // Inline target as dim "(→ 674 W ↓)" so user sees commanded vs actual // in one glance. Skip when target is 0 to reduce visual noise. if (lab.target && i < lab.target.length && Math.abs(lab.target[i]) > 1) { var actualW = ctx.measureText(actual).width; ctx.fillStyle = C.dim; ctx.font = fontTooltipS; - ctx.fillText("→ " + formatW(lab.target[i]), boxX + boxW - 10 - actualW, y); + ctx.fillText("→ " + formatFlowW(lab.target[i]), boxX + boxW - 10 - actualW, y); ctx.font = fontTooltip; } } @@ -1716,7 +1744,7 @@ ctx.fillText(lab.name, boxX + 18, y); ctx.fillStyle = C.text; ctx.textAlign = "right"; - var val = lab.literal ? lab.val : formatW(lab.val); + var val = lab.literal ? lab.val : lab.showSign ? formatFlowW(lab.val) : formatW(lab.val); ctx.fillText(val, boxX + boxW - 6, y); ctx.textAlign = "left"; }); @@ -1847,13 +1875,13 @@ v2xBtn.disabled = true; if (status) { status.className = "v2x-command-status"; - status.textContent = "Sending " + formatW(powerW) + "…"; + status.textContent = "Sending " + formatFlowW(powerW) + "…"; } v2xCommand(v2xName, powerW) .then(function () { if (status) { status.className = "v2x-command-status ok"; - status.textContent = powerW === 0 ? "Stopped" : "Sent " + formatW(powerW); + status.textContent = powerW === 0 ? "Stopped" : "Sent " + formatFlowW(powerW); } setTimeout(fetchStatus, 600); }) @@ -2035,21 +2063,21 @@ // Battery target + tracking deviation. Skip if no dispatch (planner // hasn't run) OR this driver has no battery (target meaningless). var batteryRow = - ' Battery' + formatW(batWVal) + ""; + ' Battery' + formatFlowW(batWVal) + ""; var disp = (dispatchByDriver || {})[name]; if (disp && d.bat_w != null && !d.observe_only) { var dev = batWVal - disp.target_w; var devClass = Math.abs(dev) > 200 ? "stat-warn" : "stat-dim"; batteryRow = - ' Battery' + formatW(batWVal) + - ' → ' + formatW(disp.target_w) + '' + + ' Battery' + formatFlowW(batWVal) + + ' → ' + formatFlowW(disp.target_w) + '' + ' Δ ' + formatW(dev) + '' + ""; } body = '
' + - ' Meter' + formatW(meterW) + "" + + ' Meter' + formatFlowW(meterW) + "" + ' PV' + formatW(-pvWVal) + "" + batteryRow + (d.observe_only @@ -2111,7 +2139,7 @@ item.innerHTML = '' + escHtml(d.driver) + "" + "" + - '' + formatW(d.target_w) + "" + + '' + formatFlowW(d.target_w) + "" + (d.clamped ? 'CLAMPED' : "") + ""; dispatchList.appendChild(item); @@ -2674,7 +2702,7 @@ // poll skips overwrite while dirty. Mirrors the peak slider pattern. if (gridTargetSlider) { gridTargetSlider.addEventListener("input", function () { - gridTargetValue.textContent = formatW(Number(gridTargetSlider.value)); + gridTargetValue.textContent = formatFlowW(Number(gridTargetSlider.value)); gridTargetDirty = true; if (gridTargetSend) gridTargetSend.disabled = false; }); @@ -5264,9 +5292,10 @@ ctx.font = "10px ui-monospace, SFMono-Regular, Menlo, Monaco, monospace"; ctx.textAlign = "right"; ctx.textBaseline = "middle"; - // battery: max charge / max discharge labels at the edges + // battery: max charge / max discharge at the edges, as sizes; the line + // under the chart says which side of zero is which. ctx.fillText((batMax / 1000).toFixed(1) + "kW", pad.left - 4, pad.top + 6); - ctx.fillText("-" + (batMax / 1000).toFixed(1) + "kW", pad.left - 4, batMid + (batH / 2) - 6); + ctx.fillText((batMax / 1000).toFixed(1) + "kW", pad.left - 4, batMid + (batH / 2) - 6); // SoC: 100% / 0% on the right ctx.textAlign = "left"; ctx.fillText("100%", pad.left + plotW + 4, socTop + 6); @@ -5275,6 +5304,8 @@ ctx.fillStyle = C.muted; ctx.textAlign = "left"; ctx.fillText("24h ago", pad.left, cssH - 4); + ctx.textAlign = "center"; + ctx.fillText(BATTERY_SIDES, pad.left + plotW / 2, cssH - 4); ctx.textAlign = "right"; ctx.fillText("now", pad.left + plotW, cssH - 4); } diff --git a/web/diagnose.js b/web/diagnose.js index 0c83ce89b..c29a80638 100644 --- a/web/diagnose.js +++ b/web/diagnose.js @@ -601,9 +601,16 @@ ctx.textAlign = 'right'; ctx.fillText(maxPrice.toFixed(0) + 'ö', pad.l - 4, priceY0 + 10); ctx.fillText('0', pad.l - 4, priceY0 + priceH - 2); - ctx.fillText('+' + (maxAbsPower / 1000).toFixed(1) + 'kW', pad.l - 4, powerY0 + 10); + ctx.fillText(powerAxisLabel(maxAbsPower), pad.l - 4, powerY0 + 10); ctx.fillText('0', pad.l - 4, powerMidY + 3); - ctx.fillText('−' + (maxAbsPower / 1000).toFixed(1) + 'kW', pad.l - 4, powerY0 + powerH - 2); + ctx.fillText(powerAxisLabel(-maxAbsPower), pad.l - 4, powerY0 + powerH - 2); + // The power labels are sizes; these words say which side is which. + ctx.textAlign = 'left'; + ctx.fillStyle = C.muted; + ctx.fillText(POWER_ABOVE, pad.l + 4, powerMidY - 4); + ctx.fillText(POWER_BELOW, pad.l + 4, powerMidY + 12); + ctx.fillStyle = C.dim; + ctx.textAlign = 'right'; ctx.fillText('100%', pad.l - 4, socY0 + 10); ctx.fillText('0', pad.l - 4, socY0 + socH - 2); @@ -616,6 +623,12 @@ } // ---- Helpers ---- + // The chart's power axis shows sizes, never a raw minus. + var POWER_ABOVE = '↑ import · charge'; + var POWER_BELOW = '↓ export · discharge · solar'; + function powerAxisLabel(w) { + return (Math.abs(w) / 1000).toFixed(1) + 'kW'; + } function fmtHHMM(ts) { const d = new Date(ts); return d.getHours().toString().padStart(2, '0') + ':' + diff --git a/web/no-raw-minus.test.mjs b/web/no-raw-minus.test.mjs new file mode 100644 index 000000000..8af756e2b --- /dev/null +++ b/web/no-raw-minus.test.mjs @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { describe, it } from "node:test"; +import vm from "node:vm"; + +import { derivePlanBrief } from "./plan-brief.js"; + +// FTW's rule: the UI never shows a raw minus. It shows a size, and says the +// direction in a word or with an arrow. +const read = (path) => readFileSync(new URL(`./${path}`, import.meta.url), "utf8"); +const between = (source, start, end) => { + const from = source.indexOf(start); + const to = source.indexOf(end, from); + assert.ok(from >= 0 && to > from, `${start} … ${end}`); + return source.slice(from, to); +}; +const run = (code) => { + const context = {}; + vm.createContext(context); + vm.runInContext(code, context); + return context; +}; +const NEGATIVE = [-0.4, -5, -10.5, -950, -999.6, -1000, -1234, -12700]; +const noMinus = (text) => assert.doesNotMatch(String(text), /[-−]/, String(text)); + +describe("charts show sizes, never a raw minus", () => { + const app = read("app.js"); + const live = run(between(app, " function formatW(", " // Fuse bar color.")); + + it("formats any power as a size", () => { + for (const w of NEGATIVE) { + noMinus(live.formatW(w)); + noMinus(live.formatFlowW(w)); + assert.equal(live.formatW(w), live.formatW(-w)); + } + assert.equal(live.formatW(null), "—"); + }); + + it("marks the side of zero where a power goes both ways", () => { + assert.equal(live.formatFlowW(1200), "1.2 kW ↑"); + assert.equal(live.formatFlowW(-1200), "1.2 kW ↓"); + assert.equal(live.formatFlowW(-500), "500 W ↓"); + assert.equal(live.formatFlowW(5), "5 W"); + assert.equal(live.formatFlowW(-5), "5 W"); + }); + + it("labels the plan and diagnose power axes as sizes", () => { + for (const path of ["plan.js", "diagnose.js"]) { + const source = read(path); + const { powerAxisLabel } = run(between(source, " function powerAxisLabel(", "\n }\n") + "\n }"); + assert.equal(powerAxisLabel(-12700), "12.7kW", path); + assert.equal(powerAxisLabel(12700), "12.7kW", path); + for (const w of NEGATIVE) noMinus(powerAxisLabel(w)); + assert.doesNotMatch(source, /fillText\(\s*['"][-−+]['"]\s*\+/, path); + } + }); + + it("says what sits below zero on every chart with a lower side", () => { + // Live power chart. + assert.match(app, /var BELOW_ZERO_LIVE = "↓ export · discharge · solar";/); + assert.match(app, /ctx\.fillText\(BELOW_ZERO_LIVE,/); + // 24 h battery chart: both edges are sizes, the words name the sides. + assert.doesNotMatch(app, /"-" \+ \(batMax/); + assert.match(app, /var BATTERY_SIDES = "↑ charge {3}↓ discharge";/); + assert.match(app, /ctx\.fillText\(BATTERY_SIDES,/); + // Plan power band. + const plan = read("plan.js"); + assert.match(plan, /const POWER_SIDES = '↑ import \/ charge {3}↓ export \/ discharge \/ solar';/); + assert.match(plan, /ctx\.fillText\(POWER_SIDES,/); + // Diagnose chart. + const diagnose = read("diagnose.js"); + assert.match(diagnose, /var POWER_BELOW = '↓ export · discharge · solar';/); + assert.match(diagnose, /ctx\.fillText\(POWER_BELOW,/); + }); + + it("keeps the words, not the sign, in the plan tooltip and brief", () => { + assert.match(read("plan.js"), /\$\{\(Math\.abs\(a\.battery_w\) \/ 1000\)\.toFixed\(1\)\} kW \(\$\{dir\}\)/); + const now = Date.now(); + const brief = derivePlanBrief({ + enabled: true, + plan: { actions: [{ slot_start_ms: now, slot_len_min: 15, battery_w: -1800, loadpoint_w: 0, pv_limit_w: 0 }] }, + status: { mode: "planner_passive_arbitrage", dispatch: [{ driver: "battery", clamped: true, target_w: -1800 }] }, + now, + }); + assert.match(brief.constraint, /Safety adjusted battery to 1\.8 kW ↓/); + noMinus(brief.constraint); + }); +}); diff --git a/web/plan-brief.js b/web/plan-brief.js index 4170c09d6..b11ab28bd 100644 --- a/web/plan-brief.js +++ b/web/plan-brief.js @@ -49,11 +49,14 @@ function actionLabel(action) { return "Keep the battery steady"; } +// A size, never a raw minus; the arrow says the side of zero, as on the +// chart: ↑ into the home (charge), ↓ out of it (discharge, export). function briefPower(watts) { const value = Number(watts) || 0; - return Math.abs(value) >= 1000 - ? `${(value / 1000).toFixed(1)} kW` - : `${Math.round(value)} W`; + const size = Math.abs(value) >= 1000 + ? `${(Math.abs(value) / 1000).toFixed(1)} kW` + : `${Math.round(Math.abs(value))} W`; + return size + (value > 10 ? " ↑" : value < -10 ? " ↓" : ""); } function fractionOrNull(value) { diff --git a/web/plan.js b/web/plan.js index cd486cecc..caafc5bac 100644 --- a/web/plan.js +++ b/web/plan.js @@ -69,6 +69,13 @@ import { return w; } + // The power axis shows sizes, never a raw minus; POWER_SIDES says what + // sits above zero and what below. + const POWER_SIDES = '↑ import / charge ↓ export / discharge / solar'; + function powerAxisLabel(w) { + return (Math.abs(w) / 1000).toFixed(1) + 'kW'; + } + // Horizon controls the x-axis bounds; mirrors the price chart's // 3-position pill so operators have a consistent affordance across // both charts. Persisted in localStorage so a user who prefers @@ -709,16 +716,16 @@ import { ctx.stroke(); ctx.fillStyle = C.dim; ctx.textAlign = 'right'; - ctx.fillText('+' + (pMagMax / 1000).toFixed(1) + 'kW', pad.l - 6, powerY(pMagMax) + 4); - ctx.fillText('−' + (pMagMax / 1000).toFixed(1) + 'kW', pad.l - 6, powerY(-pMagMax) + 4); + ctx.fillText(powerAxisLabel(pMagMax), pad.l - 6, powerY(pMagMax) + 4); + ctx.fillText(powerAxisLabel(-pMagMax), pad.l - 6, powerY(-pMagMax) + 4); ctx.textAlign = 'left'; - // "Power" heading + tiny sign-convention legend so readers don't have - // to remember that positive means "into the site". Placed just below - // the heading at lower opacity to read as a subtitle. + // "Power" heading + tiny legend for the two sides of zero, since the + // axis labels are sizes. Placed just below the heading at lower + // opacity to read as a subtitle. ctx.fillText('Power', pad.l + 4, powerY0 + 12); ctx.fillStyle = C.muted; ctx.font = '9px system-ui, sans-serif'; - ctx.fillText('+ import / charge − export / discharge', pad.l + 40, powerY0 + 12); + ctx.fillText(POWER_SIDES, pad.l + 40, powerY0 + 12); ctx.font = '11px system-ui, sans-serif'; // Skip every battery-related draw layer (action band, bars, SoC @@ -1027,7 +1034,7 @@ import { } if (a.battery_w != null) { const dir = a.battery_w > 100 ? 'charge' : a.battery_w < -100 ? 'discharge' : 'idle'; - lines.push(`
Battery${(a.battery_w / 1000).toFixed(1)} kW (${dir})
`); + lines.push(`
Battery${(Math.abs(a.battery_w) / 1000).toFixed(1)} kW (${dir})
`); } if (a.grid_w != null) { const gdir = a.grid_w > 0 ? 'import' : 'export'; From b4d2488d41ff046356a6710b1d173e38cdc96a64 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 26 Oct 2026 07:20:17 +0100 Subject: [PATCH 2/3] fix(web): say the direction in words, not arrows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the first pass: arrows needed a key, and the grid's ↑ for import contradicted the Flow hero, which already uses ↓ for import on its daily line. The phone app uses words, so the box page does too. - web/power-words.js holds the one rule: a size, plus "import" or "export" for the grid and "charge" or "discharge" for a battery or a two-way charger, none within 10 W of zero. index.html loads it before app.js; plan-brief.js and plan.js import it for its side effect. - Live strip (word on its own line), battery and grid targets, the grid target slider, tooltips, V2X status, Power and DC rows, driver battery and meter rows, dispatch targets, per-phase watts under the fuse bars and the plan's safety note use it. A target that goes the same way as its reading shows only its size. - The tracking deviation says "300 W short", "300 W over" or "on target", by size. - Each chart names both sides of zero beside its zero line, on a backing in the panel colour: above, what adds to the grid meter's reading (import · charge · home · car); below, what takes away from it (export · discharge · solar), for the parts each chart draws. - Flow hero: fmtKw prints the size; the battery bubble keeps the readings' own "charging" or "discharging" under it. The target it used to show there did not fit beside that word. - Tests run the dashboard's strip, target, tooltip and driver row functions on both signs, find functions by name rather than by what follows them, fail if a signed grid or battery value goes back to a bare size, and check the sign of every chart's fillText. Co-Authored-By: Claude Opus 5.5 --- .changeset/no-raw-minus-charts.md | 2 +- web/app.css | 8 ++ web/app.js | 165 +++++++++++++----------- web/components/ftw-energy-flow.js | 20 +-- web/diagnose.js | 11 +- web/index.html | 1 + web/no-raw-minus.test.mjs | 202 +++++++++++++++++++++++------- web/peak-import-ceiling.test.mjs | 3 + web/plan-brief.js | 16 +-- web/plan.js | 13 +- web/power-words.js | 99 +++++++++++++++ 11 files changed, 381 insertions(+), 159 deletions(-) create mode 100644 web/power-words.js diff --git a/.changeset/no-raw-minus-charts.md b/.changeset/no-raw-minus-charts.md index 7a6de2749..8da65e35a 100644 --- a/.changeset/no-raw-minus-charts.md +++ b/.changeset/no-raw-minus-charts.md @@ -2,4 +2,4 @@ "ftw": patch --- -The dashboard's charts no longer print a minus sign. Axis labels show sizes, and a short line on each chart says what sits below zero, such as export, discharge and solar. Tooltips and readouts for the grid and the battery add an arrow: ↑ into the home, ↓ out of it. +The dashboard no longer prints a minus sign on power. Charts label their axes with sizes and name what sits on each side of zero: above it what adds to the grid meter's reading (import, charge, the home, the car), below it what takes away (export, discharge, solar). Grid and battery values say their direction in a word, such as "500 W export" or "1.4 kW discharge", in tooltips, the live strip, targets, driver cards, the per-phase watts under the fuse bars and the plan's safety note. The Overview flow shows the battery's size with "charging" or "discharging" under it. diff --git a/web/app.css b/web/app.css index 82a81b6f7..256f9e2c5 100644 --- a/web/app.css +++ b/web/app.css @@ -1637,6 +1637,14 @@ body.ftw-app .live-stat-value.is-export { color: var(--green-e); } body.ftw-app .live-stat-value.is-charging { color: var(--cyan); } body.ftw-app .live-stat-value.is-discharging { color: var(--energy-gives); } body.ftw-app .live-stat-value.is-neutral { color: var(--fg-dim); } +/* The direction word under a two-way value, so a narrow cell keeps the + size on one line. */ +body.ftw-app .live-stat-dir { + display: block; + font-size: 10px; + font-weight: 500; + letter-spacing: 0.06em; +} /* 24 h battery + SoC history block — sits below the live power chart. * Section label matches the Plan card's chart-section labels. */ diff --git a/web/app.js b/web/app.js index b1c88be0e..bc86f127c 100644 --- a/web/app.js +++ b/web/app.js @@ -310,29 +310,11 @@ const lastUpdate = $("last-update"); const versionEl = $("version"); // ---- Formatting ---- - // The UI never shows a raw minus. formatW gives the size of a power; the - // caller says which way it flows, in a word or with flowMark. - function formatW(w) { - if (w == null || !isFinite(w)) return "—"; - const abs = Math.abs(w); - if (abs >= 1000) { - return (abs / 1000).toFixed(1) + " kW"; - } - return Math.round(abs) + " W"; - } - // Which side of zero a power sits on, as the charts draw it: ↑ into the - // home (import, charge), ↓ out of it (export, discharge). Nothing for a - // reading within 10 W of zero. - function flowMark(w) { - if (w == null || !isFinite(w)) return ""; - return w > 10 ? " ↑" : w < -10 ? " ↓" : ""; - } - function formatFlowW(w) { - return formatW(w) + flowMark(w); - } - // What the charts draw on each side of zero, where their labels are sizes. - var BELOW_ZERO_LIVE = "↓ export · discharge · solar"; - var BATTERY_SIDES = "↑ charge ↓ discharge"; + // The UI never shows a raw minus. power-words.js, loaded before this file, + // gives a size and the word for its direction; it is read when used. + function powerWords() { return globalThis.ftwPowerWords; } + function formatW(w) { return powerWords().formatW(w); } + function formatPower(w, kind) { return powerWords().formatPower(w, kind); } // Fuse bar color. The bar is grid power, so it takes the grid colour, as // on the phone; from 90 % it takes the alarm, because operators just need @@ -396,11 +378,23 @@ if (kind === "bat") return v > 0 ? "is-charging" : "is-discharging"; return "is-neutral"; } + // The live strip's value: grid and battery go both ways, so a word says + // which, on its own line; solar, home and car do not. + function liveStatParts(key, w) { + var kind = key === "grid" ? "grid" : key === "bat" ? "battery" : null; + return { size: formatW(w), word: kind ? powerWords().directionWord(w, kind) : "" }; + } function updateLiveStat(key, w, cls) { var el = document.getElementById("live-stat-" + key); if (!el) return; - // Grid and battery go both ways; the mark says which. - el.textContent = key === "grid" || key === "bat" ? formatFlowW(w) : formatW(w); + var parts = liveStatParts(key, w); + el.textContent = parts.size; + if (parts.word) { + var dir = document.createElement("span"); + dir.className = "live-stat-dir"; + dir.textContent = " " + parts.word; + el.appendChild(dir); + } el.className = "live-stat-value " + (cls || "is-neutral"); } function updateLiveSocStat(soc) { @@ -412,9 +406,30 @@ } function batteryTargetLine(targetW) { if (targetW == null || !isFinite(targetW)) return ""; - // Just the target — the "· charging/discharging" suffix overflowed the - // node circle; the mark says charge (↑) or discharge (↓). - return "target " + formatFlowW(targetW); + // Just the target and one short word; "charging/discharging" overflowed + // the node circle. + return "target " + formatPower(targetW, "battery"); + } + + // A live tooltip row's value and target. The grid and batteries go both + // ways, so their rows carry a word; the other rows are sizes. + function tooltipPowerText(lab, i) { + var kind = lab.name === "Grid" ? "grid" : "target" in lab ? "battery" : null; + var actual = kind ? formatPower(lab.data[i], kind) : formatW(lab.data[i]); + var target = lab.target && i < lab.target.length && Math.abs(lab.target[i]) > 1 + ? powerWords().formatTarget(lab.target[i], lab.data[i], kind || "") + : ""; + return { actual: actual, target: target }; + } + + // A driver card's battery row: the reading, its target and how far apart + // they are. + function batteryRowParts(actualW, targetW) { + return { + actual: formatPower(actualW, "battery"), + target: powerWords().formatTarget(targetW, actualW, "battery"), + tracking: powerWords().trackingText(actualW, targetW), + }; } function hasControllableBatteryDrivers(data) { @@ -525,17 +540,13 @@ if (lastFlowStatus) paintEnergyFlow(lastFlowStatus); }; - function energyFlowOpts(data) { - var byDriver = {}; - (data.dispatch || []).forEach(function (d) { - if (d && d.driver) byDriver[d.driver] = Number(d.target_w) || 0; - }); + function energyFlowOpts() { + // The battery bubble prints a size, so the line under it is the + // readings' own word: charging, discharging, idle or observe only. The + // target it used to show there did not fit beside that word; it stays + // on the battery card in the Values view and on the driver card. return { idleW: (typeof window.FTW_FLOW_IDLE_W === "number" && window.FTW_FLOW_IDLE_W) || 42, - batterySub: function (name, d) { - if (d.observe_only) return "observe only"; - return batteryTargetLine(byDriver[name]); - }, }; } @@ -667,7 +678,7 @@ var targetDisp = document.getElementById("grid-target-display"); if (targetDisp) { var t = data.grid_target_w || 0; - targetDisp.textContent = t === 0 ? "target 0" : "target " + formatFlowW(t); + targetDisp.textContent = t === 0 ? "target 0" : "target " + formatPower(t, "grid"); } var slotBadge = document.getElementById("grid-slot-badge"); if (slotBadge) { @@ -852,7 +863,7 @@ // dragged then mouse-released doesn't revert before Save is clicked. if (gridTargetSlider && !gridTargetDirty) { gridTargetSlider.value = data.grid_target_w; - gridTargetValue.textContent = formatFlowW(data.grid_target_w); + gridTargetValue.textContent = formatPower(data.grid_target_w, "grid"); } // Peak import ceiling. Backed by the new peak_import_ceiling_w // (hard rule across modes); 0 = disabled. Falls back to the legacy @@ -987,7 +998,7 @@ if (bvw) { var sw = rawW; if (Math.abs(sw) < 1) sw = 0; - bvw.textContent = Math.round(sw) + " W"; + bvw.textContent = formatPower(sw, "grid"); bvw.classList.toggle("export", rawW < -1); } } @@ -1120,6 +1131,7 @@ var chartFontTooltip = smallScreen ? "14px monospace" : "10px monospace"; var chartFontTooltipS = smallScreen ? "13px monospace" : "9px monospace"; var chartNowStrokeW = smallScreen ? 2 : 1; + var chartFontSides = smallScreen ? "11px monospace" : "10px monospace"; if (canvas.width !== w * dpr || canvas.height !== h * dpr) { canvas.width = w * dpr; canvas.height = h * dpr; @@ -1469,12 +1481,11 @@ if (chartView === "energy" && yVal < 0) continue; ctx.fillText(chartView === "energy" ? yVal.toFixed(1) + " kWh" : formatW(yVal), 2, ly + 4); } - // Below zero the labels are sizes; this says what sits there. + // The labels are sizes; these words say what sits on each side of zero. if (chartView !== "energy" && yMin < 0 && yMax > 0) { var zeroLabelY = pad.top + plotH * (1 - (0 - yMin) / yRange); - ctx.fillStyle = C.muted; - ctx.font = chartFontTooltipS; - ctx.fillText(BELOW_ZERO_LIVE, pad.left + 4, zeroLabelY + 12); + ctx.font = chartFontSides; + powerWords().drawSides(ctx, pad.left + 6, zeroLabelY, powerWords().SIDES.live, C.dim, C.surface); ctx.font = chartFontAxis; } @@ -1639,7 +1650,19 @@ var ts = chartHistory.timestamps[i] || 0; var timeStr = ts > 0 ? new Date(ts).toLocaleTimeString() : ""; var lineHeight = 16; + // Wide enough for the longest row: name, value and target. var boxW = 200; + if (chartView !== "energy") { + labels.forEach(function (lab) { + if (i >= lab.data.length) return; + var t = tooltipPowerText(lab, i); + ctx.font = fontTooltip; + var need = 30 + ctx.measureText(lab.name).width + ctx.measureText(t.actual).width; + ctx.font = fontTooltipS; + if (t.target) need += 8 + ctx.measureText(t.target).width; + boxW = Math.max(boxW, Math.ceil(need)); + }); + } var boxH = (labels.length + 1) * lineHeight + 10; // Position tooltip (avoid going off-screen) @@ -1669,18 +1692,16 @@ ctx.fillStyle = C.text; ctx.fillText(lab.data[i].toFixed(2) + " kWh", boxX + boxW - 6, y); } else { - // Grid and batteries go both ways; the mark says which. - var both = lab.name === "Grid" || "target" in lab; - var actual = both ? formatFlowW(lab.data[i]) : formatW(lab.data[i]); + var text = tooltipPowerText(lab, i); ctx.fillStyle = C.text; - ctx.fillText(actual, boxX + boxW - 6, y); - // Inline target as dim "(→ 674 W ↓)" so user sees commanded vs actual - // in one glance. Skip when target is 0 to reduce visual noise. - if (lab.target && i < lab.target.length && Math.abs(lab.target[i]) > 1) { - var actualW = ctx.measureText(actual).width; + ctx.fillText(text.actual, boxX + boxW - 6, y); + // Inline target as dim "→ 674 W discharge" so user sees commanded vs + // actual in one glance. + if (text.target) { + var actualW = ctx.measureText(text.actual).width; ctx.fillStyle = C.dim; ctx.font = fontTooltipS; - ctx.fillText("→ " + formatFlowW(lab.target[i]), boxX + boxW - 10 - actualW, y); + ctx.fillText(text.target, boxX + boxW - 10 - actualW, y); ctx.font = fontTooltip; } } @@ -1744,7 +1765,9 @@ ctx.fillText(lab.name, boxX + 18, y); ctx.fillStyle = C.text; ctx.textAlign = "right"; - var val = lab.literal ? lab.val : lab.showSign ? formatFlowW(lab.val) : formatW(lab.val); + var val = lab.literal ? lab.val + : lab.showSign ? formatPower(lab.val, lab.name === "Grid" ? "grid" : "battery") + : formatW(lab.val); ctx.fillText(val, boxX + boxW - 6, y); ctx.textAlign = "left"; }); @@ -1814,10 +1837,6 @@ return actions; } - function formatOptionalW(w) { - return w == null ? "—" : formatW(w); - } - // --------------------------------------------------------------------- // V2X manual-command surface. Dashboard driver cards are rendered here // rather than in a framework so the static UI keeps working without a @@ -1875,13 +1894,13 @@ v2xBtn.disabled = true; if (status) { status.className = "v2x-command-status"; - status.textContent = "Sending " + formatFlowW(powerW) + "…"; + status.textContent = "Sending " + formatPower(powerW, "v2x") + "…"; } v2xCommand(v2xName, powerW) .then(function () { if (status) { status.className = "v2x-command-status ok"; - status.textContent = powerW === 0 ? "Stopped" : "Sent " + formatFlowW(powerW); + status.textContent = powerW === 0 ? "Stopped" : "Sent " + formatPower(powerW, "v2x"); } setTimeout(fetchStatus, 600); }) @@ -1995,7 +2014,7 @@ var v2xClass = v2xWVal < -100 ? "stat-ok" : (v2xWVal > 100 ? "stat-warn" : (connected ? "stat-warn" : "stat-dim")); var vehicleSoc = d.v2x_vehicle_soc != null ? formatSoc(d.v2x_vehicle_soc) : "—"; var dcSummary = (d.v2x_dc_w != null || d.v2x_dc_v != null || d.v2x_dc_a != null) - ? formatOptionalW(d.v2x_dc_w) + " · " + + ? (d.v2x_dc_w == null ? "—" : formatPower(d.v2x_dc_w, "v2x")) + " · " + (d.v2x_dc_v != null ? d.v2x_dc_v.toFixed(0) + " V" : "—") + " · " + (d.v2x_dc_a != null ? d.v2x_dc_a.toFixed(1) + " A" : "—") : "—"; @@ -2013,7 +2032,7 @@ body = '
' + ' State' + escHtml(statusLabel) + '' + - ' Power' + formatW(v2xWVal) + '' + + ' Power' + formatPower(v2xWVal, "v2x") + '' + ' Vehicle SoC' + vehicleSoc + '' + ' DC' + escHtml(dcSummary) + '' + ' Session' + escHtml(session) + '' + @@ -2063,21 +2082,22 @@ // Battery target + tracking deviation. Skip if no dispatch (planner // hasn't run) OR this driver has no battery (target meaningless). var batteryRow = - ' Battery' + formatFlowW(batWVal) + ""; + ' Battery' + formatPower(batWVal, "battery") + ""; var disp = (dispatchByDriver || {})[name]; if (disp && d.bat_w != null && !d.observe_only) { var dev = batWVal - disp.target_w; var devClass = Math.abs(dev) > 200 ? "stat-warn" : "stat-dim"; + var row = batteryRowParts(batWVal, disp.target_w); batteryRow = - ' Battery' + formatFlowW(batWVal) + - ' → ' + formatFlowW(disp.target_w) + '' + - ' Δ ' + formatW(dev) + '' + + ' Battery' + row.actual + + ' ' + row.target + '' + + ' ' + row.tracking + '' + ""; } body = '
' + - ' Meter' + formatFlowW(meterW) + "" + + ' Meter' + formatPower(meterW, "grid") + "" + ' PV' + formatW(-pvWVal) + "" + batteryRow + (d.observe_only @@ -2139,7 +2159,7 @@ item.innerHTML = '' + escHtml(d.driver) + "" + "" + - '' + formatFlowW(d.target_w) + "" + + '' + formatPower(d.target_w, "battery") + "" + (d.clamped ? 'CLAMPED' : "") + ""; dispatchList.appendChild(item); @@ -2702,7 +2722,7 @@ // poll skips overwrite while dirty. Mirrors the peak slider pattern. if (gridTargetSlider) { gridTargetSlider.addEventListener("input", function () { - gridTargetValue.textContent = formatFlowW(Number(gridTargetSlider.value)); + gridTargetValue.textContent = formatPower(Number(gridTargetSlider.value), "grid"); gridTargetDirty = true; if (gridTargetSend) gridTargetSend.disabled = false; }); @@ -5292,10 +5312,11 @@ ctx.font = "10px ui-monospace, SFMono-Regular, Menlo, Monaco, monospace"; ctx.textAlign = "right"; ctx.textBaseline = "middle"; - // battery: max charge / max discharge at the edges, as sizes; the line - // under the chart says which side of zero is which. + // battery: max charge / max discharge at the edges, as sizes; the words + // beside the zero line say which side is which. ctx.fillText((batMax / 1000).toFixed(1) + "kW", pad.left - 4, pad.top + 6); ctx.fillText((batMax / 1000).toFixed(1) + "kW", pad.left - 4, batMid + (batH / 2) - 6); + powerWords().drawSides(ctx, pad.left + 4, batMid, powerWords().SIDES.battery, C.dim, C.surface); // SoC: 100% / 0% on the right ctx.textAlign = "left"; ctx.fillText("100%", pad.left + plotW + 4, socTop + 6); @@ -5304,8 +5325,6 @@ ctx.fillStyle = C.muted; ctx.textAlign = "left"; ctx.fillText("24h ago", pad.left, cssH - 4); - ctx.textAlign = "center"; - ctx.fillText(BATTERY_SIDES, pad.left + plotW / 2, cssH - 4); ctx.textAlign = "right"; ctx.fillText("now", pad.left + plotW, cssH - 4); } diff --git a/web/components/ftw-energy-flow.js b/web/components/ftw-energy-flow.js index c17acf4b0..aba00ee69 100644 --- a/web/components/ftw-energy-flow.js +++ b/web/components/ftw-energy-flow.js @@ -1662,17 +1662,19 @@ function renderDailyParts(parts) { } function fmtKw(kw) { - // Input is kilowatts. Sub-kW values render as plain integer watts - // (no decimals) so a "30 W" import or "−12 W" battery trickle stays - // visible as the real number. The visual "idle / balanced" - // affordances (beam dimming, sub-label text) live on a separate - // ±FLOW_IDLE_W threshold; this formatter only handles display. + // Input is kilowatts, signed as Core sends them; the bubble prints the + // size only, because the UI never shows a raw minus. The word under it + // ("charging", "discharging", "exporting") says the direction. Sub-kW + // values render as plain integer watts (no decimals) so a "30 W" import + // or a "12 W" battery trickle stays visible as the real number. The + // visual "idle / balanced" affordances (beam dimming, sub-label text) + // live on a separate ±FLOW_IDLE_W threshold; this formatter only handles + // display. // // Sub-half-watt magnitudes round to 0 W — that's noise, not signal. - // `+ 0` normalises Math.round's −0 result to plain 0 so a tiny - // negative reading doesn't print as "-0 W". - if (Math.abs(kw) < 1) return `${Math.round(kw * 1000) + 0} W`; - return `${kw.toFixed(2)} kW`; + const size = Math.abs(kw); + if (size < 1) return `${Math.round(size * 1000)} W`; + return `${size.toFixed(2)} kW`; } // Collapse multi-planet corners into a single synthesized "combined" diff --git a/web/diagnose.js b/web/diagnose.js index c29a80638..95945598d 100644 --- a/web/diagnose.js +++ b/web/diagnose.js @@ -604,13 +604,8 @@ ctx.fillText(powerAxisLabel(maxAbsPower), pad.l - 4, powerY0 + 10); ctx.fillText('0', pad.l - 4, powerMidY + 3); ctx.fillText(powerAxisLabel(-maxAbsPower), pad.l - 4, powerY0 + powerH - 2); - // The power labels are sizes; these words say which side is which. - ctx.textAlign = 'left'; - ctx.fillStyle = C.muted; - ctx.fillText(POWER_ABOVE, pad.l + 4, powerMidY - 4); - ctx.fillText(POWER_BELOW, pad.l + 4, powerMidY + 12); - ctx.fillStyle = C.dim; - ctx.textAlign = 'right'; + // The power labels are sizes; these words say what sits on each side. + globalThis.ftwPowerWords.drawSides(ctx, pad.l + 6, powerMidY, globalThis.ftwPowerWords.SIDES.diagnose, C.dim, C.panel); ctx.fillText('100%', pad.l - 4, socY0 + 10); ctx.fillText('0', pad.l - 4, socY0 + socH - 2); @@ -624,8 +619,6 @@ // ---- Helpers ---- // The chart's power axis shows sizes, never a raw minus. - var POWER_ABOVE = '↑ import · charge'; - var POWER_BELOW = '↓ export · discharge · solar'; function powerAxisLabel(w) { return (Math.abs(w) / 1000).toFixed(1) + 'kW'; } diff --git a/web/index.html b/web/index.html index 6db9c151d..d65ff6b19 100644 --- a/web/index.html +++ b/web/index.html @@ -996,6 +996,7 @@

Price bars (top of the chart)

+ diff --git a/web/no-raw-minus.test.mjs b/web/no-raw-minus.test.mjs index 8af756e2b..651309650 100644 --- a/web/no-raw-minus.test.mjs +++ b/web/no-raw-minus.test.mjs @@ -3,77 +3,183 @@ import { readFileSync } from "node:fs"; import { describe, it } from "node:test"; import vm from "node:vm"; +import "./power-words.js"; import { derivePlanBrief } from "./plan-brief.js"; -// FTW's rule: the UI never shows a raw minus. It shows a size, and says the -// direction in a word or with an arrow. +// FTW's rule: the UI never shows a raw minus. It shows a size, and a short +// word says the direction: import or export, charge or discharge. +const words = globalThis.ftwPowerWords; const read = (path) => readFileSync(new URL(`./${path}`, import.meta.url), "utf8"); -const between = (source, start, end) => { - const from = source.indexOf(start); - const to = source.indexOf(end, from); - assert.ok(from >= 0 && to > from, `${start} … ${end}`); - return source.slice(from, to); -}; -const run = (code) => { - const context = {}; + +// One function's source, found by name and closed by matching braces, so a +// test does not hang on whatever happens to follow it in the file. +function fn(source, name) { + const start = source.search(new RegExp(`function ${name}\\(`)); + assert.ok(start >= 0, `function ${name} not found`); + const open = source.indexOf("{", start); + let depth = 0; + for (let i = open; i < source.length; i++) { + if (source[i] === "{") depth++; + else if (source[i] === "}" && --depth === 0) return source.slice(start, i + 1); + } + throw new Error(`function ${name} is not closed`); +} +function run(code, context = {}) { vm.createContext(context); vm.runInContext(code, context); return context; -}; +} const NEGATIVE = [-0.4, -5, -10.5, -950, -999.6, -1000, -1234, -12700]; const noMinus = (text) => assert.doesNotMatch(String(text), /[-−]/, String(text)); +const noArrow = (text) => assert.doesNotMatch(String(text), /[↑↓]/, String(text)); -describe("charts show sizes, never a raw minus", () => { - const app = read("app.js"); - const live = run(between(app, " function formatW(", " // Fuse bar color.")); - - it("formats any power as a size", () => { +describe("power in words", () => { + it("gives any power as a size", () => { for (const w of NEGATIVE) { - noMinus(live.formatW(w)); - noMinus(live.formatFlowW(w)); - assert.equal(live.formatW(w), live.formatW(-w)); + noMinus(words.formatW(w)); + assert.equal(words.formatW(w), words.formatW(-w)); + } + assert.equal(words.formatW(-1234), "1.2 kW"); + assert.equal(words.formatW(null), "—"); + }); + + it("says the direction in a word for both signs, and none near zero", () => { + const cases = [ + [500, "grid", "500 W import"], [-500, "grid", "500 W export"], + [1400, "battery", "1.4 kW charge"], [-1400, "battery", "1.4 kW discharge"], + [3000, "v2x", "3.0 kW charge"], [-3000, "v2x", "3.0 kW discharge"], + [8, "grid", "8 W"], [-8, "battery", "8 W"], [-1400, "solar", "1.4 kW"], + ]; + for (const [w, kind, want] of cases) { + assert.equal(words.formatPower(w, kind), want, `${w} ${kind}`); + noMinus(words.formatPower(w, kind)); + noArrow(words.formatPower(w, kind)); + } + }); + + it("names a target's direction only when it differs from the reading's", () => { + assert.equal(words.formatTarget(-1200, -1100, "battery"), "→ 1.2 kW"); + assert.equal(words.formatTarget(800, -1100, "battery"), "→ 800 W charge"); + assert.equal(words.formatTarget(-2000, 5, "grid"), "→ 2.0 kW export"); + }); + + it("says how far a device is from its target", () => { + assert.equal(words.trackingText(-700, -1000), "300 W short"); + assert.equal(words.trackingText(1300, 1000), "300 W over"); + assert.equal(words.trackingText(-1005, -1000), "on target"); + }); + + it("names both sides of every chart in words, with one separator", () => { + for (const [chart, sides] of Object.entries(words.SIDES)) { + for (const text of [sides.above, sides.below]) { + noArrow(text); + assert.doesNotMatch(text, /[/,;]/, `${chart}: ${text}`); + } } - assert.equal(live.formatW(null), "—"); + assert.equal(words.SIDES.live.below, "export · discharge · solar"); + assert.equal(words.SIDES.live.above, "import · charge · home · car"); + const drawn = []; + const ctx = { + save() {}, restore() {}, fillRect() { drawn.push("back"); }, measureText: (t) => ({ width: t.length * 5 }), + fillText(text, x, y) { drawn.push([text, y]); }, + }; + words.drawSides(ctx, 10, 100, words.SIDES.battery, "ink", "panel"); + assert.deepEqual(drawn, ["back", ["charge", 93], "back", ["discharge", 107]]); + }); +}); + +describe("the dashboard uses the words where a power goes both ways", () => { + const app = read("app.js"); + const helpers = ["powerWords", "formatW", "formatPower", "liveStatParts", "updateLiveStat", "batteryTargetLine", "tooltipPowerText", "batteryRowParts"] + .map((name) => fn(app, name)).join("\n"); + + function dashboard() { + const els = {}; + // Just enough of an element: a child's text joins its parent's. + const element = () => ({ + textContent: "", className: "", + appendChild(child) { this.textContent += child.textContent; }, + }); + const document = { getElementById: (id) => (els[id] ||= element()), createElement: element }; + return { els, ...run(helpers, { ftwPowerWords: words, document }) }; + } + + it("writes the live strip with a word for the grid and the battery", () => { + const d = dashboard(); + d.updateLiveStat("bat", -1400, "is-discharging"); + d.updateLiveStat("grid", -500, "is-export"); + d.updateLiveStat("load", 900, "is-neutral"); + assert.equal(d.els["live-stat-bat"].textContent, "1.4 kW discharge"); + assert.equal(d.els["live-stat-grid"].textContent, "500 W export"); + assert.equal(d.els["live-stat-load"].textContent, "900 W"); + d.updateLiveStat("bat", 1400, "is-charging"); + assert.equal(d.els["live-stat-bat"].textContent, "1.4 kW charge"); + }); + + it("writes the battery target and the tooltip rows with their words", () => { + const d = dashboard(); + assert.equal(d.batteryTargetLine(-1500), "target 1.5 kW discharge"); + assert.deepEqual({ ...d.tooltipPowerText({ name: "Grid", data: [-500] }, 0) }, { actual: "500 W export", target: "" }); + assert.deepEqual({ ...d.tooltipPowerText({ name: "Sungrow", data: [-1100], target: [-1200] }, 0) }, + { actual: "1.1 kW discharge", target: "→ 1.2 kW" }); + assert.deepEqual({ ...d.tooltipPowerText({ name: "Sungrow", data: [-1100], target: [800] }, 0) }, + { actual: "1.1 kW discharge", target: "→ 800 W charge" }); + assert.deepEqual({ ...d.tooltipPowerText({ name: "Easee EV", data: [3000] }, 0) }, { actual: "3.0 kW", target: "" }); + assert.deepEqual({ ...d.batteryRowParts(-700, -1000) }, { actual: "700 W discharge", target: "→ 1.0 kW", tracking: "300 W short" }); + assert.deepEqual({ ...d.batteryRowParts(1300, -1000) }, { actual: "1.3 kW charge", target: "→ 1.0 kW discharge", tracking: "300 W over" }); }); - it("marks the side of zero where a power goes both ways", () => { - assert.equal(live.formatFlowW(1200), "1.2 kW ↑"); - assert.equal(live.formatFlowW(-1200), "1.2 kW ↓"); - assert.equal(live.formatFlowW(-500), "500 W ↓"); - assert.equal(live.formatFlowW(5), "5 W"); - assert.equal(live.formatFlowW(-5), "5 W"); + it("never prints a signed grid or battery power as a bare size", () => { + // Each of these can be negative and has no word beside it on screen. + // (The V2X confirm prints formatW(powerW) after the word "Discharge".) + const signed = ["batWVal", "meterW", "d.target_w", "disp.target_w", "v2xWVal", "sw", "dev", + "data.grid_target_w", "Number(gridTargetSlider.value)", "targetW", "t", "d.v2x_dc_w"]; + for (const expr of signed) { + assert.ok(!app.includes(`formatW(${expr})`), `formatW(${expr}) drops the direction`); + } + // The grid and battery cards print the size and set the word on the next lines. + for (const [value, word] of [["data.grid_w", "gridDir"], ["data.bat_w", "batDir"]]) { + const at = app.indexOf(`formatW(${value})`); + assert.ok(at > 0 && app.slice(at, at + 400).includes(`${word}.textContent`), value); + assert.equal(app.indexOf(`formatW(${value})`, at + 1), -1, `${value} is printed bare elsewhere`); + } }); +}); +describe("charts show sizes, never a raw minus", () => { it("labels the plan and diagnose power axes as sizes", () => { for (const path of ["plan.js", "diagnose.js"]) { - const source = read(path); - const { powerAxisLabel } = run(between(source, " function powerAxisLabel(", "\n }\n") + "\n }"); + const { powerAxisLabel } = run(fn(read(path), "powerAxisLabel")); assert.equal(powerAxisLabel(-12700), "12.7kW", path); - assert.equal(powerAxisLabel(12700), "12.7kW", path); for (const w of NEGATIVE) noMinus(powerAxisLabel(w)); - assert.doesNotMatch(source, /fillText\(\s*['"][-−+]['"]\s*\+/, path); } }); - it("says what sits below zero on every chart with a lower side", () => { - // Live power chart. - assert.match(app, /var BELOW_ZERO_LIVE = "↓ export · discharge · solar";/); - assert.match(app, /ctx\.fillText\(BELOW_ZERO_LIVE,/); - // 24 h battery chart: both edges are sizes, the words name the sides. - assert.doesNotMatch(app, /"-" \+ \(batMax/); - assert.match(app, /var BATTERY_SIDES = "↑ charge {3}↓ discharge";/); - assert.match(app, /ctx\.fillText\(BATTERY_SIDES,/); - // Plan power band. - const plan = read("plan.js"); - assert.match(plan, /const POWER_SIDES = '↑ import \/ charge {3}↓ export \/ discharge \/ solar';/); - assert.match(plan, /ctx\.fillText\(POWER_SIDES,/); - // Diagnose chart. - const diagnose = read("diagnose.js"); - assert.match(diagnose, /var POWER_BELOW = '↓ export · discharge · solar';/); - assert.match(diagnose, /ctx\.fillText\(POWER_BELOW,/); + it("starts no chart text with a sign", () => { + for (const path of ["app.js", "plan.js", "diagnose.js"]) { + assert.doesNotMatch(read(path), /fillText\(\s*['"`][-−+]['"`]?\s*\+/, path); + } + }); + + it("draws the words beside zero on every chart with a lower side", () => { + const app = read("app.js"); + assert.match(app, /drawSides\(ctx, pad\.left \+ 6, zeroLabelY, powerWords\(\)\.SIDES\.live,/); + assert.match(app, /drawSides\(ctx, pad\.left \+ 4, batMid, powerWords\(\)\.SIDES\.battery,/); + assert.match(read("plan.js"), /drawSides\(ctx, pad\.l \+ 6, powerYCenter, globalThis\.ftwPowerWords\.SIDES\.plan,/); + assert.match(read("diagnose.js"), /drawSides\(ctx, pad\.l \+ 6, powerMidY, globalThis\.ftwPowerWords\.SIDES\.diagnose,/); + // Loaded before the dashboard script that reads it. + const html = read("index.html"); + assert.ok(html.indexOf('src="/power-words.js"') < html.indexOf('src="/app.js')); + }); + + it("prints the flow bubble as a size, with the word under it", () => { + const { fmtKw } = run(fn(read("components/ftw-energy-flow.js"), "fmtKw")); + assert.equal(fmtKw(-1.2), "1.20 kW"); + assert.equal(fmtKw(-0.012), "12 W"); + assert.equal(fmtKw(-0.0004), "0 W"); }); - it("keeps the words, not the sign, in the plan tooltip and brief", () => { + it("says the battery's direction in the plan tooltip and brief", () => { assert.match(read("plan.js"), /\$\{\(Math\.abs\(a\.battery_w\) \/ 1000\)\.toFixed\(1\)\} kW \(\$\{dir\}\)/); const now = Date.now(); const brief = derivePlanBrief({ @@ -82,7 +188,7 @@ describe("charts show sizes, never a raw minus", () => { status: { mode: "planner_passive_arbitrage", dispatch: [{ driver: "battery", clamped: true, target_w: -1800 }] }, now, }); - assert.match(brief.constraint, /Safety adjusted battery to 1\.8 kW ↓/); + assert.match(brief.constraint, /Safety adjusted battery to 1\.8 kW discharge/); noMinus(brief.constraint); }); }); diff --git a/web/peak-import-ceiling.test.mjs b/web/peak-import-ceiling.test.mjs index c13b3c80b..fcb4a810e 100644 --- a/web/peak-import-ceiling.test.mjs +++ b/web/peak-import-ceiling.test.mjs @@ -9,6 +9,8 @@ import vm from "node:vm"; // ceiling and nothing this browser stored. const source = readFileSync(new URL("./app.js", import.meta.url), "utf8"); +// index.html loads the shared power words before app.js. +const powerWords = readFileSync(new URL("./power-words.js", import.meta.url), "utf8"); function element(id) { const listeners = new Map(); @@ -100,6 +102,7 @@ function rig(status) { window.localStorage = storage; sandbox.globalThis = sandbox; vm.createContext(sandbox); + vm.runInContext(powerWords, sandbox); vm.runInContext(source, sandbox); const settle = () => new Promise((resolve) => setImmediate(resolve)); diff --git a/web/plan-brief.js b/web/plan-brief.js index b11ab28bd..1d126b971 100644 --- a/web/plan-brief.js +++ b/web/plan-brief.js @@ -1,5 +1,9 @@ import { plannedEVWatts } from "./ev-plan.js"; import { fillPlanSoC } from "./plan-soc.js"; +import "./power-words.js"; + +// The same size-and-word rule as the rest of the page. +const powerWords = globalThis.ftwPowerWords; function formatClock(tsMs) { const date = new Date(tsMs); @@ -49,16 +53,6 @@ function actionLabel(action) { return "Keep the battery steady"; } -// A size, never a raw minus; the arrow says the side of zero, as on the -// chart: ↑ into the home (charge), ↓ out of it (discharge, export). -function briefPower(watts) { - const value = Number(watts) || 0; - const size = Math.abs(value) >= 1000 - ? `${(Math.abs(value) / 1000).toFixed(1)} kW` - : `${Math.round(Math.abs(value))} W`; - return size + (value > 10 ? " ↑" : value < -10 ? " ↓" : ""); -} - function fractionOrNull(value) { if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1.001) return null; return Math.min(1, value); @@ -324,7 +318,7 @@ export function derivePlanBrief({ let constraint = cautionOff ? cautionCopy : "No active safety adjustment"; if (clamps.length) { const adjusted = clamps - .map((dispatch) => `${dispatch.driver || "device"} to ${briefPower(dispatch.target_w)}`) + .map((dispatch) => `${dispatch.driver || "device"} to ${powerWords.formatPower(Number(dispatch.target_w) || 0, "battery")}`) .join(", "); constraint = `Safety adjusted ${adjusted} to stay within battery or site limits`; } else if (stale) { diff --git a/web/plan.js b/web/plan.js index caafc5bac..4c43ba3c6 100644 --- a/web/plan.js +++ b/web/plan.js @@ -4,6 +4,7 @@ import { plannedEVWatts, createChargingTimeline } from "./ev-plan.js"; // the middle, SoC + PV line on bottom. Refreshes every 30s. import { derivePlanBrief, unavailablePlannerCopy } from "./plan-brief.js"; +import "./power-words.js"; import { forecastMargins, evShortfallWh } from "./plan-forecast.js"; import { fillPlanSoC } from "./plan-soc.js"; import { setActiveCurrency, toDisplay, unitFor } from "./components/price-units.js"; @@ -69,9 +70,8 @@ import { return w; } - // The power axis shows sizes, never a raw minus; POWER_SIDES says what - // sits above zero and what below. - const POWER_SIDES = '↑ import / charge ↓ export / discharge / solar'; + // The power axis shows sizes, never a raw minus; the words beside the + // centre line say what sits on each side. function powerAxisLabel(w) { return (Math.abs(w) / 1000).toFixed(1) + 'kW'; } @@ -719,13 +719,10 @@ import { ctx.fillText(powerAxisLabel(pMagMax), pad.l - 6, powerY(pMagMax) + 4); ctx.fillText(powerAxisLabel(-pMagMax), pad.l - 6, powerY(-pMagMax) + 4); ctx.textAlign = 'left'; - // "Power" heading + tiny legend for the two sides of zero, since the - // axis labels are sizes. Placed just below the heading at lower - // opacity to read as a subtitle. ctx.fillText('Power', pad.l + 4, powerY0 + 12); - ctx.fillStyle = C.muted; + // The axis labels are sizes; these words say what sits on each side. ctx.font = '9px system-ui, sans-serif'; - ctx.fillText(POWER_SIDES, pad.l + 40, powerY0 + 12); + globalThis.ftwPowerWords.drawSides(ctx, pad.l + 6, powerYCenter, globalThis.ftwPowerWords.SIDES.plan, C.dim, C.panel); ctx.font = '11px system-ui, sans-serif'; // Skip every battery-related draw layer (action band, bars, SoC diff --git a/web/power-words.js b/web/power-words.js new file mode 100644 index 000000000..318013c9c --- /dev/null +++ b/web/power-words.js @@ -0,0 +1,99 @@ +// Power in words. The UI never shows a raw minus: it shows a size, and a +// short word says which way the power goes. Above zero sits what adds to +// the grid meter's reading (import, charge, the home, the car); below sits +// what takes away from it (export, discharge, solar). +// +// One file for every page: index.html loads it as a plain script before +// app.js, and modules import it for its side effect. It has no import or +// export of its own, so both ways read the same globalThis.ftwPowerWords. +(function (root) { + "use strict"; + + // The only direction words, as the phone app uses them. + var WORDS = { + grid: ["import", "export"], + battery: ["charge", "discharge"], + v2x: ["charge", "discharge"], + }; + // Within this of zero a power has no direction worth a word. + var QUIET_W = 10; + + // The size of a power: "500 W", "1.2 kW". Never signed. + function formatW(w) { + if (w == null || !isFinite(w)) return "—"; + var abs = Math.abs(w); + if (abs >= 1000) return (abs / 1000).toFixed(1) + " kW"; + return Math.round(abs) + " W"; + } + + // "import" or "export" for the grid, "charge" or "discharge" for a + // battery or a two-way charger; "" near zero or for anything else. + function directionWord(w, kind) { + var words = WORDS[kind]; + if (!words || w == null || !isFinite(w) || Math.abs(w) <= QUIET_W) return ""; + return w > 0 ? words[0] : words[1]; + } + + // A two-way power as a size and its word: "1.4 kW discharge". + function formatPower(w, kind) { + var word = directionWord(w, kind); + return formatW(w) + (word ? " " + word : ""); + } + + // A target beside a reading: its size, and its word only when it goes + // the other way from the reading, so "→ 1.2 kW" rather than repeating + // "discharge". + function formatTarget(targetW, actualW, kind) { + var word = directionWord(targetW, kind); + return "→ " + (word && word !== directionWord(actualW, kind) ? formatPower(targetW, kind) : formatW(targetW)); + } + + // How far a device is from its target, by size: "300 W short" when it + // moves less power than asked, "300 W over" when more. + function trackingText(actualW, targetW) { + var gap = Math.abs(Number(actualW) || 0) - Math.abs(Number(targetW) || 0); + if (Math.abs(gap) <= QUIET_W) return "on target"; + return formatW(gap) + (gap < 0 ? " short" : " over"); + } + + // What each chart draws above and below zero, where its axis labels are + // sizes. One separator everywhere. + var SIDES = { + live: { above: "import · charge · home · car", below: "export · discharge · solar" }, + battery: { above: "charge", below: "discharge" }, + plan: { above: "charge · home · car", below: "discharge · solar" }, + diagnose: { above: "import · charge · car", below: "export · discharge · solar" }, + }; + + // Write a chart's two sides beside its zero line, at x. Each word sits on + // a backing in the panel colour, so a series line under it cannot make it + // unreadable. The caller sets the font. + function drawSides(ctx, x, zeroY, sides, ink, panel) { + var rows = [[sides.above, zeroY - 7], [sides.below, zeroY + 7]]; + ctx.save(); + ctx.textAlign = "left"; + ctx.textBaseline = "middle"; + for (var i = 0; i < rows.length; i++) { + var text = rows[i][0]; + var y = rows[i][1]; + var w = ctx.measureText(text).width; + ctx.globalAlpha = 0.85; + ctx.fillStyle = panel; + ctx.fillRect(x - 3, y - 6, w + 6, 12); + ctx.globalAlpha = 1; + ctx.fillStyle = ink; + ctx.fillText(text, x, y); + } + ctx.restore(); + } + + root.ftwPowerWords = { + formatW: formatW, + directionWord: directionWord, + formatPower: formatPower, + formatTarget: formatTarget, + trackingText: trackingText, + SIDES: SIDES, + drawSides: drawSides, + }; +})(globalThis); From cd68b44947322eb1da3b0a86310e15b5121896a3 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 26 Oct 2026 08:12:14 +0100 Subject: [PATCH 3/3] web: measure the gap along the target, keep Home use signed Second review round on the no-raw-minus change. - trackingText measures along the target's direction, so a battery charging 1.3 kW against a 1.0 kW discharge target is "2.3 kW short". The driver card's warning colour uses the same gap. - Home use keeps Core's sign in the flow hub, the Values card, the live strip, the live and forecast tooltips until a word is chosen (#1768). The flow's planets print sizes. - An observed battery keeps its direction word; "observe only" moves to the title's second line ("observed" on a phone), because both do not fit one line of the bubble. - The plan draws its side words after the bars and the SoC line, and says "load" instead of "home". Its grid tooltip row and the charger card's battery column use the words and the 10 W rule. - The slot badge uses the flow's arrows: down import, up export. - app.js falls back to sizes if power-words.js fails to load; the unused formatSignedWh and batterySub are gone. - Tests: opposite-sign tracking, the forecast row on both signs, a ban on hand-made "W" strings for signed values, and a check that knows every fillText on the charts and why none starts with a sign. Co-Authored-By: Claude Opus 5.5 --- .changeset/no-raw-minus-charts.md | 2 +- web/app.js | 67 +++++--- web/components/energy-flow-readings.js | 11 +- web/components/ftw-energy-flow.js | 36 +++-- web/diagnose.js | 3 +- web/loadpoints.js | 10 +- web/no-raw-minus.test.mjs | 206 ++++++++++++++++++++++--- web/plan.js | 13 +- web/power-words.js | 32 +++- 9 files changed, 299 insertions(+), 81 deletions(-) diff --git a/.changeset/no-raw-minus-charts.md b/.changeset/no-raw-minus-charts.md index 8da65e35a..13ad98078 100644 --- a/.changeset/no-raw-minus-charts.md +++ b/.changeset/no-raw-minus-charts.md @@ -2,4 +2,4 @@ "ftw": patch --- -The dashboard no longer prints a minus sign on power. Charts label their axes with sizes and name what sits on each side of zero: above it what adds to the grid meter's reading (import, charge, the home, the car), below it what takes away (export, discharge, solar). Grid and battery values say their direction in a word, such as "500 W export" or "1.4 kW discharge", in tooltips, the live strip, targets, driver cards, the per-phase watts under the fuse bars and the plan's safety note. The Overview flow shows the battery's size with "charging" or "discharging" under it. +The dashboard no longer prints a minus sign on grid and battery power. Charts label their axes with sizes and name what sits on each side of zero: above it what adds to the grid meter's reading (import, charge, load, the car), below it what takes away (export, discharge, solar). Grid and battery values say their direction in a word, such as "500 W export" or "1.4 kW discharge", in tooltips, the live strip, targets, driver cards, the charger card's plan table, the per-phase watts under the fuse bars and the plan's safety note. A battery that misses its target reads "short" or "over" along the target's direction. The Overview flow shows each device's size with "charging" or "discharging" under it, and a battery FTW only observes says so under its title. Home use keeps its sign for now. diff --git a/web/app.js b/web/app.js index bc86f127c..fbbc0721a 100644 --- a/web/app.js +++ b/web/app.js @@ -311,8 +311,26 @@ const versionEl = $("version"); // ---- Formatting ---- // The UI never shows a raw minus. power-words.js, loaded before this file, - // gives a size and the word for its direction; it is read when used. - function powerWords() { return globalThis.ftwPowerWords; } + // gives a size and the word for its direction; it is read when used. If + // that file failed to load, the page still draws, with sizes and no words. + function powerWords() { return globalThis.ftwPowerWords || SIZES_ONLY; } + var SIZES_ONLY = (function () { + function size(w) { + if (w == null || !isFinite(w)) return "—"; + var abs = Math.abs(w); + return abs >= 1000 ? (abs / 1000).toFixed(1) + " kW" : Math.round(abs) + " W"; + } + var none = { above: "", below: "" }; + return { + formatW: size, formatSignedW: size, formatPower: size, + formatTarget: function (w) { return "→ " + size(w); }, + directionWord: function () { return ""; }, + trackingGap: function (a, t) { return (Number(a) || 0) - (Number(t) || 0); }, + trackingText: function () { return ""; }, + SIDES: { live: none, battery: none, plan: none, diagnose: none }, + drawSides: function () {}, + }; + })(); function formatW(w) { return powerWords().formatW(w); } function formatPower(w, kind) { return powerWords().formatPower(w, kind); } @@ -351,14 +369,6 @@ if (kwh >= 10) return kwh.toFixed(1) + " kWh"; return kwh.toFixed(2) + " kWh"; } - function formatSignedWh(wh) { - if (wh == null) return "Unknown"; - var v = Number(wh) || 0; - var abs = Math.abs(v); - var sign = v > 0.5 ? "+" : v < -0.5 ? "-" : "±"; - if (abs >= 1000) return sign + (abs / 1000).toFixed(2) + " kWh"; - return sign + Math.round(abs) + " Wh"; - } function formatWhMagnitude(wh) { if (wh == null) return "Unknown"; var abs = Math.abs(Number(wh) || 0); @@ -379,8 +389,10 @@ return "is-neutral"; } // The live strip's value: grid and battery go both ways, so a word says - // which, on its own line; solar, home and car do not. + // which, on its own line; solar and car do not. Home use keeps Core's + // sign (see formatSignedW). function liveStatParts(key, w) { + if (key === "load") return { size: powerWords().formatSignedW(w), word: "" }; var kind = key === "grid" ? "grid" : key === "bat" ? "battery" : null; return { size: formatW(w), word: kind ? powerWords().directionWord(w, kind) : "" }; } @@ -412,16 +424,29 @@ } // A live tooltip row's value and target. The grid and batteries go both - // ways, so their rows carry a word; the other rows are sizes. + // ways, so their rows carry a word; Home use keeps Core's sign; the other + // rows are sizes. function tooltipPowerText(lab, i) { var kind = lab.name === "Grid" ? "grid" : "target" in lab ? "battery" : null; - var actual = kind ? formatPower(lab.data[i], kind) : formatW(lab.data[i]); + var actual = kind ? formatPower(lab.data[i], kind) + : lab.name === "Load" ? powerWords().formatSignedW(lab.data[i]) + : formatW(lab.data[i]); var target = lab.target && i < lab.target.length && Math.abs(lab.target[i]) > 1 ? powerWords().formatTarget(lab.target[i], lab.data[i], kind || "") : ""; return { actual: actual, target: target }; } + // A forecast tooltip row: the plan's battery and grid say their + // direction, predicted Home use keeps its sign like the live value, and + // the other rows are sizes or text as given. + function forecastRowText(lab) { + if (lab.literal) return lab.val; + if (lab.showSign) return formatPower(lab.val, lab.name === "Grid" ? "grid" : "battery"); + if (lab.name === "Load pred") return powerWords().formatSignedW(lab.val); + return formatW(lab.val); + } + // A driver card's battery row: the reading, its target and how far apart // they are. function batteryRowParts(actualW, targetW) { @@ -570,7 +595,7 @@ queueFlowReplay(); return; } - var opts = energyFlowOpts(data); opts.live = lastFlowLive; + var opts = energyFlowOpts(); opts.live = lastFlowLive; lastFlowReadings = window.ftwFlowReadingsFromStatus(data, opts); (lastFlowReadings.planets || []).forEach(function (p) { if (p.role !== "ev" || p.placeholder || !p.name) return; @@ -690,7 +715,8 @@ // directions so the operator sees the slot's true exposure. var impWh = slot.import_wh; var expWh = slot.export_wh; - slotBadge.textContent = "15m ↑" + formatWhMagnitude(impWh) + " ↓" + formatWhMagnitude(expWh); + // The same arrows as the flow's daily line: ↓ import, ↑ export. + slotBadge.textContent = "15m ↓" + formatWhMagnitude(impWh) + " ↑" + formatWhMagnitude(expWh); slotBadge.className = "grid-slot-badge" + (impWh > expWh + 5 ? " slot-import" : expWh > impWh + 5 ? " slot-export" : ""); } else { @@ -726,7 +752,7 @@ } // Load - loadW.textContent = formatW(data.load_w); + loadW.textContent = powerWords().formatSignedW(data.load_w); var loadDir = document.getElementById("load-dir"); if (loadDir) { loadDir.textContent = data.load_w == null || !isFinite(data.load_w) ? "no data" @@ -1765,10 +1791,7 @@ ctx.fillText(lab.name, boxX + 18, y); ctx.fillStyle = C.text; ctx.textAlign = "right"; - var val = lab.literal ? lab.val - : lab.showSign ? formatPower(lab.val, lab.name === "Grid" ? "grid" : "battery") - : formatW(lab.val); - ctx.fillText(val, boxX + boxW - 6, y); + ctx.fillText(forecastRowText(lab), boxX + boxW - 6, y); ctx.textAlign = "left"; }); @@ -2085,7 +2108,9 @@ ' Battery' + formatPower(batWVal, "battery") + ""; var disp = (dispatchByDriver || {})[name]; if (disp && d.bat_w != null && !d.observe_only) { - var dev = batWVal - disp.target_w; + // The same gap the words below describe, so a warning colour + // never sits on "on target". + var dev = powerWords().trackingGap(batWVal, disp.target_w); var devClass = Math.abs(dev) > 200 ? "stat-warn" : "stat-dim"; var row = batteryRowParts(batWVal, disp.target_w); batteryRow = diff --git a/web/components/energy-flow-readings.js b/web/components/energy-flow-readings.js index a251506fc..af4f1e30a 100644 --- a/web/components/energy-flow-readings.js +++ b/web/components/energy-flow-readings.js @@ -73,11 +73,9 @@ function placeholderPlanet(partial) { * @param {object} status GET /api/status body * @param {object} [opts] * @param {number} [opts.idleW] - * @param {(name: string, driver: object, batW: number) => string} [opts.batterySub] */ export function flowReadingsFromStatus(status, opts) { const thresholdW = (opts && opts.idleW) || idleW(); - const batterySub = opts && opts.batterySub; const planets = []; const today = (status && status.energy && status.energy.today) || {}; const importWh = num(today.import_wh); @@ -155,10 +153,10 @@ export function flowReadingsFromStatus(status, opts) { if (batW !== null) { const bIdle = isIdle(batW, thresholdW); const soc = num(d.bat_soc); - const defaultSub = d.observe_only === true - ? "observe only" - : bIdle ? "idle" : batW >= 0 ? "charging" : "discharging"; - const sub = batterySub ? batterySub(name, d, batW) : defaultSub; + // The bubble prints a size, so this line says the direction. The + // bubble has no room for "observe only" on the same line; the + // component puts it under the title. + const sub = bIdle ? "idle" : batW >= 0 ? "charging" : "discharging"; const planet = { id: `bat-${name}`, corner: "top-right", title: "BATTERY", role: "battery", name, kw: batW / 1000, toHub: batW < 0, @@ -169,6 +167,7 @@ export function flowReadingsFromStatus(status, opts) { dailyScope: "aggregate", dailyAggregateMembers: batDailyMembers, clickable: d.observe_only !== true, + observeOnly: d.observe_only === true, }; if (online) { live.battery = true; diff --git a/web/components/ftw-energy-flow.js b/web/components/ftw-energy-flow.js index aba00ee69..4ca98e4c6 100644 --- a/web/components/ftw-energy-flow.js +++ b/web/components/ftw-energy-flow.js @@ -653,12 +653,21 @@ class FtwEnergyFlow extends FtwElement { !p.aggregated && (layerClass === "ind" || aggregateMemberCount > 1) && Math.max(p._groupSize || 1, aggregateMemberCount || 1) > 1; + const grouped = p._groupSize > 1 && !!p.name; return renderCircleNode({ pos: p._pos, - value: p.placeholder ? "—" : fmtKw(p.kw), + // A planet prints its size; the word under it gives the direction, + // because the UI never shows a raw minus. The hub keeps Core's sign + // for Home use (#1768). + value: p.placeholder ? "—" : fmtKw(Math.abs(p.kw)), title: p.title, - nameLabel: p._groupSize > 1 && p.name ? p.name.toUpperCase() : null, - sub: p.sub, + // The title's second line names the device in a group, or says + // that FTW only observes it; a phone's larger title font has room + // for one word. A grouped device keeps its name there and says + // "observe only" under the size instead. + nameLabel: grouped ? p.name.toUpperCase() + : p.observeOnly ? (this._compact ? "OBSERVED" : "OBSERVE ONLY") : null, + sub: grouped && p.observeOnly ? "observe only" : p.sub, color: p.color, soc: p.placeholder ? null : p.soc, chargeLimit: p.placeholder ? null : p.chargeLimit, @@ -1662,19 +1671,18 @@ function renderDailyParts(parts) { } function fmtKw(kw) { - // Input is kilowatts, signed as Core sends them; the bubble prints the - // size only, because the UI never shows a raw minus. The word under it - // ("charging", "discharging", "exporting") says the direction. Sub-kW - // values render as plain integer watts (no decimals) so a "30 W" import - // or a "12 W" battery trickle stays visible as the real number. The - // visual "idle / balanced" affordances (beam dimming, sub-label text) - // live on a separate ±FLOW_IDLE_W threshold; this formatter only handles - // display. + // Input is kilowatts: a size for a planet, Home use as Core signs it + // for the hub. Sub-kW values render as plain integer watts (no + // decimals) so a "30 W" import or "12 W" battery trickle stays + // visible as the real number. The visual "idle / balanced" + // affordances (beam dimming, sub-label text) live on a separate + // ±FLOW_IDLE_W threshold; this formatter only handles display. // // Sub-half-watt magnitudes round to 0 W — that's noise, not signal. - const size = Math.abs(kw); - if (size < 1) return `${Math.round(size * 1000)} W`; - return `${size.toFixed(2)} kW`; + // `+ 0` normalises Math.round's −0 result to plain 0 so a tiny + // negative reading doesn't print as "-0 W". + if (Math.abs(kw) < 1) return `${Math.round(kw * 1000) + 0} W`; + return `${kw.toFixed(2)} kW`; } // Collapse multi-planet corners into a single synthesized "combined" diff --git a/web/diagnose.js b/web/diagnose.js index 95945598d..3aa0ec516 100644 --- a/web/diagnose.js +++ b/web/diagnose.js @@ -605,7 +605,8 @@ ctx.fillText('0', pad.l - 4, powerMidY + 3); ctx.fillText(powerAxisLabel(-maxAbsPower), pad.l - 4, powerY0 + powerH - 2); // The power labels are sizes; these words say what sits on each side. - globalThis.ftwPowerWords.drawSides(ctx, pad.l + 6, powerMidY, globalThis.ftwPowerWords.SIDES.diagnose, C.dim, C.panel); + const words = globalThis.ftwPowerWords; + if (words) words.drawSides(ctx, pad.l + 6, powerMidY, words.SIDES.diagnose, C.dim, C.panel); ctx.fillText('100%', pad.l - 4, socY0 + 10); ctx.fillText('0', pad.l - 4, socY0 + socH - 2); diff --git a/web/loadpoints.js b/web/loadpoints.js index 031159e0d..47254c283 100644 --- a/web/loadpoints.js +++ b/web/loadpoints.js @@ -43,6 +43,14 @@ return Math.round(w) + ' W'; } + // The plan's battery power as a size and its word ("1.2 kW discharge"), + // never a raw minus; signed, as before, only if power-words.js is missing. + function batteryW(w) { + const words = globalThis.ftwPowerWords; + if (w == null || !isFinite(w)) return '—'; + return words ? words.formatPower(w, 'battery') : fmtW(w); + } + function fmtPct(frac) { if (frac == null || !isFinite(frac)) return '—'; return (frac * 100).toFixed(1) + '%'; @@ -215,7 +223,7 @@ `${fmtPriceOre(a.price_ore)}` + `${fmtW(a.loadpoint_w || 0)}` + `${fmtPct(a.loadpoint_soc)}` + - `${fmtW(a.battery_w)}` + + `${batteryW(a.battery_w)}` + `${escapeHtml(a.reason || '')}` + ''; }).join(''); diff --git a/web/no-raw-minus.test.mjs b/web/no-raw-minus.test.mjs index 651309650..3a6777fb4 100644 --- a/web/no-raw-minus.test.mjs +++ b/web/no-raw-minus.test.mjs @@ -4,10 +4,12 @@ import { describe, it } from "node:test"; import vm from "node:vm"; import "./power-words.js"; +import { flowReadingsFromStatus } from "./components/energy-flow-readings.js"; import { derivePlanBrief } from "./plan-brief.js"; // FTW's rule: the UI never shows a raw minus. It shows a size, and a short -// word says the direction: import or export, charge or discharge. +// word says the direction: import or export, charge or discharge. Home use +// keeps Core's sign for now (#1768); see formatSignedW. const words = globalThis.ftwPowerWords; const read = (path) => readFileSync(new URL(`./${path}`, import.meta.url), "utf8"); @@ -16,19 +18,46 @@ const read = (path) => readFileSync(new URL(`./${path}`, import.meta.url), "utf8 function fn(source, name) { const start = source.search(new RegExp(`function ${name}\\(`)); assert.ok(start >= 0, `function ${name} not found`); - const open = source.indexOf("{", start); + return closed(source, start, source.indexOf("{", start)); +} +function closed(source, start, open) { let depth = 0; for (let i = open; i < source.length; i++) { if (source[i] === "{") depth++; else if (source[i] === "}" && --depth === 0) return source.slice(start, i + 1); } - throw new Error(`function ${name} is not closed`); + throw new Error("not closed"); } function run(code, context = {}) { vm.createContext(context); vm.runInContext(code, context); return context; } +// The first argument of every fillText call, read with brackets and quotes +// balanced. +function fillTextArgs(source) { + const out = []; + let at = 0; + while ((at = source.indexOf("fillText(", at)) >= 0) { + let i = at + "fillText(".length; + const start = i; + let depth = 0; + let quote = null; + for (; i < source.length; i++) { + const c = source[i]; + if (quote) { + if (c === "\\") i++; + else if (c === quote) quote = null; + } else if (c === "'" || c === '"' || c === "`") quote = c; + else if ("([{".includes(c)) depth++; + else if (")]}".includes(c)) { if (depth === 0) break; depth--; } + else if (c === "," && depth === 0) break; + } + out.push(source.slice(start, i).trim()); + at = i; + } + return out; +} const NEGATIVE = [-0.4, -5, -10.5, -950, -999.6, -1000, -1234, -12700]; const noMinus = (text) => assert.doesNotMatch(String(text), /[-−]/, String(text)); const noArrow = (text) => assert.doesNotMatch(String(text), /[↑↓]/, String(text)); @@ -48,7 +77,7 @@ describe("power in words", () => { [500, "grid", "500 W import"], [-500, "grid", "500 W export"], [1400, "battery", "1.4 kW charge"], [-1400, "battery", "1.4 kW discharge"], [3000, "v2x", "3.0 kW charge"], [-3000, "v2x", "3.0 kW discharge"], - [8, "grid", "8 W"], [-8, "battery", "8 W"], [-1400, "solar", "1.4 kW"], + [8, "grid", "8 W"], [-8, "battery", "8 W"], [0, "grid", "0 W"], [-1400, "solar", "1.4 kW"], ]; for (const [w, kind, want] of cases) { assert.equal(words.formatPower(w, kind), want, `${w} ${kind}`); @@ -57,27 +86,42 @@ describe("power in words", () => { } }); + it("keeps Core's sign on Home use until a word is chosen for it", () => { + assert.equal(words.formatSignedW(-300), "-300 W"); + assert.equal(words.formatSignedW(-1234), "-1.2 kW"); + assert.equal(words.formatSignedW(900), "900 W"); + assert.equal(words.formatSignedW(-0.3), "0 W"); + }); + it("names a target's direction only when it differs from the reading's", () => { assert.equal(words.formatTarget(-1200, -1100, "battery"), "→ 1.2 kW"); assert.equal(words.formatTarget(800, -1100, "battery"), "→ 800 W charge"); assert.equal(words.formatTarget(-2000, 5, "grid"), "→ 2.0 kW export"); }); - it("says how far a device is from its target", () => { + it("measures how far a device is from its target along the target's direction", () => { assert.equal(words.trackingText(-700, -1000), "300 W short"); assert.equal(words.trackingText(1300, 1000), "300 W over"); + assert.equal(words.trackingText(-1300, -1000), "300 W over"); assert.equal(words.trackingText(-1005, -1000), "on target"); + // Going the wrong way is short by the whole distance. + assert.equal(words.trackingText(1300, -1000), "2.3 kW short"); + assert.equal(words.trackingText(1000, -1000), "2.0 kW short"); + assert.equal(words.trackingText(-1000, 1000), "2.0 kW short"); + // A target near zero has no direction; the reading's size is compared. + assert.equal(words.trackingText(-500, 5), "495 W over"); + assert.equal(words.trackingGap(1300, -1000), -2300); }); it("names both sides of every chart in words, with one separator", () => { for (const [chart, sides] of Object.entries(words.SIDES)) { for (const text of [sides.above, sides.below]) { noArrow(text); - assert.doesNotMatch(text, /[/,;]/, `${chart}: ${text}`); + assert.doesNotMatch(text, /[/,;]|home/, `${chart}: ${text}`); } } assert.equal(words.SIDES.live.below, "export · discharge · solar"); - assert.equal(words.SIDES.live.above, "import · charge · home · car"); + assert.equal(words.SIDES.live.above, "import · charge · load · car"); const drawn = []; const ctx = { save() {}, restore() {}, fillRect() { drawn.push("back"); }, measureText: (t) => ({ width: t.length * 5 }), @@ -90,10 +134,15 @@ describe("power in words", () => { describe("the dashboard uses the words where a power goes both ways", () => { const app = read("app.js"); - const helpers = ["powerWords", "formatW", "formatPower", "liveStatParts", "updateLiveStat", "batteryTargetLine", "tooltipPowerText", "batteryRowParts"] - .map((name) => fn(app, name)).join("\n"); + const helpers = ["powerWords", "formatW", "formatPower", "liveStatParts", "updateLiveStat", "batteryTargetLine", + "tooltipPowerText", "forecastRowText", "batteryRowParts"].map((name) => fn(app, name)).join("\n"); + const sizesOnly = (() => { + const start = app.indexOf(" var SIZES_ONLY = (function () {"); + assert.ok(start >= 0, "SIZES_ONLY"); + return closed(app, start, app.indexOf("{", start)) + ")();"; + })(); - function dashboard() { + function dashboard(context = { ftwPowerWords: words }) { const els = {}; // Just enough of an element: a child's text joins its parent's. const element = () => ({ @@ -101,7 +150,7 @@ describe("the dashboard uses the words where a power goes both ways", () => { appendChild(child) { this.textContent += child.textContent; }, }); const document = { getElementById: (id) => (els[id] ||= element()), createElement: element }; - return { els, ...run(helpers, { ftwPowerWords: words, document }) }; + return { els, ...run(sizesOnly + "\n" + helpers, { ...context, document }) }; } it("writes the live strip with a word for the grid and the battery", () => { @@ -114,9 +163,12 @@ describe("the dashboard uses the words where a power goes both ways", () => { assert.equal(d.els["live-stat-load"].textContent, "900 W"); d.updateLiveStat("bat", 1400, "is-charging"); assert.equal(d.els["live-stat-bat"].textContent, "1.4 kW charge"); + // Home use below zero keeps its sign. + d.updateLiveStat("load", -300, "is-neutral"); + assert.equal(d.els["live-stat-load"].textContent, "-300 W"); }); - it("writes the battery target and the tooltip rows with their words", () => { + it("writes the targets, tooltip rows and driver rows with their words", () => { const d = dashboard(); assert.equal(d.batteryTargetLine(-1500), "target 1.5 kW discharge"); assert.deepEqual({ ...d.tooltipPowerText({ name: "Grid", data: [-500] }, 0) }, { actual: "500 W export", target: "" }); @@ -125,24 +177,63 @@ describe("the dashboard uses the words where a power goes both ways", () => { assert.deepEqual({ ...d.tooltipPowerText({ name: "Sungrow", data: [-1100], target: [800] }, 0) }, { actual: "1.1 kW discharge", target: "→ 800 W charge" }); assert.deepEqual({ ...d.tooltipPowerText({ name: "Easee EV", data: [3000] }, 0) }, { actual: "3.0 kW", target: "" }); + assert.deepEqual({ ...d.tooltipPowerText({ name: "Load", data: [-300] }, 0) }, { actual: "-300 W", target: "" }); assert.deepEqual({ ...d.batteryRowParts(-700, -1000) }, { actual: "700 W discharge", target: "→ 1.0 kW", tracking: "300 W short" }); - assert.deepEqual({ ...d.batteryRowParts(1300, -1000) }, { actual: "1.3 kW charge", target: "→ 1.0 kW discharge", tracking: "300 W over" }); + assert.deepEqual({ ...d.batteryRowParts(1300, -1000) }, { actual: "1.3 kW charge", target: "→ 1.0 kW discharge", tracking: "2.3 kW short" }); + // The warning colour follows the same gap as the words. + assert.match(app, /var dev = powerWords\(\)\.trackingGap\(batWVal, disp\.target_w\);/); + }); + + it("writes the forecast tooltip's battery and grid with their words", () => { + const d = dashboard(); + assert.equal(d.forecastRowText({ name: "Battery", val: -1700, showSign: true }), "1.7 kW discharge"); + assert.equal(d.forecastRowText({ name: "Battery", val: 1700, showSign: true }), "1.7 kW charge"); + assert.equal(d.forecastRowText({ name: "Grid", val: -500, showSign: true }), "500 W export"); + assert.equal(d.forecastRowText({ name: "Grid", val: 500, showSign: true }), "500 W import"); + assert.equal(d.forecastRowText({ name: "PV pred", val: 2100 }), "2.1 kW"); + assert.equal(d.forecastRowText({ name: "Load pred", val: -300 }), "-300 W"); + assert.equal(d.forecastRowText({ name: "SoC", val: "64%", literal: true }), "64%"); + }); + + it("still draws sizes if power-words.js did not load", () => { + const d = dashboard({}); + d.updateLiveStat("bat", -1400, "is-discharging"); + assert.equal(d.els["live-stat-bat"].textContent, "1.4 kW"); + assert.equal(d.batteryTargetLine(-1500), "target 1.5 kW"); + assert.equal(d.forecastRowText({ name: "Grid", val: -500, showSign: true }), "500 W"); }); it("never prints a signed grid or battery power as a bare size", () => { // Each of these can be negative and has no word beside it on screen. // (The V2X confirm prints formatW(powerW) after the word "Discharge".) + // (The forecast rows go through forecastRowText, tested above.) const signed = ["batWVal", "meterW", "d.target_w", "disp.target_w", "v2xWVal", "sw", "dev", "data.grid_target_w", "Number(gridTargetSlider.value)", "targetW", "t", "d.v2x_dc_w"]; for (const expr of signed) { assert.ok(!app.includes(`formatW(${expr})`), `formatW(${expr}) drops the direction`); } + // Nor as a hand-made "… W", the way the per-phase watts used to print. + // A size taken with Math.abs first is fine. + const handMade = [...app.matchAll(/Math\.round\(([^;\n]*?)\)\s*\+\s*" ?W"/g)] + .map((m) => m[1]).filter((arg) => !/^(abs|Math\.abs\(.*\))$/.test(arg)); + assert.deepEqual(handMade, []); // The grid and battery cards print the size and set the word on the next lines. for (const [value, word] of [["data.grid_w", "gridDir"], ["data.bat_w", "batDir"]]) { const at = app.indexOf(`formatW(${value})`); assert.ok(at > 0 && app.slice(at, at + 400).includes(`${word}.textContent`), value); assert.equal(app.indexOf(`formatW(${value})`, at + 1), -1, `${value} is printed bare elsewhere`); } + assert.ok(app.includes('bvw.textContent = formatPower(sw, "grid");'), "per-phase watts"); + // The live chart's slot badge uses the flow's arrows: ↓ import, ↑ export. + assert.ok(app.includes('"15m ↓" + formatWhMagnitude(impWh) + " ↑" + formatWhMagnitude(expWh)')); + }); + + it("words the plan's battery column on the charger card", () => { + const loadpoints = read("loadpoints.js"); + const { batteryW } = run(fn(loadpoints, "fmtW") + "\n" + fn(loadpoints, "batteryW"), { ftwPowerWords: words }); + assert.equal(batteryW(-1200), "1.2 kW discharge"); + assert.equal(batteryW(900), "900 W charge"); + assert.match(loadpoints, /\$\{batteryW\(a\.battery_w\)\}<\/td>/); }); }); @@ -155,9 +246,51 @@ describe("charts show sizes, never a raw minus", () => { } }); - it("starts no chart text with a sign", () => { - for (const path of ["app.js", "plan.js", "diagnose.js"]) { - assert.doesNotMatch(read(path), /fillText\(\s*['"`][-−+]['"`]?\s*\+/, path); + it("knows every text a chart draws, and none of them starts with a sign", () => { + // Each fillText on these canvases, by its first argument. A new one + // fails here until someone says why it cannot start with a sign. + const known = { + "app.js": { + '"waiting for data..."': "words", + 'chartView === "energy" ? yVal.toFixed(1) + " kWh" : formatW(yVal)': "a size; the energy view skips ticks below zero", + 'chartRange + " ago"': "a range", + '"now"': "words", + ageStr: "live or an age", + timeStr: "a clock", + "lab.name": "a series name", + 'lab.data[i].toFixed(2) + " kWh"': "today's totals, never below zero", + "text.actual": "tooltipPowerText, tested above", + "text.target": "tooltipPowerText, tested above", + 'hh + " predicted"': "a clock", + "forecastRowText(lab)": "tested above; its literal rows are SoC and price", + reason: "the plan's reason", + '"Battery measurements unknown"': "words", + '(batMax / 1000).toFixed(1) + "kW"': "the largest battery size, never below zero", + '"100%"': "words", '"0%"': "words", '"24h ago"': "words", + }, + "plan.js": { + "'No devices yet. Add one in Settings.'": "words", + "fmtHHMM(t)": "a clock", + "toDisplay(priceMax, state.currency).toFixed(0) + ' ' + priceUnit.axis": "the highest price", + "toDisplay(priceMin, state.currency).toFixed(0)": "the lowest price: a price below zero keeps its sign", + "'Price'": "words", "'Power'": "words", "'Battery'": "words", "'SoC'": "words", + "powerAxisLabel(pMagMax)": "a size", "powerAxisLabel(-pMagMax)": "a size", + "'100%'": "words", "'0%'": "words", + }, + "diagnose.js": { + "maxPrice.toFixed(0) + 'ö'": "the highest price, at least 1", + "'0'": "zero", + "powerAxisLabel(maxAbsPower)": "a size", "powerAxisLabel(-maxAbsPower)": "a size", + "'100%'": "words", + "fmtHHMM(slots[i].slot_start_ms)": "a clock", + }, + "power-words.js": { text: "the side words, checked above" }, + }; + for (const [path, allowed] of Object.entries(known)) { + for (const arg of fillTextArgs(read(path))) { + assert.ok(arg in allowed, `${path}: fillText(${arg}) is new; say why it cannot start with a sign`); + assert.doesNotMatch(arg, /^(['"`])[-−+]/, `${path}: ${arg}`); + } } }); @@ -165,22 +298,45 @@ describe("charts show sizes, never a raw minus", () => { const app = read("app.js"); assert.match(app, /drawSides\(ctx, pad\.left \+ 6, zeroLabelY, powerWords\(\)\.SIDES\.live,/); assert.match(app, /drawSides\(ctx, pad\.left \+ 4, batMid, powerWords\(\)\.SIDES\.battery,/); - assert.match(read("plan.js"), /drawSides\(ctx, pad\.l \+ 6, powerYCenter, globalThis\.ftwPowerWords\.SIDES\.plan,/); - assert.match(read("diagnose.js"), /drawSides\(ctx, pad\.l \+ 6, powerMidY, globalThis\.ftwPowerWords\.SIDES\.diagnose,/); + const plan = read("plan.js"); + assert.match(plan, /drawSides\(ctx, pad\.l \+ 6, powerYCenter, globalThis\.ftwPowerWords\.SIDES\.plan,/); + // After the battery bars and the SoC line, so they cannot paint over it. + assert.ok(plan.indexOf("SIDES.plan") > plan.indexOf("ctx.fillText('SoC'"), "plan side words drawn last"); + assert.match(read("diagnose.js"), /words\.drawSides\(ctx, pad\.l \+ 6, powerMidY, words\.SIDES\.diagnose,/); // Loaded before the dashboard script that reads it. const html = read("index.html"); assert.ok(html.indexOf('src="/power-words.js"') < html.indexOf('src="/app.js')); }); - it("prints the flow bubble as a size, with the word under it", () => { - const { fmtKw } = run(fn(read("components/ftw-energy-flow.js"), "fmtKw")); - assert.equal(fmtKw(-1.2), "1.20 kW"); - assert.equal(fmtKw(-0.012), "12 W"); - assert.equal(fmtKw(-0.0004), "0 W"); + it("prints the flow's planets as sizes and keeps the hub's sign", () => { + const flow = read("components/ftw-energy-flow.js"); + assert.match(flow, /value: p\.placeholder \? "—" : fmtKw\(Math\.abs\(p\.kw\)\),/); + assert.match(flow, /fmtKw\(load\)/); + const { fmtKw } = run(fn(flow, "fmtKw")); + assert.equal(fmtKw(Math.abs(-1.2)), "1.20 kW"); + assert.equal(fmtKw(-0.3), "-300 W"); + }); + + it("keeps the direction word under an observed battery and says it is observed", () => { + const battery = (batW, observeOnly) => flowReadingsFromStatus({ + drivers: { battery: { bat_w: batW, bat_soc: 0.5, observe_only: observeOnly, status: "ok" } }, + }).planets.find((p) => p.role === "battery"); + assert.equal(battery(-1500, true).sub, "discharging"); + assert.equal(battery(1500, true).sub, "charging"); + assert.equal(battery(0, true).sub, "idle"); + assert.equal(battery(-1500, true).observeOnly, true); + assert.equal(battery(-1500, false).observeOnly, false); + // "Observe only" and the direction do not fit on one line of the + // bubble, so the component puts "observe only" under the title. + const flow = read("components/ftw-energy-flow.js"); + assert.match(flow, /p\.observeOnly \? \(this\._compact \? "OBSERVED" : "OBSERVE ONLY"\) : null/); + assert.match(flow, /sub: grouped && p\.observeOnly \? "observe only" : p\.sub,/); }); - it("says the battery's direction in the plan tooltip and brief", () => { - assert.match(read("plan.js"), /\$\{\(Math\.abs\(a\.battery_w\) \/ 1000\)\.toFixed\(1\)\} kW \(\$\{dir\}\)/); + it("says the direction in the plan tooltip and brief", () => { + const plan = read("plan.js"); + assert.match(plan, /\$\{\(Math\.abs\(a\.battery_w\) \/ 1000\)\.toFixed\(1\)\} kW \(\$\{dir\}\)/); + assert.match(plan, /\$\{globalThis\.ftwPowerWords\.formatPower\(a\.grid_w, 'grid'\)\}<\/b>/); const now = Date.now(); const brief = derivePlanBrief({ enabled: true, diff --git a/web/plan.js b/web/plan.js index 4c43ba3c6..d4c3ab0d0 100644 --- a/web/plan.js +++ b/web/plan.js @@ -720,10 +720,6 @@ import { ctx.fillText(powerAxisLabel(-pMagMax), pad.l - 6, powerY(-pMagMax) + 4); ctx.textAlign = 'left'; ctx.fillText('Power', pad.l + 4, powerY0 + 12); - // The axis labels are sizes; these words say what sits on each side. - ctx.font = '9px system-ui, sans-serif'; - globalThis.ftwPowerWords.drawSides(ctx, pad.l + 6, powerYCenter, globalThis.ftwPowerWords.SIDES.plan, C.dim, C.panel); - ctx.font = '11px system-ui, sans-serif'; // Skip every battery-related draw layer (action band, bars, SoC // line + axis labels) when the site has no battery reporter. @@ -800,6 +796,12 @@ import { ctx.fillText('SoC', pad.l + 4, socY0 + 12); } + // The axis labels are sizes; these words say what sits on each side. + // Drawn after the bars and the SoC line, so nothing paints over them. + ctx.font = '9px system-ui, sans-serif'; + globalThis.ftwPowerWords.drawSides(ctx, pad.l + 6, powerYCenter, globalThis.ftwPowerWords.SIDES.plan, C.dim, C.panel); + ctx.font = '11px system-ui, sans-serif'; + // ---- Summary ---- // Structured as labeled pieces with `title=` tooltips so every number // in the header is self-explanatory on hover. Was a single flat string @@ -1034,8 +1036,7 @@ import { lines.push(`
Battery${(Math.abs(a.battery_w) / 1000).toFixed(1)} kW (${dir})
`); } if (a.grid_w != null) { - const gdir = a.grid_w > 0 ? 'import' : 'export'; - lines.push(`
Grid${(Math.abs(a.grid_w) / 1000).toFixed(1)} kW ${gdir}
`); + lines.push(`
Grid${globalThis.ftwPowerWords.formatPower(a.grid_w, 'grid')}
`); } const endSoc = socPercent(a.soc); if (endSoc != null) lines.push(`
SoC (end)${endSoc.toFixed(0)}%
`); diff --git a/web/power-words.js b/web/power-words.js index 318013c9c..11cca77c6 100644 --- a/web/power-words.js +++ b/web/power-words.js @@ -1,6 +1,6 @@ // Power in words. The UI never shows a raw minus: it shows a size, and a // short word says which way the power goes. Above zero sits what adds to -// the grid meter's reading (import, charge, the home, the car); below sits +// the grid meter's reading (import, charge, the load, the car); below sits // what takes away from it (export, discharge, solar). // // One file for every page: index.html loads it as a plain script before @@ -26,6 +26,14 @@ return Math.round(abs) + " W"; } + // Home use, signed as Core sends it. A negative value is Core saying it + // cannot see some production (#1768), not ordinary use, so it keeps its + // sign until a word is chosen for it. + function formatSignedW(w) { + if (w == null || !isFinite(w)) return "—"; + return (w < 0 && formatW(w) !== "0 W" ? "-" : "") + formatW(w); + } + // "import" or "export" for the grid, "charge" or "discharge" for a // battery or a two-way charger; "" near zero or for anything else. function directionWord(w, kind) { @@ -48,10 +56,20 @@ return "→ " + (word && word !== directionWord(actualW, kind) ? formatPower(targetW, kind) : formatW(targetW)); } - // How far a device is from its target, by size: "300 W short" when it - // moves less power than asked, "300 W over" when more. + // How far a device is from its target: "300 W short" when it moves less + // power than asked, "300 W over" when more. Measured along the target's + // direction, so a battery that charges against a discharge target is + // short by the whole distance. A target near zero has no direction, and + // the reading's size is compared with it instead. + function trackingGap(actualW, targetW) { + var actual = Number(actualW) || 0; + var target = Number(targetW) || 0; + return Math.abs(target) > QUIET_W + ? Math.sign(target) * (actual - target) + : Math.abs(actual) - Math.abs(target); + } function trackingText(actualW, targetW) { - var gap = Math.abs(Number(actualW) || 0) - Math.abs(Number(targetW) || 0); + var gap = trackingGap(actualW, targetW); if (Math.abs(gap) <= QUIET_W) return "on target"; return formatW(gap) + (gap < 0 ? " short" : " over"); } @@ -59,9 +77,9 @@ // What each chart draws above and below zero, where its axis labels are // sizes. One separator everywhere. var SIDES = { - live: { above: "import · charge · home · car", below: "export · discharge · solar" }, + live: { above: "import · charge · load · car", below: "export · discharge · solar" }, battery: { above: "charge", below: "discharge" }, - plan: { above: "charge · home · car", below: "discharge · solar" }, + plan: { above: "charge · load · car", below: "discharge · solar" }, diagnose: { above: "import · charge · car", below: "export · discharge · solar" }, }; @@ -89,9 +107,11 @@ root.ftwPowerWords = { formatW: formatW, + formatSignedW: formatSignedW, directionWord: directionWord, formatPower: formatPower, formatTarget: formatTarget, + trackingGap: trackingGap, trackingText: trackingText, SIDES: SIDES, drawSides: drawSides,