mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-09-07 00:42:37 +02:00
153 lines
11 KiB
Markdown
153 lines
11 KiB
Markdown
# 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`
|
|
|
|
1. **Setup order could permanently freeze the plot (library defect, widget trigger).**
|
|
`_setup_data_api_subscription` adds x, y, z one by one after `set_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.
|
|
|
|
2. **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 with
|
|
`DataSubscription.unbound_devices` (bec commit `f6cd80fa`) + `_data_api_feed_healthy()`:
|
|
the legacy path stays active whenever any device is unbound.
|
|
Test: `test_heatmap_unhealthy_subscription_keeps_legacy_updates`.
|
|
|
|
3. **A failed setup leaked a half-configured live subscription.** `self._data_subscription`
|
|
was assigned only after all `add_device` calls, so the `except` branch's cleanup closed
|
|
nothing; the orphan (strongly holding `data_api_update.emit`) would rebind on every scan and
|
|
emit forever. Fixed by assigning immediately after `create_subscription`.
|
|
Test: `test_heatmap_failed_subscription_setup_closes_partial_subscription`.
|
|
|
|
4. **No rate limit on the DataAPI feed.** The legacy path is throttled by a 5 Hz
|
|
`pg.SignalProxy`, but `data_api_update` was connected straight to `update_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.
|
|
|
|
5. **Stale payloads could redraw over new content.** The buffered branch ignored the payload's
|
|
`scan_id` (and shadowed its `metadata` parameter), 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, or
|
|
`metadata["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_subscription` is only reached via `plot()`; a widget restored from config
|
|
without a `plot()` call never uses the DataAPI.
|
|
- The widget tests still fake the DataAPI; one integration-style test driving a real
|
|
`BECLiveDataPlugin` emission into `update_plot` would 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`:
|
|
|
|
```python
|
|
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` + widgets `f57949ae`) 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`, pubsub `MessageObject` payloads) 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_rate` on `PlotBase`** (1-100 Hz, per-widget defaults, RPC/Designer-visible): wired
|
|
into every bridge through the backend's runtime-adjustable `min_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_callback` via `bridge.progress`.
|
|
- **Columns may be numpy arrays** (history reads); the widgets' source checks are
|
|
length-based, never truthiness-based — regression-tested with numpy-backed fixtures.
|