* Add deterministic fixtures and characterization tests for chart data processing * Extract statistics chart data processing into a pure function * Extract state history line chart data processing into a pure function * Add benchmark suite for chart data processing * Add chart data optimization playbook * Point agent instructions at the chart optimization playbook
10 KiB
Chart data processing benchmarks
This directory contains benchmarks and an optimization playbook for the frontend's chart data processing: the code that sanitizes, downsamples, and transforms history/statistics/energy data for visualization. This data can be large (weeks of 5-minute statistics, 100k+ state histories) and refreshes often, so this processing should put as little load as possible on client devices.
The harness has three parts:
- Deterministic fixtures (
test/fixtures/) — seeded generators for history states, statistics, and energy data. The same seed always produces the same payload. - Characterization tests — snapshot tests that pin the exact current output of every transform (see the target map below). They are the correctness contract for optimization work: a performance change must leave all outputs bit-identical.
- Benchmarks (this directory) —
vitest benchsuites over the same fixtures, runnable with machine-readable output for baseline comparison.
Running
yarn test:bench # run all benchmarks
yarn test:bench down-sample # run one suite
# Record a baseline, then compare after a change:
yarn test:bench --outputJson test/benchmarks/results/baseline.json
yarn test:bench --compare test/benchmarks/results/baseline.json --outputJson test/benchmarks/results/after.json
test/benchmarks/results/ is gitignored. The JSON reports include hz,
mean, rme (relative margin of error), and percentiles per benchmark;
compare mode also prints the delta next to each result.
Benchmarks run in a plain node environment (test/vitest.bench.config.ts)
with sequential files for stable timings. Expect run-to-run noise of a few
percent; always record the baseline twice and check rme before drawing
conclusions.
Optimization target map
| Transform | Source | Benchmark | Characterization test |
|---|---|---|---|
downSampleLineData |
src/components/chart/down-sample.ts |
down-sample.bench.ts |
test/components/chart/down-sample.test.ts |
computeHistory |
src/data/history.ts |
history.bench.ts |
test/data/history-characterization.test.ts |
HistoryStream.processMessage |
src/data/history.ts |
history-stream.bench.ts |
test/data/history-characterization.test.ts |
convertStatisticsToHistory, mergeHistoryResults |
src/data/history.ts |
statistics.bench.ts |
test/data/history-characterization.test.ts |
generateStatisticsChartData |
src/components/chart/statistics-chart-data.ts |
statistics-chart-data.bench.ts |
test/components/chart/statistics-chart-data.test.ts |
generateStateHistoryChartLineData |
src/components/chart/state-history-chart-line-data.ts |
state-history-chart-line-data.bench.ts |
test/components/chart/state-history-chart-line-data.test.ts |
fillDataGapsAndRoundCaps |
src/components/chart/round-caps.ts |
chart-helpers.bench.ts |
test/panels/lovelace/cards/energy/common/energy-chart-options.test.ts |
computeYAxisFractionDigits |
src/components/chart/y-axis-fraction-digits.ts |
chart-helpers.bench.ts |
test/components/chart/y-axis-fraction-digits.test.ts |
getSummedData, computeConsumptionData, computeConsumptionSingle |
src/data/energy.ts |
energy.bench.ts |
test/data/energy-characterization.test.ts |
fillLineGaps, computeStatMidpoint, getCompareTransform, getSuggestedMax |
src/panels/lovelace/cards/energy/common/energy-chart-options.ts |
energy.bench.ts |
test/panels/lovelace/cards/energy/common/energy-chart-options.test.ts |
Call frequency notes (what makes a target worth optimizing):
computeHistoryruns on every history stream message for every visible history graph card;HistoryStream.processMessagemerges and purges on the same cadence.downSampleLineDataruns inha-chart-base._getSeries()on every chart render of every chart on screen.generateStatisticsChartData/generateStateHistoryChartLineDatarun on every data or config change of their charts (MIN_TIME_BETWEEN_UPDATES= 5 minutes keeps charts refreshing even without new data).getSummedData/computeConsumptionDatarun per energy collection update and feed most cards on the energy dashboard. Both arememoizeOne-wrapped: benchmarks must pass a fresh object reference per iteration or they only measure the cache hit.
Not yet extracted (refactor before optimizing)
The energy graph cards still embed their series generation in the Lit
component (_processDataSet, _processTotal, _processStatistics,
_processForecast, _processUntracked):
src/panels/lovelace/cards/energy/hui-energy-usage-graph-card.tssrc/panels/lovelace/cards/energy/hui-energy-solar-graph-card.tssrc/panels/lovelace/cards/energy/hui-energy-devices-detail-graph-card.tssrc/panels/lovelace/cards/energy/hui-energy-gas-graph-card.tssrc/panels/lovelace/cards/energy/hui-energy-water-graph-card.tssrc/panels/lovelace/cards/energy/hui-power-sources-graph-card.ts
To optimize one of these, first repeat the extraction pattern used for
statistics-chart-data.ts / state-history-chart-line-data.ts:
- Move the processing methods into a pure module next to the card, taking an
options object. Inject every environment read:
hass(or the narrow pieces used),getComputedStyle(this)results, andnew Date()as anow: Dateparameter. Zero logic changes — this commit must be a mechanical move reviewable side-by-side. - Add characterization tests for the extracted function using the
test/fixtures/generators (snapshot small inputs,digestResultlarge ones) and a benchmark file here. - Only then optimize, following the loop below.
The optimization loop
Work on one target at a time:
- Preflight —
yarn testmust be green before you start. - Coverage check — confirm the target has characterization coverage for the code paths you will touch; add missing cases in a separate commit before changing the implementation.
- Baseline — run
yarn test:bench --outputJson .../baseline.jsontwice; note thermeand the spread between runs. That spread is your noise floor. - Optimize — change the implementation. Stay within the guardrails below.
- Verify correctness —
yarn testandyarn lintmust pass. Never runyarn lint:typeswith file arguments. - Measure —
yarn test:bench --compare .../baseline.json --outputJson .../after.json. - Report — include a before/after table (
mean,hz,rme) for every affected benchmark, generated from the two JSON files.
Guardrails
- Outputs must be bit-identical. Never run vitest with
-u/--update; never edit files under__snapshots__/ortest/fixtures/; never modify existing characterization tests (adding new cases is fine, in its own commit). Even a change in floating-point summation order is a behavior change. If an optimization seems to require changing the output, stop and escalate with a written justification instead. - No public API changes. Exported function signatures and component properties stay as they are.
- No new runtime dependencies.
- Bundle size matters. This code ships to browsers and CI tracks bundle stats; keep added source small.
- Strategy preference, in order:
- Algorithmic: single-pass loops, avoiding repeated full reprocessing per message, avoiding intermediate allocations.
- Memoization:
memoize-oneis already a dependency; only useful when inputs are reference-stable across calls. - Incremental/delta processing: reuse the previous result when only new data arrived (e.g. history stream updates).
- Web workers: an architecture change — propose it with measurements, do not implement it as part of a routine optimization pass.
Acceptance criteria
An optimization is accepted only if all of the following hold:
yarn testfully green andyarn lintclean.git diffcontains no changes undertest/fixtures/,__snapshots__/, or existing characterization tests.- The declared target improves by ≥ 10% mean time, and the improvement is
larger than 3× the combined
rmeof the baseline and after runs. - No other benchmark regresses by more than max(5%, 2× its
rme). - No new dependencies and no public export changes.
- The report includes the before/after table described above.
Fixture conventions
- All fixtures are seeded (
test/fixtures/random.ts, mulberry32); identical seeds yield identical data. Scale tiers:SCALES = { small: 1k, medium: 10k, large: 100k }total states. - Everything is anchored at
FIXED_EPOCH_MS(2024-01-01T00:00:00Z) exceptHistoryStreamscenarios: the stream purges againstDate.now(), so its fixtures take astartMsderived from the current time. Characterization tests pin the clock withvi.useFakeTimers(); benchmarks use the realDate.now()at module load (tinybench needs a real clock — never fake timers in a benchmark). - Large outputs are snapshotted via
digestResult()(test/fixtures/digest.ts), a structural checksum that stays small but catches numeric drift.