From df28e386e4bd175669de86424829745274b2ae0e Mon Sep 17 00:00:00 2001 From: Nick Winans Date: Mon, 11 Jan 2021 14:20:15 -0600 Subject: [PATCH] feat(docs): Add power profiler --- docs/docusaurus.config.js | 7 +- docs/src/components/power-estimate.js | 242 ++++++++++++++++ docs/src/css/power-estimate.css | 71 +++++ docs/src/css/power-profiler.css | 189 +++++++++++++ docs/src/data/power.js | 55 ++++ docs/src/pages/index.js | 2 +- docs/src/pages/power-profiler.js | 387 ++++++++++++++++++++++++++ docs/src/utils/hooks.js | 17 ++ 8 files changed, 968 insertions(+), 2 deletions(-) create mode 100644 docs/src/components/power-estimate.js create mode 100644 docs/src/css/power-estimate.css create mode 100644 docs/src/css/power-profiler.css create mode 100644 docs/src/data/power.js create mode 100644 docs/src/pages/power-profiler.js create mode 100644 docs/src/utils/hooks.js diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index acf51f4a90fd..ab7ec12da65d 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -23,12 +23,17 @@ module.exports = { }, items: [ { - to: "docs/", + to: "docs", activeBasePath: "docs", label: "Docs", position: "left", }, { to: "blog", label: "Blog", position: "left" }, + { + to: "power-profiler", + label: "Power Profiler", + position: "left", + }, { href: "https://github.com/zmkfirmware/zmk", label: "GitHub", diff --git a/docs/src/components/power-estimate.js b/docs/src/components/power-estimate.js new file mode 100644 index 000000000000..0786b525fbed --- /dev/null +++ b/docs/src/components/power-estimate.js @@ -0,0 +1,242 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { displayPower, underglowPower, zmkBase } from "../data/power"; +import "../css/power-estimate.css"; + +const lithiumIonMonthlyDischargePercent = 5; +const lithiumIonAverageVoltage = 3.8; +const lithiumIonDischargeEfficiency = 0.85; +const lithiumIonDischargeEffiecincyRange = 0.05; + +const timeSpentTyping = 0.02; + +const measurementAccuracy = 0.2; // Nordic power profiler kit accuracy + +const batVolt = lithiumIonAverageVoltage; + +const palette = [ + "#bbdefb", + "#90caf9", + "#64b5f6", + "#42a5f5", + "#2196f3", + "#1e88e5", + "#1976d2", +]; + +function formatUsage(usage) { + if (usage > 1000) { + return (usage / 1000).toFixed(1) + "mW"; + } + + return Math.round(usage) + "µW"; +} + +function voltageEquivalentCalc(powerSupply) { + if (powerSupply.type === "LDO") { + return batVolt; + } else if (powerSupply.type === "SWITCHING") { + return powerSupply.outputVoltage / powerSupply.efficiency; + } +} + +function formatMinutes(minutes, precision, floor) { + let message = ""; + let count = 0; + + let units = ["year", "month", "week", "day", "minute"]; + let multiples = [60 * 24 * 365, 60 * 24 * 30, 60 * 24 * 7, 60 * 24, 60, 1]; + + for (let i = 0; i < units.length; i++) { + if (minutes >= multiples[i]) { + const timeCount = floor + ? Math.floor(minutes / multiples[i]) + : Math.ceil(minutes / multiples[i]); + minutes -= timeCount * multiples[i]; + count++; + message += + timeCount + (timeCount > 1 ? ` ${units[i]}s ` : ` ${units[i]} `); + } + + if (count == precision) return message; + } + + return message || "0 minutes"; +} + +function PowerEstimate({ + board, + splitType, + batterymAh, + usage, + underglow, + display, +}) { + if (!board || !board.powerSupply.type || !batterymAh) { + return ( +
+

... {splitType !== "standalone" ? "on " + splitType : ""}

+
+
+
+
+ ); + } + + const powerUsage = []; + let totalUsage = 0; + + const voltageEquivalent = voltageEquivalentCalc(board.powerSupply); + + // Lithium ion self discharge + const lithiumDischarge = + ((parseInt(batterymAh) * 1000 * lithiumIonMonthlyDischargePercent) / + 100 / + 30 / + 24) * + batVolt; + totalUsage += lithiumDischarge; + + powerUsage.push({ + title: "Battery Self Discharge", + usage: lithiumDischarge, + }); + + // Quiescent current + const quiescentTotal = + (parseInt(board.powerSupply.quiescent) + parseInt(board.otherQuiescent)) * + voltageEquivalent; + totalUsage += quiescentTotal; + + powerUsage.push({ + title: "Board Quiescent Usage", + usage: quiescentTotal, + }); + + // ZMK overall usage + const zmkUsage = + (zmkBase[splitType].idle + + (splitType !== "peripheral" + ? zmkBase.hostConnection * usage.bondedQty + : 0)) * + voltageEquivalent * + (1 - usage.percentAsleep); + totalUsage += zmkUsage; + + powerUsage.push({ + title: "ZMK Base Usage", + usage: zmkUsage, + }); + + // ZMK typing usage + const zmkTyping = + zmkBase[splitType].typing * + timeSpentTyping * + voltageEquivalent * + (1 - usage.percentAsleep); + totalUsage += zmkTyping; + + powerUsage.push({ + title: "ZMK Typing Usage", + usage: zmkTyping, + }); + + if (underglow.glowEnabled) { + const underglowUsage = + (underglowPower.firmware + + underglow.glowQuantity * + (underglow.glowBrightness * + (underglowPower.ledOn - underglowPower.ledOff) + + underglowPower.ledOff)) * + voltageEquivalent * + (1 - usage.percentAsleep); + totalUsage += underglowUsage; + + powerUsage.push({ + title: "RGB Underglow", + usage: underglowUsage, + }); + } + + if (display.displayEnabled && display.displayType) { + const { activePercent, active, sleep } = displayPower[display.displayType]; + const displayUsage = + (active * activePercent + sleep * (1 - activePercent)) * + voltageEquivalent * + (1 - usage.percentAsleep); + totalUsage += displayUsage; + + powerUsage.push({ + title: "Display", + usage: displayUsage, + }); + } + + const estimatedMinutes = Math.round( + ((batterymAh * batVolt * lithiumIonDischargeEfficiency * 1000) / + totalUsage) * + 60 + ); + + const estimatedRange = + estimatedMinutes - + Math.round( + ((batterymAh * + batVolt * + (lithiumIonDischargeEfficiency - lithiumIonDischargeEffiecincyRange) * + 1000) / + (totalUsage * (1 + measurementAccuracy))) * + 60 + ); + + return ( +
+

