' +
- '
Meter' + formatW(meterW) + "" +
+ '
Meter' + formatPower(meterW, "grid") + "" +
'
PV' + formatW(-pvWVal) + "" +
batteryRow +
(d.observe_only
@@ -2111,7 +2184,7 @@
item.innerHTML =
'
' + escHtml(d.driver) + "" +
"
" +
- '' + formatW(d.target_w) + "" +
+ '' + formatPower(d.target_w, "battery") + "" +
(d.clamped ? 'CLAMPED' : "") +
"";
dispatchList.appendChild(item);
@@ -2674,7 +2747,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 = formatPower(Number(gridTargetSlider.value), "grid");
gridTargetDirty = true;
if (gridTargetSend) gridTargetSend.disabled = false;
});
@@ -5264,9 +5337,11 @@
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 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);
+ 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);
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 c17acf4b0..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,8 +1671,9 @@ 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
+ // 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.
diff --git a/web/diagnose.js b/web/diagnose.js
index 0c83ce89b..3aa0ec516 100644
--- a/web/diagnose.js
+++ b/web/diagnose.js
@@ -601,9 +601,12 @@
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 what sits on each side.
+ 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);
@@ -616,6 +619,10 @@
}
// ---- Helpers ----
+ // The chart's power axis shows sizes, never a raw minus.
+ 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/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/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
new file mode 100644
index 000000000..3a6777fb4
--- /dev/null
+++ b/web/no-raw-minus.test.mjs
@@ -0,0 +1,350 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+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. 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");
+
+// 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`);
+ 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("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));
+
+describe("power in words", () => {
+ it("gives any power as a size", () => {
+ for (const w of NEGATIVE) {
+ 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"], [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}`);
+ noMinus(words.formatPower(w, kind));
+ noArrow(words.formatPower(w, kind));
+ }
+ });
+
+ 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("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, /[/,;]|home/, `${chart}: ${text}`);
+ }
+ }
+ assert.equal(words.SIDES.live.below, "export · discharge · solar");
+ 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 }),
+ 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", "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(context = { ftwPowerWords: words }) {
+ 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(sizesOnly + "\n" + helpers, { ...context, 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");
+ // 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 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: "" });
+ 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.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: "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>/);
+ });
+});
+
+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 { powerAxisLabel } = run(fn(read(path), "powerAxisLabel"));
+ assert.equal(powerAxisLabel(-12700), "12.7kW", path);
+ for (const w of NEGATIVE) noMinus(powerAxisLabel(w));
+ }
+ });
+
+ 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}`);
+ }
+ }
+ });
+
+ 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,/);
+ 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'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 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,
+ 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 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 4170c09d6..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,13 +53,6 @@ function actionLabel(action) {
return "Keep the battery steady";
}
-function briefPower(watts) {
- const value = Number(watts) || 0;
- return Math.abs(value) >= 1000
- ? `${(value / 1000).toFixed(1)} kW`
- : `${Math.round(value)} W`;
-}
-
function fractionOrNull(value) {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1.001) return null;
return Math.min(1, value);
@@ -321,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 cd486cecc..d4c3ab0d0 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,6 +70,12 @@ import {
return w;
}
+ // 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';
+ }
+
// 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,17 +716,10 @@ 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.
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.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.
@@ -796,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
@@ -1027,11 +1033,10 @@ 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';
- 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
new file mode 100644
index 000000000..11cca77c6
--- /dev/null
+++ b/web/power-words.js
@@ -0,0 +1,119 @@
+// 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 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
+// 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";
+ }
+
+ // 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) {
+ 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: "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 = trackingGap(actualW, targetW);
+ 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 · load · car", below: "export · discharge · solar" },
+ battery: { above: "charge", below: "discharge" },
+ plan: { above: "charge · load · 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,
+ formatSignedW: formatSignedW,
+ directionWord: directionWord,
+ formatPower: formatPower,
+ formatTarget: formatTarget,
+ trackingGap: trackingGap,
+ trackingText: trackingText,
+ SIDES: SIDES,
+ drawSides: drawSides,
+ };
+})(globalThis);
|