Skip to content

refactor: columnar typed-array chart store - #1936

Open
pedrolamas wants to merge 10 commits into
fluidd-core:developfrom
pedrolamas:pedrolamas/columnar-chart-store
Open

refactor: columnar typed-array chart store#1936
pedrolamas wants to merge 10 commits into
fluidd-core:developfrom
pedrolamas:pedrolamas/columnar-chart-store

Conversation

@pedrolamas

@pedrolamas pedrolamas commented Aug 17, 2026

Copy link
Copy Markdown
Member

Replaces the chart store's row-object model — a ChartState with a wide-open [index: string]: any holding Readonly<ChartData>[], where every value was a number | Date union — with a closed, explicitly typed state backed by columnar Float64Array buffers.

src/util/chart-buffer.ts is the new core: a capacity-bounded buffer whose live window is [offset, offset + count) over a time array and one column per series. Appends are amortised O(1) with no steady-state allocation, expiry is a binary search that advances offset (was a linear findIndex plus an O(n) splice), and chartBufferSource() hands ECharts subarray() views directly via its keyed-columns dataset.source format — so there is no per-render materialisation. Vue 2 cannot observe typed array writes, so a revision counter is the single reactive change signal; the arrays themselves are markRaw'd so the strict-mode deep watcher does not enumerate every element.

For a typical 8-sensor printer at the default 1200-sample retention this cuts the thermal bucket from ~550–600 KB of row objects and Dates to ~165 KB, and removes essentially all allocation on the 1 Hz chart path.

Bugs fixed along the way

  • Series added mid-session were never charted. ECharts derived its dimension names from chartData[0], and initSeries ran once behind an initialized flag, so any sensor or #target/#power/#speed column that first appeared after the initial sample stayed invisible for the rest of the session. Series are now built incrementally from the buffer's column list.
  • Thermal history disagreed with live data. initTempStore selected sensors by printer[key] != null while the live path uses printer/getChartableSensors, so history created columns that no series ever rendered and no update ever filled. It also skipped the decimalRound(v, 2) the live path applies. Both now match.
  • The Moonraker load chart doubled back after a reconnect. machine.proc_stats runs in the identify bootstrap, so every reconnect appended Moonraker's ~30 min backlog behind the samples already in the buffer, breaking the monotonic ordering dropExpired assumes. The array form is now treated as a history load and replaces the bucket in a single commit.

Thermal history lead-in

An intermediate commit on this branch (d749ae94) removed the lead-in padding from the history load, reasoning that because the x-axis window is pinned to the retention regardless, repeating the oldest sample bought no axis stability. That was wrong, and its commit message still asserts it — spanning the retention regardless is exactly why the padding matters. Against a freshly-started Moonraker, with only a few minutes of backlog, the chart drew a stub in the corner of a full-width window and appeared frozen until the retention filled. Bisected and confirmed against a live instance.

buildThermalHistoryBuffer again holds each sensor's oldest reading across the unfilled part of the window. The column-major load introduced by that commit is unaffected — one fill per column rather than the old per-field array spread.

Also

  • src/store/charts/thermal-columns.ts centralises the <sensor>#<sub> column convention that was duplicated across six files.
  • src/util/chart-tooltip.ts centralises the positional param.value lookup that keyed columns require, shared by all three formatters. Its param type is derived from echarts' own DefaultLabelFormatterCallbackParams rather than hand-declared.
  • smoothChartData becomes smoothChartSource, operating on typed array views and taking a ChartDataSource — it only ever needed the source and a count — so the util no longer depends on the store types. Unsmoothed columns pass through as views.
  • ChartBuffer.columns is a Map, not a plain object: column names are runtime sensor ids, and key in columns was true for constructor, toString and every other Object.prototype member, so a sensor with such a name silently lost its data.
  • The live notify_proc_stat_update path and the backlog load share moonrakerChartSample, so the cpu_usage bound, the seconds-to-ms conversion and the rounding cannot drift between them.
  • setInitCharts no longer Object.assigns an arbitrary Moonraker DB document onto store state — only selectedLegends is read.
  • Retention for the system, MCU and sensor buckets is now Globals.CHART_SYSTEM_RETENTION rather than a literal repeated in eight places.
  • A cleanup pass over the branch: resolveBuffer collapses five pass-through cases to default and merges the duplicated mcu/sensor blocks; setChartEntry drops a retention check resizeChartBuffer already makes; chartBufferRateOfChange hoists a loop-invariant; reallocate NaN-fills only the tail it isn't about to overwrite; ThermalChart probes the column Map directly instead of copying it into a Set each tick.

Testing

chart-buffer, chart-smoothing, thermal-history and moonraker-history have unit specs (460 tests total). Manual verification against a live printer is still worth doing for the reconnect and klippy-restart paths, which unit tests can't reach; the lead-in regression above was found that way, not by the suite.

🤖 Generated with Claude Code

pedrolamas and others added 6 commits August 16, 2026 17:01
Replaces the chart store's row-object model - a `ChartState` with a
wide-open `[index: string]: any` holding `Readonly<ChartData>[]`, where
every value was a `number | Date` union - with a closed, explicitly
typed state backed by columnar `Float64Array` buffers.

`src/util/chart-buffer.ts` is the new core: a capacity-bounded buffer
whose live window is `[offset, offset + count)` over a `time` array and
one column per series. Appends are amortised O(1) with no steady-state
allocation, expiry is a binary search that advances `offset` (was a
linear `findIndex` plus an O(n) `splice`), and `chartBufferSource()`
hands ECharts `subarray()` views directly via its keyed-columns
`dataset.source` format - so there is no per-render materialisation.
Vue 2 cannot observe typed array writes, so a `revision` counter is the
single reactive change signal; the arrays themselves are `markRaw`'d so
the strict-mode deep watcher does not enumerate every element.

For a typical 8-sensor printer at the default 1200-sample retention this
cuts the thermal bucket from ~550-600 KB of row objects and `Date`s to
~165 KB, and removes essentially all allocation on the 1 Hz chart path.

Also fixes a pre-existing bug: ECharts derived its dimension names from
`chartData[0]`, and `initSeries` ran once behind an `initialized` flag,
so any sensor or `#target`/`#power`/`#speed` column that first appeared
after the initial sample was never charted for the rest of the session.
Series are now built incrementally from the buffer's column list.

Supporting changes:

- `src/store/charts/thermal-columns.ts` centralises the `<sensor>#<sub>`
  column convention that was duplicated across six files.
- `src/util/chart-tooltip.ts` centralises the positional `param.value`
  lookup that keyed columns require, shared by all three formatters.
- `smoothChartData` becomes `smoothChartSource`, operating on typed
  array views; unsmoothed columns pass through as views.
- `setInitCharts` no longer `Object.assign`s an arbitrary Moonraker DB
  document onto store state - only `selectedLegends` is read.
- Retention for the system, MCU and sensor buckets is now
  `Globals.CHART_SYSTEM_RETENTION` rather than a literal repeated in
  eight places.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Pedro Lamas <pedrolamas@gmail.com>
Move ChartBuffer and ChartDataSource into the charts store types,
matching how MoveStore lives in store/gcodePreview/types.

Drop the ThermalColumn alias and the four per-bucket column unions,
which were all aliases of string used only at their declaration, and
remove ChartBuffer's now-pointless generic parameter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Pedro Lamas <pedrolamas@gmail.com>
initTempStore built a row object and a Date per sample, and padded every
field up to the retention with copies of its oldest value, mutating the
socket payload to do it. Replace that with a pure builder that writes the
buffer columns directly.

Short history is now right-aligned rather than padded, so a fresh boot no
longer draws a flat line back across data that was never recorded. The
chart's x-axis window is unaffected.

Key selection moves from state.printer.printer to printer/getChartableSensors,
matching handleAddChartEntry, so history no longer creates columns that get
no series and no live updates. Values are rounded to 2dp for the same reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Pedro Lamas <pedrolamas@gmail.com>
Derive `ChartTooltipParam` from echarts' `DefaultLabelFormatterCallbackParams`
via `Pick` rather than redeclaring the three fields by hand, so they cannot
drift from what echarts actually passes the tooltip formatter.

Type `historyFields` with a `HistoryField` alias on the declaration instead of
`as const satisfies …`, dropping the trailing 120-char type and pointing typo
errors at the offending element.

Normalize guard clauses to braced bodies and rename loop counters to `index`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Pedro Lamas <pedrolamas@gmail.com>
`machine.proc_stats` runs in the identify bootstrap, so on every reconnect its
response carries Moonraker's rolling backlog of `moonraker_stats`. Those were
appended one sample at a time behind the samples already in the buffer, leaving
it as [pre-drop live … T_drop][T_drop - 30min … now].