+ {formatMinutes(estimatedMinutes, 2, true)}{" "} + {splitType !== "standalone" ? "on " + splitType : ""} ± + {formatMinutes(estimatedRange, 1, false)} +

+
+ {powerUsage.map((p, i) => ( +
1 ? " rightEl" : "")} + style={{ + width: (p.usage / totalUsage) * 100 + "%", + background: palette[i], + }} + > +
+
+
+ {p.title} - {Math.round((p.usage / totalUsage) * 100) + "%"} +
+
+ ~{formatUsage(p.usage)} estimated avg. consumption +
+
+
+
+ ))} +
+
+ ); +} + +PowerEstimate.propTypes = { + board: PropTypes.Object, + splitType: PropTypes.string, + batterymAh: PropTypes.number, + usage: PropTypes.Object, + underglow: PropTypes.Object, + display: PropTypes.Object, +}; + +export default PowerEstimate; diff --git a/docs/src/css/power-estimate.css b/docs/src/css/power-estimate.css new file mode 100644 index 000000000000..25911cab4ade --- /dev/null +++ b/docs/src/css/power-estimate.css @@ -0,0 +1,71 @@ +.powerEstimate { + margin: 20px 0; +} + +.powerEstimateBar { + height: 64px; + width: 100%; + box-shadow: rgba(0, 0, 0, 0.03) 0px 10px 20px 0px, + rgba(0, 0, 0, 0.1) 0px 1px 4px 0px; + border-radius: 64px; + display: flex; + justify-content: flex-start; + overflow: hidden; +} + +.powerEstimateBarEl { + transition: all 0.2s ease; + flex-grow: 1; +} + +.powerEstimateBarEl.rightEl { + display: flex; + justify-content: flex-end; +} + +.powerEstimateTooltipWrap { + position: absolute; + visibility: hidden; + opacity: 0; + transform: translateY(calc(-100% - 8px)); + transition: opacity 0.2s ease; +} + +.powerEstimateBarEl:hover .powerEstimateTooltipWrap { + visibility: visible; + opacity: 1; +} + +.powerEstimateTooltip { + display: block; + position: relative; + box-shadow: var(--ifm-global-shadow-tl); + width: 260px; + padding: 10px; + border-radius: 4px; + background: var(--ifm-background-surface-color); + transform: translateX(-15px); +} + +.rightEl .powerEstimateTooltip { + transform: translateX(15px); +} + +.powerEstimateTooltip:after { + content: ""; + position: absolute; + top: 100%; + left: 27px; + margin-left: -8px; + width: 0; + height: 0; + border-top: 8px solid var(--ifm-background-surface-color); + border-right: 8px solid transparent; + border-left: 8px solid transparent; +} + +.rightEl .powerEstimateTooltip:after { + left: unset; + right: 27px; + margin-right: -8px; +} diff --git a/docs/src/css/power-profiler.css b/docs/src/css/power-profiler.css new file mode 100644 index 000000000000..195bc239540d --- /dev/null +++ b/docs/src/css/power-profiler.css @@ -0,0 +1,189 @@ +.profilerSection { + margin: 10px 0; + padding: 10px 20px; + background: var(--ifm-background-surface-color); + border-radius: 4px; + box-shadow: rgba(0, 0, 0, 0.03) 0px 10px 20px 0px, + rgba(0, 0, 0, 0.1) 0px 1px 4px 0px; +} + +.profilerInput { + margin-bottom: 12px; +} + +.profilerInput label { + display: block; +} + +.profilerDisclaimer { + padding: 20px 0; + font-size: 14px; +} + +span[tooltip] { + position: relative; +} + +span[tooltip]::before { + content: attr(tooltip); + font-size: 13px; + padding: 5px 10px; + position: absolute; + width: 220px; + border-radius: 4px; + background: var(--ifm-background-surface-color); + opacity: 0; + visibility: hidden; + box-shadow: rgba(0, 0, 0, 0.03) 0px 10px 20px 0px, + rgba(0, 0, 0, 0.1) 0px 1px 4px 0px; + transition: opacity 0.2s ease; + transform: translate(-50%, -100%); + left: 50%; +} + +span[tooltip]::after { + content: ""; + position: absolute; + border-top: 8px solid var(--ifm-background-surface-color); + border-right: 8px solid transparent; + border-left: 8px solid transparent; + width: 0; + height: 0; + opacity: 0; + visibility: hidden; + transition: opacity 0.2s ease; + transform: translateX(-50%); + left: 50%; +} + +span[tooltip]:hover::before { + opacity: 1; + visibility: visible; +} + +span[tooltip]:hover::after { + opacity: 1; + visibility: visible; +} + +input[type="checkbox"].toggleInput { + display: none; +} + +input[type="checkbox"] + .toggle { + margin: 6px 2px; + height: 20px; + width: 48px; + background: rgba(0, 0, 0, 0.5); + border-radius: 20px; + transition: all 0.2s ease; + user-select: none; +} + +input[type="checkbox"] + .toggle > .toggleThumb { + height: 16px; + border-radius: 20px; + transform: translate(2px, 2px); + width: 16px; + background: var(--ifm-color-white); + box-shadow: var(--ifm-global-shadow-lw); + transition: all 0.2s ease; +} + +input[type="checkbox"]:checked + .toggle { + background: var(--ifm-color-primary); +} + +input[type="checkbox"]:checked + .toggle > .toggleThumb { + transform: translate(30px, 2px); +} + +select { + border: solid 1px rgba(0, 0, 0, 0.5); + border-radius: 4px; + display: flex; + height: 34px; + width: 200px; + + background: inherit; + color: inherit; + font-size: inherit; + line-height: inherit; + margin: 0; + padding: 3px 5px; + outline: none; +} + +select > option { + background: var(--ifm-background-surface-color); +} + +.inputBox { + border: solid 1px rgba(0, 0, 0, 0.5); + border-radius: 4px; + display: flex; + width: 200px; +} + +.inputBox > input { + background: inherit; + color: inherit; + font-size: inherit; + line-height: inherit; + margin: 0; + padding: 3px 10px; + border: none; + width: 100%; + min-width: 0; + text-align: right; + outline: none; +} + +.inputBox > span { + background: rgba(0, 0, 0, 0.05); + border-left: solid 1px rgba(0, 0, 0, 0.5); + padding: 3px 10px; +} + +/* Chrome, Safari, Edge, Opera */ +.inputBox > input::-webkit-outer-spin-button, +.inputBox > input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +/* Firefox */ +.inputBox > input[type="number"] { + -moz-appearance: textfield; +} + +.disclaimerHolder { + position: absolute; + width: 100vw; + height: 100vh; + top: 0; + left: 0; + z-index: 99; + background: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; +} + +.disclaimer { + padding: 20px 20px; + background: var(--ifm-background-surface-color); + border-radius: 4px; + box-shadow: rgba(0, 0, 0, 0.03) 0px 10px 20px 0px, + rgba(0, 0, 0, 0.1) 0px 1px 4px 0px; + width: 500px; +} + +.disclaimer > button { + border: none; + background: var(--ifm-color-primary); + color: var(--ifm-color-white); + cursor: pointer; + border-radius: 4px; + padding: 5px 15px; +} diff --git a/docs/src/data/power.js b/docs/src/data/power.js new file mode 100644 index 000000000000..e93bb54457f0 --- /dev/null +++ b/docs/src/data/power.js @@ -0,0 +1,55 @@ +export const zmkBase = { + hostConnection: 23, + standalone: { + idle: 0, + typing: 315, + }, + central: { + idle: 490, + typing: 380, + }, + peripheral: { + idle: 20, + typing: 365, + }, +}; + +export const zmkBoards = { + "nice!nano": { + name: "nice!nano", + powerSupply: { + type: "LDO", + outputVoltage: 3.3, + quiescent: 55, + }, + otherQuiescent: 4, + }, + "nice!60": { + powerSupply: { + type: "SWITCHING", + outputVoltage: 3.3, + efficiency: 0.95, + quiescent: 4, + }, + otherQuiescent: 4, + }, +}; + +export const underglowPower = { + firmware: 60, + ledOn: 20000, + ledOff: 460, +}; + +export const displayPower = { + EPAPER: { + activePercent: 0.05, + active: 1500, + sleep: 5, + }, + OLED: { + activePercent: 0.5, + active: 10000, + sleep: 7, + }, +}; diff --git a/docs/src/pages/index.js b/docs/src/pages/index.js index 7019e57969f5..ccaab5086852 100644 --- a/docs/src/pages/index.js +++ b/docs/src/pages/index.js @@ -59,7 +59,7 @@ function Home() { return (
diff --git a/docs/src/pages/power-profiler.js b/docs/src/pages/power-profiler.js new file mode 100644 index 000000000000..3b7c95a713e1 --- /dev/null +++ b/docs/src/pages/power-profiler.js @@ -0,0 +1,387 @@ +import React, { useState } from "react"; +import classnames from "classnames"; +import Layout from "@theme/Layout"; +import styles from "./styles.module.css"; +import { useInput } from "../utils/hooks"; +import "../css/power-profiler.css"; +import { zmkBoards } from "../data/power"; +import PowerEstimate from "../components/power-estimate"; + +function PowerProfiler() { + const { value: board, bind: bindBoard } = useInput(""); + const { value: split, bind: bindSplit } = useInput(false); + const { value: batterySize, bind: bindBatterySize } = useInput(110); + + const { value: psuType, bind: bindPsuType } = useInput(""); + const { value: outputV, bind: bindOutputV } = useInput(3.3); + const { value: quiescent, bind: bindQuiescent } = useInput(55); + const { value: otherQuiescent, bind: bindOtherQuiescent } = useInput(0); + const { value: efficiency, bind: bindEfficiency } = useInput(0.9); + + const { value: bondedQty, bind: bindBondedQty } = useInput(1); + const { value: percentAsleep, bind: bindPercentAsleep } = useInput(0.5); + + const { value: glowEnabled, bind: bindGlowEnabled } = useInput(false); + const { value: glowQuantity, bind: bindGlowQuantity } = useInput(10); + const { value: glowBrightness, bind: bindGlowBrightness } = useInput(1); + + const { value: displayEnabled, bind: bindDisplayEnabled } = useInput(false); + const { value: displayType, bind: bindDisplayType } = useInput(""); + + const [disclaimerAcknowledged, setDisclaimerAcknowledged] = useState( + typeof window !== "undefined" + ? localStorage.getItem("zmkPowerProfilerDisclaimer") === "true" + : false + ); + + const currentBoard = + board === "custom" + ? { + powerSupply: { + type: psuType, + outputVoltage: outputV, + quiescent, + efficiency, + }, + otherQuiescent, + } + : zmkBoards[board]; + + return ( + +
+
+

ZMK Power Profiler

+

+ {"Estimate your keyboard's power usage and battery life on ZMK."} +

+
+
+
+
+
+

Keyboard Specifications

+
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+ + mAh +
+
+
+
+
+ + {board === "custom" && ( +
+

Custom Board

+
+
+
+ + +
+
+
+
+ + + {parseFloat(outputV).toFixed(1)}V +
+ {psuType === "SWITCHING" && ( +
+ + + {Math.round(efficiency * 100)}% +
+ )} +
+
+
+ +
+ + µA +
+
+
+ +
+ + µA +
+
+
+
+
+ )} + +
+

Usage Values

+
+
+
+ + + {bondedQty} +
+
+
+
+ + + {Math.round(percentAsleep * 100)}% +
+
+
+
+ +
+

Features

+
+
+
+ + +
+
+
+ + +
+
+
+ {split ? ( + <> + + + + ) : ( + + )} +
+
+ Disclaimer: This profiler makes many assumptions about typing + activity, battery characteristics, hardware behavior, and + doesn't account for error of user inputs. While it tries to + estimate power usage using real power readings of ZMK, every + person will have different results that may be worse or even + better than the estimation given here. +
+
+
+
+ {!disclaimerAcknowledged && ( +
+
+

Disclaimer

+

+ This profiler makes many assumptions about typing activity, + battery characteristics, hardware behavior, and doesn't + account for error of user inputs. While it tries to estimate power + usage using real power readings of ZMK, every person will have + different results that may be worse or even better than the + estimation given here. +

+ +
+
+ )} +
+ ); +} + +export default PowerProfiler; diff --git a/docs/src/utils/hooks.js b/docs/src/utils/hooks.js new file mode 100644 index 000000000000..a210f195cdcf --- /dev/null +++ b/docs/src/utils/hooks.js @@ -0,0 +1,17 @@ +import { useState } from "react"; + +export const useInput = (initialValue) => { + const [value, setValue] = useState(initialValue); + + return { + value, + setValue, + bind: { + value, + onChange: (event) => { + const target = event.target; + setValue(target.type === "checkbox" ? target.checked : target.value); + }, + }, + }; +};