Repository navigation
ViSR plugin: scan-aware /binned and a prototype /render endpoint - #6
Conversation
get_setpoints() took the spec's first dimension as x. A spec lists its axes outermost first, and a raster scan has y outermost, so setpoints mode returned the transpose of the image built from readbacks, where x is always the x motor. Map the axes to x, y, z by name instead. Runs recorded before the spec named its axes have object reprs there, so the run's motors list (same order as the spec's axes) supplies the names for those. When the axes cannot be told apart as x, y and z, keep the spec's own order as before. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Tiled raises DataNotReadyError when the file on disk has fewer frames than the catalog advertises, which happens briefly during a live write. /binned turned every read error into a 422, so a retryable condition looked like a bad request and the client had nothing to retry on. get_data now retries a few times (DLS_TILED_VISR_NOT_READY_RETRIES and DLS_TILED_VISR_NOT_READY_RETRY_DELAY) and then answers 503 with Retry-After, as Tiled's own routes do. The handlers that convert other read errors to 422 let that 503 through. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
numpy.histogram2d indexes its result [x_bin, y_bin] and /binned returned it untransposed, so each matrix had one row per x bin. Image plots (and the ViSR UI, which takes height from the number of rows) expect one row per y bin and one column per x bin, so the picture was transposed against its own axes. It only looked plausible because the default grid is square. The matrices are now transposed to rows of y. Bins no point fell into are reported as null instead of 0, so a plot can tell "no data yet" from a measured zero. The response also carries n_points, the number of scan points used, so a client can show progress and ignore a response older than one it already has. x_limits and y_limits are unchanged. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
With no width/height or range, /binned used numpy's default 10x10 over the extent of the points received so far. A 5x5 scan therefore filled a quarter of the cells, a 1000-point scan was squeezed to a tenth of its resolution, and because the extent grows as the scan fills in, the whole image rescaled on nearly every poll. When the run's start document has a spec that lays points out on a grid, the bins are now that grid: the distinct setpoint positions on x and y, with edges halfway between neighbours. Every setpoint gets its own cell, the range is fixed from the first poll, and the image has the scan's native resolution. Points are still placed by their recorded positions, so a dropped point leaves one empty cell instead of shifting the rest of the scan. Explicit width/height or a full range, non-default x/y dimension indices, runs without a usable spec, and specs that aren't grids (a spiral, or axes that are not x and y) keep the previous binning. The response also carries n_total, the number of points the scan will have (null without a spec), next to n_points. The grid is cached per spec since a run's spec never changes. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A client polls /binned several times a second while new points arrive only every couple of seconds, and each poll read three data channels (and the positions) from the HDF5 files and rebinned everything. Several clients, or one with overlapping requests, meant many concurrent readers of a file that is still being written. Identical requests (same run, same query) now share one computation while it is in flight, and its result is reused for DLS_TILED_VISR_CACHE_SECONDS (default 0.5, 0 turns it off). Errors reach everyone waiting on that call but are never kept. The response carries an ETag and Cache-Control: no-cache, and answers 304 when If-None-Match matches, which browsers send on their own for a fetch of the same URL, so an unchanged image costs a header. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Binning onto the scan's own grid sends one cell per setpoint, which is right for the scans the UI runs but would put a very large scan's full resolution into every poll. Beyond DLS_TILED_VISR_MAX_GRID_CELLS (default 100000) the default falls back to the coarse binning; width/height can still ask for more. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The ViSR plot needs at least three points on each axis (its own initial axes are built to have three), and the scan's setpoint grid has one axis point per setpoint, so a line scan or a 2-wide grid would hand it fewer. Those scans keep the previous default binning, as do runs with no usable spec. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Runs store each spec axis as whatever the plan passed in. Current runs carry ophyd-async's repr, Motor(name="sample_stage-x"), older ones an object repr with no name (<... Motor object at 0x...>), and plans that serialize with Spec.serialize() store the plain name. Only the last was recognised, so on current runs neither the setpoint ordering nor the scan grid applied, and fly scans, which have no motors list in their start document, had no fallback. _axis_name() now takes the name from a plain name or from a name="..." repr, and only falls back to the run's motors list for reprs without one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The grid is in the spec's coordinates. Fly scans currently record X and Y offset from their setpoints (about +1 in x and +10.4 in y on recent runs), so binning those positions onto the spec's grid left it empty. When fewer than 90% of the received positions fall inside the grid, the default binning is kept and the mismatch is logged. Setpoints mode always fits, since its positions are the setpoints themselves. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: true
Comment |
Welcome to Codecov 🎉Once you merge this PR into your default branch, you're all set! Codecov will compare coverage reports and display results in all future pull requests. Thanks for integrating Codecov - We've got you covered ☂️ |
Fly scans record X and Y from the PandA's encoder capture, which on b01-1 is offset from the motors' user coordinates: on every recent fly run, by 10.388 in y and about 0.92-0.99 in x, while the scale matches the spec. Their positions never landed on the spec's grid, so fly scans kept the coarse default binning with a range that grows as the scan fills in. When the positions don't fit the grid, each point's recorded position minus its own setpoint (matched by index) gives an offset per axis; the median of those is robust to a few dropped or late points. If the shifted positions sit within half a cell of their setpoints and fit the grid, they are binned there and the shift is reported as position_offset; otherwise the default binning stays. Positions that already fit are never shifted, so this switches itself off once the capture is in the motors' coordinates. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The mask was sized from readbacks.size, which counts every element of the (3, n) positions array, then combined with a per-point comparison of shape (n,). Any request with slice_dim failed with a broadcasting error (a 500). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In setpoints mode the positions come from the spec and are complete from the first poll, but they took part in the length reconciliation like readbacks do. While a scan was running the data could never reach them, so every poll re-fetched everything through the whole retry budget (about half a second) before truncating. The setpoints are now left out of the waiting and simply cut to the length of the data. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds endpoint tests for setpoints=true (with and without a spec, and while a scan is running), slice_dim (masking and the three ways it is rejected), swapped x_dim_index/y_dim_index, positions stored under primary/internal or as sample_stage-x/-y, the length reconciliation (truncating, and waiting for a channel that catches up), and a file behind the catalog through the whole app (retried to a 200, or answered 503 with Retry-After). They found the two previous fixes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Welcome to Codecov 🎉Once you merge this PR into your default branch, you're all set! Codecov will compare coverage reports and display results in all future pull requests. Thanks for integrating Codecov - We've got you covered ☂️ |
|
Thanks Hiran, this is a clear improvement and the PR description is excellent. I checked it out at Most of my comments are about the grid and offset heuristics. When they misfire, they quietly produce a plausible image that is wrong. None of these cases affect the b01-1 runs you checked, because there the y offset (10.4) is much larger than the grid. Two of them are worth fixing before merge:
Smaller points: Nits: Happy to approve once 1 and 2 are addressed or you've judged them not to apply. |
GET /api/v1/render/{run} returns the scan drawn into a viewport (x0..x1,
y0..y1 in data coordinates, `size` pixels along the longer side), as JSON
rows per channel plus coverage, or as a tinted RGBA PNG of one channel.
The viewport defaults to the scan's grid extent, so a client can pan and
zoom by changing it.
Three kinds of figure:
- cells: the scan's setpoint grid cells, each the mean of its points
- rectangles: a nominal-cell-size rectangle centred on each recorded
(aligned) position
- gaussian: a beam-sized Gaussian per point, so gaps between beam
footprints show as low coverage; the beam comes from the request,
start["beam_size"], or falls back to the scan step
Point loading is split out of /binned into load_points() so both
endpoints share it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kind=ovals draws a beam-sized oval (from beam_x/beam_y, start["beam_size"], or the scan step) on each point, filled with the point's value. Where ovals overlap, the overlap is split along the line through the points where their outlines cross, each side taking its own oval's colour, rather than blending. For ovals of one size that line is where the centres are equally far in units of the radii, so each pixel takes the nearest centre in those units, which also splits three-way overlaps consistently. Each oval's outline is drawn as a faint white line (line_width pixels at line_opacity, 0 for none): an "outline" layer in JSON, drawn into the PNG. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The grid was used when 90% of the recorded positions fell inside its outer edges, and an offset was looked for only when they didn't, so an offset smaller than the grid (half a cell, say) went unnoticed and the image came out striped or shifted by a column. The offset was also found by pairing points with setpoints by index alone, so a frame gained or dropped at the start of a raster gave an offset one cell out that still fit. place_on_grid() now pairs each recorded point with its own setpoint, always takes the median difference as the offset (ignoring offsets under a tenth of a cell), and uses the grid when most points then lie within half a cell of their setpoint. It pairs by index and one place either way, keeping the pairing the most points fit; only points where a row wraps tell those apart, so a non-zero offset is accepted only once the scan is past its first row (before that, a scan needing one keeps the default binning rather than showing a grid with a guessed offset). The positions are now placed before slice_dim is applied, since slicing breaks the pairing by index, and n_points counts the points after slicing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
_shared ran the computation in the first caller's own task, so cancelling that request (its client disconnecting, say) stored the CancelledError for every other caller waiting on the same result. The computation now runs as a task of its own that every caller, the first included, awaits through asyncio.shield, so a caller going away only stops it waiting. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A bin with points but a NaN mean (a NaN value among them) was written as NaN, which isn't JSON, so JSON.parse in the UI threw on the whole response. Bins whose mean is NaN or infinite are now null, like empty bins, in /binned and /render, and both serialise with allow_nan=False so anything else non-finite fails loudly on the server instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The pattern matched "name=" anywhere, so Motor(device_name="stage", name="sample_stage-x") gave "stage". It now matches only an argument called name, after the opening parenthesis or a comma. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tiled's compression middleware gzips large responses and keeps the ETag header, so the tag can only promise the same content, not the same bytes. /binned and /render now send it as W/"...", and match If-None-Match on the quoted tag alone, so it's recognised whether it comes back weak or strong. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Binning and serialising a large grid ran on the event loop, stalling every other request: a 316 x 316 grid (allowed by the old 100000-cell cap) takes about 0.08 s and makes 5.8 MB of JSON. The histograms and the JSON encoding of /binned, and the drawing and encoding of /render, now run in a worker thread. The default DLS_TILED_VISR_MAX_GRID_CELLS is now 40000 (200 x 200, about 2.3 MB per poll), and the cache of spec grids, which keeps every point's setpoints, holds 8 runs rather than 32. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Safe today only because the endpoints read through root_tree.lookup_adapter, bypassing Tiled's access policy; per-run access control would need the principal or its access tags in the key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DLS_TILED_VISR_NOT_READY_RETRIES=-1 skipped the retry loop entirely and hit its "unreachable" assert, answering 500. Retry counts are now read as non-negative (a negative one counts as 0). The Retry-After of the 503 now follows Tiled's own TILED_ARRAY_RETRY_AFTER, as its array routes do. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s them Runs whose spec axes are object reprs take their axis names from start["motors"], on the assumption that it lists the motors in the spec's axis order. On the b01-1 runs that rely on it, it doesn't hold for 39 of 103: they list x first for a spec whose outer axis is y, so their grid had x and y swapped and they fell back to the default binning. For such runs place_on_grid now also tries the grid with x and y swapped and keeps the orientation the recorded points fit. Specs that name their own axes are taken as they are. Checked on the b01-1 runs: all 35 that list their motors the other way round and have a grid's worth of moving points now bin on their grid, the right way round; the 63 listed in order are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks Giles, all of these were right. Fixed in nine commits (
Done in
Also in
I don't. I'm asking the fly-scan owner.
Done that way in
Covered by the first-row rule in 2. Until then, a scan that needs an offset keeps the default binning, so there's one switch to the grid early on rather than a grid with a guessed offset.
Done in
Now
Weak now (
Both, in
Added at both keys (
Now counted after. Positions are also placed on the grid before the mask, so the pairing by index survives slicing.
Both in
It doesn't. On the b01-1 runs whose spec axes are object reprs, 39 of 103 list x first for a spec whose outer axis is y. For those runs
Done: aligned before masking, with a test of a z-innermost scan with an offset. The branch also now has the |
|
Thanks Hiran, that's a thorough set of fixes. I checked it out at One question on On the new
Nits: I'm happy to approve the |
Two changes to the ViSR plugin:
/binnednow follows the scan's own spec, and a new prototype endpoint,/render, draws the figure on the server./binnedfollows the scanThe image showed up transposed, coarse (numpy's default 10×10) and jittery (its range grew on nearly every poll). Now:
start.spec, fixed from the first poll, withnullfor empty cellsposition_offsetRetry-After(was 422); identical polls share one result for 0.5 s, withETag/304slice_dimfailed every request;setpoints=trueswapped x/y and stalled about 0.5 s per poll during a scanA 1024-point fly scan replayed the way the ViSR UI draws it. Top row: before. Bottom row: after.
The deployed UI needs no change. Checked on every recent step and fly scan in the b01-1 Tiled: each puts one point per cell on its native grid.
/render(prototype)GET /api/v1/render/{run}returns the figure for a viewport (x0,x1,y0,y1,sizein pixels), so the UI can pan and zoom by changing it. The response is JSON per channel, or a PNG withformat=png.kindpicks how points are drawn:cells: the scan's grid cellsrectangles: a step-sized rectangle on each recorded positiongaussian: a beam-sized blob per pointovals: a beam-sized oval per point with a faint outline; overlaps are split between neighbours rather than blended, and areas no beam covered stay emptyThe beam size comes from the request, then
start["beam_size"], then the scan step. Nothing uses/renderyet.Not tested yet
A live scan with this build deployed, in particular the 503 path and the cache through the ingress.
🤖 Generated with Claude Code