Skip to content

ViSR plugin: scan-aware /binned and a prototype /render endpoint - #6

Merged
gilesknap merged 25 commits into
DiamondLightSource:mainfrom
hyperrealist:scan-aware-binned
Oct 13, 2026
Merged

gilesknap merged 25 commits into
DiamondLightSource:mainfrom
hyperrealist:scan-aware-binned

Conversation

@hyperrealist

@hyperrealist hyperrealist commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Two changes to the ViSR plugin: /binned now follows the scan's own spec, and a new prototype endpoint, /render, draws the figure on the server.

/binned follows the scan

The image showed up transposed, coarse (numpy's default 10×10) and jittery (its range grew on nearly every poll). Now:

  • rows are y; with no bins requested, the image uses the scan's setpoint grid from start.spec, fixed from the first poll, with null for empty cells
  • fly scans: PandA-captured positions are offset from the motors (≈1 in x, 10.388 in y on b01-1); the endpoint measures and removes the offset and reports it as position_offset
  • a file still behind the catalog gets a short retry, then 503 with Retry-After (was 422); identical polls share one result for 0.5 s, with ETag/304
  • fixed: slice_dim failed every request; setpoints=true swapped x/y and stalled about 0.5 s per poll during a scan
fly_scan_6185ff0c

A 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, size in pixels), so the UI can pan and zoom by changing it. The response is JSON per channel, or a PNG with format=png. kind picks how points are drawn:

  • cells: the scan's grid cells
  • rectangles: a step-sized rectangle on each recorded position
  • gaussian: a beam-sized blob per point
  • ovals: a beam-sized oval per point with a faint outline; overlaps are split between neighbours rather than blended, and areas no beam covered stay empty

The beam size comes from the request, then start["beam_size"], then the scan step. Nothing uses /render yet.

image

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

hyperrealist and others added 10 commits October 7, 2026 10:39
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>
@hyperrealist
hyperrealist marked this pull request as draft October 7, 2026 15:51
@coderabbitai

coderabbitai Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Oct 7, 2026

Copy link
Copy Markdown

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 ☂️

hyperrealist and others added 4 commits October 13, 2026 10:09
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>
@codecov

codecov Bot commented Oct 13, 2026

Copy link
Copy Markdown

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 ☂️

@hyperrealist
hyperrealist marked this pull request as ready for review October 13, 2026 09:57
@hyperrealist hyperrealist self-assigned this Oct 13, 2026
@gilesknap

Copy link
Copy Markdown
Contributor