That breaks the monotonic ordering `dropExpired` assumes, so which samples
expire became arbitrary, and the chart drew a line doubling back across the
pre-drop window with every overlapping sample duplicated.

Treat the array form as a history load, mirroring `initTempStore`: build the
buffer column-major from the newest `retention` samples and replace the bucket
in one commit, rather than ~1800 appends each bumping `revision` and rescanning
for expiry. The single-sample notification form still appends as before.

`setResetChartStore` is deliberately untouched - it is shared with
`resetKlippy`, which does not re-fetch proc stats, so clearing the bucket there
would blank the chart with nothing to refill it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Pedro Lamas <pedrolamas@gmail.com>
Braced guard bodies, chained calls broken onto their own lines, and blank lines
around statements - matching the pass already applied to the chart utils. No
behaviour change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Pedro Lamas <pedrolamas@gmail.com>
@pedrolamas
pedrolamas requested a lite review from Copilot August 17, 2026 18:25
@pedrolamas pedrolamas added the Code - Optimizations Everything that relates to code optimizations! label Aug 17, 2026
@pedrolamas pedrolamas added this to the 1.37.5 milestone Aug 17, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Refactors Fluidd’s charting pipeline from row-object ChartData[] into a typed, columnar ChartBuffer backed by Float64Array columns, enabling allocation-free appends/expiry and keyed-column ECharts datasets. This also aligns history generation with the live chart path and fixes reconnect/backlog ordering issues (notably Moonraker proc_stats).

Changes:

  • Introduces src/util/chart-buffer.ts and migrates chart state to fixed, explicitly-typed buffers (thermal, moonraker, klipper, memory, diagnostics, plus mcus/sensors maps).
  • Switches chart rendering to ECharts “keyed columns” sources, adds shared tooltip helpers, and ports smoothing/history logic to typed-array views.
  • Adds unit coverage for the new buffer, smoothing, and history builders.

Reviewed changes

Copilot reviewed 30 out of 31 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/util/chart-tooltip.ts Adds shared helpers for positional tooltip value/dimension lookup with keyed-column datasets.
src/util/chart-smoothing.ts Ports smoothing from row objects to typed-array column views sourced from ChartBuffer.
src/util/chart-buffer.ts New core columnar buffer (append/expiry/source views/ROC helpers) for chart data.
src/util/tests/chart-smoothing.spec.ts Updates smoothing tests to operate on buffers and column views.
src/util/tests/chart-buffer.spec.ts Adds unit tests for buffer behavior (append/expiry/resize/source caching/ROC).
src/typings/moonraker.proc_stats.d.ts Widens moonraker_stats type to support single stat or backlog array.
src/store/server/actions.ts Treats proc_stats backlog as a replace/init (not append) and rounds load consistently.
src/store/printer/actions.ts Uses chart retention getter and emits diagnostics samples as {time, values} (NaN gaps).
src/store/charts/types.ts Replaces open-ended chart state with typed buffers and a ChartEntryPayload union.
src/store/charts/thermal-history.ts New builder converting Moonraker temp store history into a ChartBuffer.
src/store/charts/thermal-columns.ts Centralizes <sensor> / <sensor>#<sub> naming and parsing for thermal columns.
src/store/charts/state.ts Initializes fixed buckets with typed buffers and standardized retentions.
src/store/charts/mutations.ts Reworks mutations to append to/rescale buffers and to init only persisted legends.
src/store/charts/moonraker-history.ts New builder converting proc_stats backlog into a ChartBuffer (rounded, ms timestamps).
src/store/charts/getters.ts Updates base tooltip formatter to use positional tooltip lookups for keyed columns.
src/store/charts/actions.ts Replaces legacy init logic with typed history-buffer builders (thermal + moonraker).
src/store/charts/tests/thermal-history.spec.ts Adds unit tests validating thermal history buffer construction and behavior.
src/store/charts/tests/moonraker-history.spec.ts Adds unit tests validating Moonraker backlog buffer behavior and monotonicity.
src/store/chart_helpers.ts Migrates chart entry creation to {bucket,id,time,values} and thermal column conventions.
src/globals.ts Adds Globals.CHART_SYSTEM_RETENTION to remove repeated literals across system charts.
src/components/widgets/thermals/ThermalChart.vue Migrates thermal chart rendering to buffer-based keyed columns and incremental series creation.
src/components/widgets/thermals/TemperatureTargets.vue Updates legend toggles to new column naming and uses buffer ROC helper.
src/components/widgets/system/SystemMemoryChart.vue Updates inline memory chart to use chartBufferSource keyed-column dataset.
src/components/widgets/system/SystemLoadChart.vue Updates inline system load chart to use chartBufferSource keyed-column dataset.
src/components/widgets/system/MoonrakerLoadChart.vue Updates inline Moonraker load chart to use chartBufferSource keyed-column dataset.
src/components/widgets/system/McuLoadChart.vue Updates MCU load inline chart to source from charts.mcus[id] buffer.
src/components/widgets/system/KlipperLoadChart.vue Updates inline Klipper load chart to use chartBufferSource keyed-column dataset.
src/components/widgets/sensors/SensorChart.vue Updates sensor inline chart to source from charts.sensors[id] buffer.
src/components/widgets/diagnostics/DiagnosticsCard.vue Updates diagnostics chart/tooltips to keyed columns and rounds tooltip values consistently.
src/components/ui/AppInlineChart.vue Changes inline chart data prop to a keyed-column source and updates last-value extraction.
src/components/ui/AppChart.vue Changes chart data prop to a keyed-column source and simplifies dataset updates.
Suppressed comments (1)

src/util/chart-buffer.ts:191

  • chartBufferSource builds source as a normal object and then assigns dynamic keys. If any column name is __proto__ (sensor names are runtime data), source[key] = ... can mutate the object prototype and break ECharts/tooltips. Build source with a null prototype before assigning dynamic keys.
  const source: ChartDataSource = {
    date: time.subarray(offset, offset + count)
  }

  for (const key in columns) {
    source[key] = columns[key].subarray(offset, offset + count)
  }

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/util/chart-buffer.ts
Comment thread src/util/chart-buffer.ts
Comment thread src/store/chart_helpers.ts Outdated
Comment thread src/store/charts/thermal-history.ts Outdated
From PR review.

`chartBufferRateOfChange` divided by the time delta without checking it,
so two trailing samples sharing a timestamp yielded Infinity (or NaN),
rendered verbatim in the temperature table. Equal timestamps now return
0, alongside the existing single-sample guard.

`handleMcuStatsChange` had the same exposure on its bandwidth maths, via
a delta that is 0 when two MCU updates land in the same millisecond; the
delta is now clamped to 1ms. Pre-existing, but the file was already
being touched.

`ChartBuffer.columns` becomes a `Map`. Column names are runtime sensor
ids, and the membership test was `key in columns`, which is true for
`constructor`, `toString` and every other Object.prototype member - so a
sensor with such a name never got a column created and its data was
silently dropped. A plain assignment of `__proto__` would also have
reparented the map. `Map` has own-key semantics, guarantees insertion
order even for integer-like names, and cannot be polluted.

Vue 2 cannot observe a `Map`, so the `defineColumn` callback and the
`Vue.set` wiring in `setChartEntry` are gone. Nothing depended on that
reactivity: column discovery runs in `initSeries`, driven by
`@Watch('chartRevision')`, and `chartBufferSource` reads `revision` too.
The new `chartBufferColumn` accessor absorbs the create-on-miss paths.

Also corrects the `buildThermalHistoryBuffer` comment, which claimed the
timeline ends at `endTime` when the newest sample sits at `endTime - 1000`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Pedro Lamas <pedrolamas@gmail.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 30 out of 31 changed files in this pull request and generated no new comments.

Suppressed comments (2)

src/util/chart-buffer.ts:193

  • chartBufferSource assigns source[key] = … for runtime-derived column names. If a column name is __proto__, this mutates the returned object's prototype (e.g. to a Float64Array) and ECharts/key enumeration can start seeing numeric indices via the prototype chain. This is observable JS behavior and can corrupt/slow tooltip/series logic. Build source as a null-prototype dictionary (or define properties) so all column keys are treated as ordinary data keys.
  for (const [key, column] of columns) {
    source[key] = column.subarray(offset, offset + count)
  }

src/store/charts/mutations.ts:54

  • setResetChartStore is invoked on reconnect/klippy reset (charts/resetChartStore), but it currently only resets thermal and ready. This leaves other chart buckets (klipper, memory, moonraker, diagnostics, mcus, sensors) carrying stale pre-drop samples into the new session, contradicting the action name/usage and risking mixed timelines after a reconnect. Reset the full charts state while preserving selectedLegends.
  setResetChartStore (state) {
    const { thermal, ready } = defaultState()

    Object.assign(state, {
      thermal,

`d749ae94` dropped the fabricated lead-in from the thermal history load,
on the reasoning that the padding bought no axis stability because the
x-axis window spans the retention regardless. That reasoning was
backwards: spanning the retention regardless is precisely why the
padding mattered. Against a freshly-started Moonraker, with only a few
minutes of backlog, the chart drew a stub in the corner of a full-width
window and looked frozen until the retention filled.

`buildThermalHistoryBuffer` again holds each sensor's oldest reading
across the unfilled part of the window, so `count` is the retention
whenever there is any source at all. The column-major load stays - one
`fill` per column rather than the old per-field array spread - so the
performance work in that commit is unaffected.

Cleanups found by a review pass over the branch:

`chartBufferRateOfChange` hoists `last`, which was loop-invariant, and
walks back only for `first`. `reallocate` NaN-fills just the tail rather
than the whole array it is about to overwrite. `createTimeColumn`
becomes `allocate`, the generic allocator it always was, and
`chartBufferLastTime`/`chartBufferLastValue` accept `undefined` like
`chartBufferSource` does.

`smoothChartSource` takes a `ChartDataSource` instead of a `ChartBuffer`
- it only ever needed the source and a count, and `count` is
`date.length` - which drops the util's dependency on the store types.

`resolveBuffer` collapses five pass-through cases to `default`, and the
duplicated mcu/sensor blocks to one; `setChartEntry` no longer repeats
the retention check that `resizeChartBuffer` already makes.

The live `notify_proc_stat_update` path and the backlog load now share
`moonrakerChartSample`, so the cpu_usage bound, the seconds-to-ms
conversion and the rounding cannot drift between them.

`ThermalChart` probes `columns` directly instead of copying the Map into
a Set on every tick, and its tooltip formatter returns early rather than
wrapping its body in an `if`. `AppInlineChart` drops a ternary that
guarded an index lookup already safe at -1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Pedro Lamas <pedrolamas@gmail.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 30 out of 31 changed files in this pull request and generated 1 comment.

Suppressed comments (1)

src/store/charts/thermal-history.ts:90

  • PR description says short thermal history is no longer padded and “simply starts where the real data starts”, but buildThermalHistoryBuffer still fills the lead-in with the oldest reading (target.fill(...)), which draws a flat segment that didn’t occur. Either update the PR description to match this behavior, or change the history mapping to leave the lead-in as gaps (NaN) so the chart starts at the first real sample.
    // Hold the oldest reading across the lead-in so a short history still
    // fills the window - the chart's x-axis spans the retention regardless.
    target.fill(decimalRound(values[from], 2), 0, to)

Comment thread src/util/chart-buffer.ts Outdated
`chartBufferSource` built its keyed-columns object from a plain literal,
so `source[key] = …` for a column named `__proto__` invoked the
`Object.prototype.__proto__` setter instead of creating an own property.
The column silently vanished from the dataset — the same own-key bug
`constructor` and `toString` hit before `ChartBuffer.columns` became a
`Map`, just on the other side of the boundary.

Build the source on a null prototype, and carry that through
`smoothChartSource`'s copy so `result[key] = smoothed` can't hit the
setter either. Verified echarts is fine with it: keyed-columns detection
uses `hasOwn(data, key)` and `each(data, …)` → `Object.keys`.

Nothing escaped the per-revision object, so this was not a
prototype-pollution vector; the impact was one empty series.

Signed-off-by: Pedro Lamas <pedrolamas@gmail.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 30 out of 31 changed files in this pull request and generated no new comments.

`AGENTS.md` had no entry for the chart store, while the parallel G-code
`MoveStore` has a detailed one. Add a `### Charts` section covering the
parts that aren't inferable from the code: the ring-buffer window and
its accessors, `revision` as the sole reactive signal, NaN-not-gaps, why
`columns` is a `Map` and why `chartBufferSource` builds on a null
prototype, `date` as dimension 0, positional tooltip params, the
monotonic-time requirement that forces history loads to replace rather
than append, and the thermal lead-in padding together with the x-axis
pin that makes it load-bearing.

Also tighten the socket state machine bullet: `charts/resetChartStore`
clears only the thermal bucket, not the whole module, and that is
deliberate - `resetKlippy` shares it and a klippy restart does not
re-fetch `machine.proc_stats`.

No user-facing docs changed; nothing under `docs/docs/` describes
behaviour this branch alters.

Signed-off-by: Pedro Lamas <pedrolamas@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Code - Optimizations Everything that relates to code optimizations!

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants