11 KiB
Heatmap DataAPI draft — audit notes (draft/heatmap_data_api)
Date: 2026-07-28. Audited head: 820afb20 ("wip"). Companion library audit with the full
54-finding registry: bec_data_api/DATA_API_AUDIT.md (widget findings appear there with the
same evidence and status fields). Fix commit on this branch: 08200a05. Nothing was pushed.
Reading guide. The sections up to "Known remaining gaps" audit the original draft
(820afb20) and keep their line references to that code. Everything from
"Status after the widget migration" onwards records what was built afterwards: the draft's
approach was validated, then rewritten onto the DataAPI v2 contract and generalized to all six
plotting widgets. The bec-side counterpart is bec_data_api/DATA_API_AUDIT.md §4.
This draft was assessed as an integration probe for the bec-side DataAPI, per its known
"rough" status. Verdict: the shape is right — a live buffered subscription feeding
update_plot through a Qt signal is exactly the pattern the DataAPI should serve — but the
draft had one blocking defect that came from the library (mid-scan add_device
misalignment froze the plot; fixed on the bec side, commit d89e9ae9 there) and several
integration defects of its own, four of which are fixed here.
What was wrong, and what changed in 08200a05
-
Setup order could permanently freeze the plot (library defect, widget trigger).
_setup_data_api_subscriptionadds x, y, z one by one afterset_callback; on a scan with N recorded points the old library emitted N x-only bundles and then permanently paired x point N+k with y/z point k, so the widget's length check skipped every update for the rest of the scan. Proven by probe (x (7) / y (2) / z (2), widget length check passes: False); the probe passes against the fixed library (all series length 7, zero misaligned pairs). No widget change needed — but note the new library semantic: adding a device to an active bundle re-emits the aligned history, and buffered mode restarts its accumulation. -
Legacy fallback was gated on subscription existence, not health. A live rebind can fail per device (e.g. z neither monitored nor async in the new scan — the ValueError is swallowed inside the scan-status dispatch); x/y stayed subscribed, z stayed dead, and both the DataAPI path (z=None → skip) and the legacy path (disabled because
_data_subscription is not None) produced nothing: a permanently blank heatmap. Fixed withDataSubscription.unbound_devices(bec commitf6cd80fa) +_data_api_feed_healthy(): the legacy path stays active whenever any device is unbound. Test:test_heatmap_unhealthy_subscription_keeps_legacy_updates. -
A failed setup leaked a half-configured live subscription.
self._data_subscriptionwas assigned only after alladd_devicecalls, so theexceptbranch's cleanup closed nothing; the orphan (strongly holdingdata_api_update.emit) would rebind on every scan and emit forever. Fixed by assigning immediately aftercreate_subscription. Test:test_heatmap_failed_subscription_setup_closes_partial_subscription. -
No rate limit on the DataAPI feed. The legacy path is throttled by a 5 Hz
pg.SignalProxy, butdata_api_updatewas connected straight toupdate_plot: one full O(N) buffer walk + grid rebuild per point on the GUI thread (measured library-side: 60.4 s of buffer-walking alone for a 36000-point scan). Fixed by routing the feed through its own 5 Hz SignalProxy delivering only the newest payload — lossless, because buffered payloads carry the full state. -
Stale payloads could redraw over new content. The buffered branch ignored the payload's
scan_id(and shadowed itsmetadataparameter), so an emission queued on the Qt event loop before a scan switch or a history-view entry redrew over the new scan/history. Fixed: buffered payloads are dropped when the subscription is gone, a history view is active, ormetadata["scan_id"]mismatches; the shadowing local was renamed. Test:test_heatmap_update_plot_drops_stale_data_api_payload.
Known remaining gaps (as of the draft; all closed since — see the next section)
These were consequences of DataAPI scope, recorded in the library audit's recommendations:
- The legacy fetch machinery must stay until the DataAPI covers history scans and
device-readback fallbacks; property-change refreshes (
sync_signal_update) still perform the full legacy fetch even while the feed is active, and both writers share_grid_index. - Array-valued async z fragments pass the length check but crash the scalar grid assignment
(
ValueError: setting an array element with a sequence, swallowed by SafeSlot) — the buffered contract needs an explicit "scalar per aligned point" guarantee or a defined flattening. - End-of-scan settle updates are a no-op on both paths: the
QTimer.singleShot(..., self.update_plot)fallback is skipped by@SafeSlot(verify_sender=True)(sender is None), and the DataAPI path has no scan-end reconciliation. Pre-existing on main; call it with_override_slot_params={"verify_sender": False}and add an end-of-scan handler when the DataAPI grows a scan-closed notification. _setup_data_api_subscriptionis only reached viaplot(); a widget restored from config without aplot()call never uses the DataAPI.- The widget tests still fake the DataAPI; one integration-style test driving a real
BECLiveDataPluginemission intoupdate_plotwould tie the payload shapes together (the library-side alignment tests currently cover that contract).
Status after the widget migration (2026-07-29)
The gaps above were the reason this draft could not be shipped as-is. All of them were closed by the v2 redesign plus the widget migration; the draft's own code no longer exists.
| Gap from the section above | Resolution |
|---|---|
| Legacy fetch machinery must stay (no history/readback coverage) | HistoryDataPlugin and DeviceStreamPlugin (bec a8448b05, 63b87bf3) cover terminal scans and scan-less device streams, so the heatmap port (e9fe0518) drives live and history through the same _on_data_update. At the time of the migration the legacy pull survived as an explicitly health-gated fallback (_data_api_feed_healthy); it was removed entirely afterwards together with the last legacy data paths of all plot widgets — feed health is surfaced through bridge.healthy, and there is deliberately no second data-access path. |
| Array-valued async z fragments crash grid rendering | The columnar contract makes the shape explicit per source (SourceData.values + metadata["async_update_type"]), and update.aligned() guarantees equal-length columns, so the widget no longer infers structure from a buffered list. |
| End-of-scan settle updates are a no-op | Deleted along with the QTimer.singleShot hacks in every port. The backend performs a final flush at scan end and re-routes the same live subscription to the authoritative file when the scan-history entry appears (a8448b05), which is the real end-of-scan reconciliation the hack was approximating. |
_setup_data_api_subscription only reachable via plot() |
Closed afterwards: a widget restored from a saved configuration now starts the data feed itself (_start_data_feed), without requiring a plot() call; while idle it binds to the latest finished scan and switches to live-follow when a scan starts. |
| Widget tests fake the DataAPI; payload shapes untied | Partly closed: the bec side now has real-connector tests (TestDeviceStreamsThroughRealConnector) and real-message history fixtures. Widget suites still fake the bridge deliberately (they test rendering, not transport). The real-HDF5 gap is closed: the widget history fixtures write genuine HDF5 files (create_history_file, h5py) and the heatmap/waveform history tests read them through the history plugin end to end. |
What the widgets look like now
All six plotting widgets — Heatmap (e9fe0518), ScatterWaveform (47851dab), Waveform
(e0abc7f3), Image (235d5614), MultiWaveform and MotorMap (2288aa95) — consume
bec_widgets/utils/qt_data_subscription.py:
self._data_bridge = QtDataSubscription(
self.client, sources=[(dev, entry), ...], scan="live", # or a scan_id, or None for streams
parent=self, min_emit_interval=self.update_interval_s, # driven by the update_rate property
)
self._data_bridge.updated.connect(self._on_data_update) # SubscriptionUpdate on the Qt thread
Deleted across those ports: three verbatim copies of the live/history fetch fork
(_fetch_scan_data_and_access), four scan-lifecycle handlers with their
QTimer.singleShot(100/300) settle hacks, two hand-rolled async reconstructors
(on_async_readback, adjust_image_buffer), three history resolvers' data paths, the per-widget
pg.SignalProxy data throttles (rate limiting is backend-side; presentation proxies stay),
and every direct dispatcher registration for device_async_signal / device_preview /
device_monitor_1d / device_readback.
Two things the migration got wrong and had to correct — both worth knowing before porting a seventh widget:
- Removing functionality is not the same as deferring it. The Waveform port deleted the
large-dataset guard and recorded it as a follow-up; it is critical functionality. It is back
(bec
af11c666+ widgetsf57949ae) with the original names, dialog and RPC surface, now gated backend-side and loading off-thread. - A fake bridge does not prove the transport works. Three live-only failures (queued Qt
delivery, pydantic
_StoredDataInfo, pubsubMessageObjectpayloads) all passed the widget suites. See DATA_API_AUDIT.md §4.2.
What a correct integration looks like (for the next widget)
Create one QtDataSubscription per widget configuration, declaring all sources at once
(the backend partitions them into correlation groups, so mixing monitored, async and standalone
sources in one call is fine); render from update.aligned() and update.axis(...) rather than
re-deriving lengths or x data; use scan="live" for the running scan, a scan id for history and
scan=None for device streams (with max_points); gate any remaining fallback on
bridge.healthy; and close the bridge in cleanup(). The signal bridge, Qt-thread marshalling,
stale-scan filtering, rate limiting and the size gate are all in the wrapper and the backend —
none of it should be re-implemented per widget.
Post-migration addendum (2026-08-13)
Landed after the sections above were written; recorded here so this document matches the code:
- Legacy sweep: the health-gated legacy fallbacks were removed from all six widgets — the Data API is the only data path (see the corrected rows above).
update_rateonPlotBase(1-100 Hz, per-widget defaults, RPC/Designer-visible): wired into every bridge through the backend's runtime-adjustablemin_emit_interval.- Incremental async rendering matching main's update rates, with a throughput benchmark
(
tests/unit_tests/benchmarks/test_data_api_widget_throughput.py). - Waveform large datasets: min/max envelope decimation above one million points with
zoom-window re-rendering down to raw samples, and a labelled progress bar below the plot fed
by the backend's chunked-read
progress_callbackviabridge.progress. - Columns may be numpy arrays (history reads); the widgets' source checks are length-based, never truthiness-based — regression-tested with numpy-backed fixtures.