Thanks Hiran, this is a clear improvement and the PR description is excellent. I checked it out at 6a306a9. The full suite passes (57 tests), as do ruff, ruff format and pyright. The transpose, the null bins, the 503/Retry-After path (which matches tiled's own DataNotReadyError handling), axis matching by name, the slice_dim fix and the non-waiting setpoints all look correct.

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:

  1. The "fits" check only uses the bounding box (positions_fit_grid, visr.py:282-309). If an offset is small enough that 90% of points stay inside the grid edges, alignment is never attempted. For example, a 20×20 grid with x off by 0.5 cell comes out with 110/400 cells null and 96 cells double-filled, so the image is striped. With an offset of 0.9 cell the whole image shifts one column and position_offset stays null. This is the "capture partly fixed" case, e.g. y corrected but x still about 1 off. Suggestion: decide fit from each point's distance to its own setpoint, always compute the median offset, and apply it when it is more than about 0.1 cell and the residual check passes. A test with a 0.5-cell offset would cover it.
  2. The offset is matched by index (align_to_setpoints, visr.py:312-335). If a fly scan drops its first frame or gains a run-up frame, every pair is one step out. On a non-snaked raster the differences all agree, so the residual is about 0 and the offset is accepted one cell wrong: (1.526, 10.388) instead of (1.0, 10.388), and the image is shifted by one column. Snaked scans fail the residual check, so they are safe. Suggestion: also try lags −1, 0 and +1 and keep the best, or take the offset modulo the cell pitch. A test that drops index 0 on a raster would cover it. Do you know whether the PandA can drop or add a frame at the start?

Smaller points:
3. Cancellation in _shared (visr.py:75-106). The first caller computes the result in its own task. If that task is cancelled, the CancelledError is stored in the future, so every waiter fails with it too. Running compute() as its own task and having all callers await asyncio.shield(task) would avoid this.
4. Alignment at n = 1 or 2 always "succeeds", because the residual is 0 by construction. In the x_scale=3 test case, the endpoint uses the grid with a bogus offset at the first poll, then falls back to 10×10 once n reaches 3, which brings back the layout jumping. A minimum point count before accepting an offset would stop this.
5. NaN. json.dumps (visr.py:579) writes NaN, which is not valid JSON, for bins that have points but a NaN mean, so JSON.parse throws in the UI. Previously JSONResponse raised instead. Mapping non-finite values to None and using allow_nan=False would handle both.
6. _REPR_NAME_RE (visr.py:175) has no word boundary, so Motor(device_name="foo", name="...") picks up foo. Use \bname= or (?:\(|,\s*)name=.
7. ETag. tiled's compression middleware gzips the body but keeps the ETag, so it should be a weak one (W/"...").
8. Performance. MAX_GRID_CELLS=100000 permits a 316×316 grid. That takes about 0.11 s to bin and serialise on the event loop and produces a 5.8 MB payload. Consider a lower default, or anyio.to_thread.run_sync. _grid_from_spec's lru_cache(32) holds the full setpoint arrays, which could reach about 0.5 GB in the worst case with large 3-D specs.
9. Cache key. It has no principal. That is fine today, because the endpoint reads through root_tree.lookup_adapter and bypasses tiled's access policy anyway. If per-run access control is added later, the key needs the principal or the access tags. A comment at visr.py:562 would save someone a surprise.

Nits: n_points is counted before slice_dim masking. A negative DLS_TILED_VISR_NOT_READY_RETRIES reaches the "unreachable" assert and returns a 500, so clamp it. RETRY_AFTER_SECONDS could reuse tiled's TILED_ARRAY_RETRY_AFTER. _axis_letters assumes start["motors"] is in spec.axes() order; can you confirm that holds for the older plans? slice_dim on a 3-D scan whose z axis is innermost falls back to 10×10. That is safe, but aligning before masking would keep the grid.

Happy to approve once 1 and 2 are addressed or you've judged them not to apply.

hyperrealist and others added 2 commits October 13, 2026 11:58
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>
@hyperrealist hyperrealist changed the title Make /binned images follow the scan: right orientation, native grid, steadier polling ViSR plugin: scan-aware /binned and a prototype /render endpoint Oct 13, 2026
hyperrealist and others added 4 commits October 13, 2026 14:39
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>
hyperrealist and others added 5 commits October 13, 2026 14:44
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>
@hyperrealist

Copy link
Copy Markdown
Contributor Author

Thanks Giles, all of these were right. Fixed in nine commits (c11a6d8..c5d54df), one per point, each with a test.

  1. The "fits" check only uses the bounding box (positions_fit_grid, visr.py:282-309). If an offset is small enough that 90% of points stay inside the grid edges, alignment is never attempted. [...] A test with a 0.5-cell offset would cover it.

Done in c11a6d8: place_on_grid replaces positions_fit_grid and align_to_setpoints. It pairs each point with its own setpoint, always takes the median offset (ignoring anything under 0.1 cell), and uses the grid when most points then sit within half a cell of their setpoint. Your 0.5- and 0.9-cell cases on a 20×20 grid now give the offset and one point per cell.

  1. The offset is matched by index (align_to_setpoints, visr.py:312-335). If a fly scan drops its first frame or gains a run-up frame, every pair is one step out. [...] Suggestion: also try lags −1, 0 and +1 and keep the best, or take the offset modulo the cell pitch. A test that drops index 0 on a raster would cover it.

Also in c11a6d8: it tries lags −1, 0 and +1 and keeps the one that puts the most points on their setpoint. The median error can't pick the lag (the wrong one scores ~0 too); only the points where a row wraps tell them apart, so a non-zero offset is accepted only once the scan is past its first row. Tests drop the first frame and add a run-up frame on a raster; with index-only pairing the dropped frame gives x 2.0 instead of 1.0.

Do you know whether the PandA can drop or add a frame at the start?

I don't. I'm asking the fly-scan owner.

  1. Cancellation in _shared (visr.py:75-106). [...] Running compute() as its own task and having all callers await asyncio.shield(task) would avoid this.

Done that way in 3578903, with a test that cancels the first caller while three others wait.

  1. Alignment at n = 1 or 2 always "succeeds", because the residual is 0 by construction. [...] A minimum point count before accepting an offset would stop this.

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.

  1. NaN. json.dumps (visr.py:579) writes NaN, which is not valid JSON, for bins that have points but a NaN mean [...]. Mapping non-finite values to None and using allow_nan=False would handle both.

Done in f44f304, for /binned and /render. NaN and infinite means are null, and the test parses the response as strict JSON.

  1. _REPR_NAME_RE (visr.py:175) has no word boundary, so Motor(device_name="foo", name="...") picks up foo.

Now (?:\(|,\s*)name= (1d30aba), tested with device_name= and prefix= before name=.

  1. ETag. tiled's compression middleware gzips the body but keeps the ETag, so it should be a weak one (W/"...").

Weak now (b133e2d). If-None-Match matches on the quoted tag, so it works sent back either way.

  1. Performance. MAX_GRID_CELLS=100000 permits a 316×316 grid. [...] Consider a lower default, or anyio.to_thread.run_sync. _grid_from_spec's lru_cache(32) holds the full setpoint arrays [...]

Both, in 6a7abba. Binning and JSON encoding run in a worker thread (as does /render's drawing), and the default cap is now 40000 cells: 200×200 is 0.03 s and 2.3 MB here, against 0.08 s and 5.8 MB for 316×316. The spec-grid cache holds 8 runs.

  1. Cache key. It has no principal. [...] A comment at visr.py:562 would save someone a surprise.

Added at both keys (36d6795).

Nits: n_points is counted before slice_dim masking.

Now counted after. Positions are also placed on the grid before the mask, so the pairing by index survives slicing.

A negative DLS_TILED_VISR_NOT_READY_RETRIES reaches the "unreachable" assert and returns a 500, so clamp it. RETRY_AFTER_SECONDS could reuse tiled's TILED_ARRAY_RETRY_AFTER.

Both in 2df073a. Retry counts are clamped at 0, and Retry-After follows TILED_ARRAY_RETRY_AFTER.

_axis_letters assumes start["motors"] is in spec.axes() order; can you confirm that holds for the older plans?

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 place_on_grid now also tries x and y swapped and keeps whichever the recorded points fit (c5d54df). All 35 such runs with moving motors now bin on their grid the right way round, and the 63 listed in order are unchanged; the rest are runs where the motors didn't move. Specs that name their own axes (everything recent) are taken as they are. setpoints=true still follows the motors list for those old runs, since it doesn't read the positions.

slice_dim on a 3-D scan whose z axis is innermost falls back to 10×10. That is safe, but aligning before masking would keep the grid.

Done: aligned before masking, with a test of a z-innermost scan with an offset.

The branch also now has the /render prototype from the description, which shares this loading and placement.

@gilesknap

Copy link
Copy Markdown
Contributor

Thanks Hiran, that's a thorough set of fixes. I checked it out at c5d54df: 102 tests pass, ruff/format/pyright clean. The per-point placement and the lag search look right to me. Using the row wraps to choose the lag is the key insight, and waiting for the first wrap also deals with point 4. Shield-based _shared, strict JSON, weak ETag and the off-loop binning all look good, and thanks for checking the motors-list ordering against the real b01-1 runs.

One question on /binned: MIN_POINTS_ON_SETPOINT is 0.5, down from 0.9. That lets a run with nearly half its points more than half a cell off use the grid, which brings the striping back. Was 0.5 needed for the b01-1 data, or could it be higher?

On the new /render prototype:

  1. Line scans 500 for rectangles and gaussian. For 1-D scans y is NaN (visr.py:448 / fill_data), and int(numpy.floor(nan)) raises at rendering.py:121 and :158. oval_layout already skips non-finite positions; the other two need the same.
  2. Request cost is unbounded. beam_*/cell_* aren't validated, so a huge beam makes every point paint the whole viewport: 2000 points at 1024 px took 5.3 s here, and the limits allow 40k points at 2048 px. Varying a parameter gets past the dedup. A zero beam divides by zero. I'd require them to be positive and finite, and cap them at the viewport size, or cap points × window area.
  3. The grid is used even when placement failed. load_points returns grid whether or not on_grid is set, so /render takes its extent, step and cells edges from a grid the unshifted points don't sit on (e.g. blank during a fly scan's first row). /binned falls back here; /render should check points.on_grid too.

Nits: x0 > x1 silently draws nothing for rectangles/gaussian (normalise or reject it), and an empty path makes uid = [...][0] an IndexError.

I'm happy to approve the /binned side now. Since /render is a prototype, would you rather fix 1–3 here or split it into its own PR?

@gilesknap
gilesknap merged commit c1dcb1e into DiamondLightSource:main Oct 13, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants