From 4efd64bacb432a6c37a6f562e5d1383be640511d Mon Sep 17 00:00:00 2001 From: Hiran Wijesinghe Date: Wed, 14 Oct 2026 13:15:21 +0100 Subject: [PATCH 1/3] Use the scan grid for scans of any size, down to a single point The grid needed at least 3 setpoints on each axis, on the assumption that ViSR's plot needs three points per axis; it doesn't (2x2 and 1x1 images render), so 2x2 scans, line scans and single points fell back to the default 10x10 binning for no reason. Any grid size is now used. An axis with a single setpoint has no step of its own, so its cell takes the other axis's step (1 for a single point). Offsets still need a row to wrap before they are taken, so a fly scan along a single line keeps the default binning: on one row, an offset can't be told from a frame gained or lost at the start. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Fy4Rv41HcbMRMkxzmCCJga --- README.md | 5 ++- src/dls_tiled/visr.py | 31 ++++++++++----- tests/test_visr.py | 92 +++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 113 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 431b118..b4a65cc 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,9 @@ setpoints in the run's start document with `setpoints=true`. Unless `width`/`height` or a full `xmin`/`xmax`/`ymin`/`ymax` range are given, the bins follow the scan's setpoint grid from its spec (one cell per setpoint, -fixed from the first poll), provided the spec is a grid of at least 3×3 and the -recorded positions lie on it. Positions that are off the grid by a constant +fixed from the first poll), provided the spec lays its points out on a grid +(any size, down to a line or a single point) and the recorded positions lie on +it. Positions that are off the grid by a constant amount per axis (as fly scans currently record them) are first shifted onto it by the median of each point's position minus its setpoint, and the shift is returned as `position_offset`. Otherwise numpy's default 10×10 over the data. diff --git a/src/dls_tiled/visr.py b/src/dls_tiled/visr.py index 06817e7..3a7be1e 100644 --- a/src/dls_tiled/visr.py +++ b/src/dls_tiled/visr.py @@ -227,10 +227,16 @@ def _xyz_order(axes: list, motors: object) -> list[int]: return sorted(range(len(axes)), key=lambda i: _AXIS_LETTERS.index(letters[i])) -def _bin_edges(centres: numpy.ndarray) -> numpy.ndarray: - """Edges halfway between consecutive centres, extended half a step at each end.""" +def _bin_edges(centres: numpy.ndarray, single_width: float = 1.0) -> numpy.ndarray: + """Edges halfway between consecutive centres, extended half a step at each end. + + A single centre has no neighbour to take a step from, so its cell is + ``single_width`` wide. + """ if len(centres) == 1: - return numpy.array([centres[0] - 0.5, centres[0] + 0.5]) + return numpy.array( + [centres[0] - single_width / 2, centres[0] + single_width / 2] + ) mids = (centres[:-1] + centres[1:]) / 2 first = centres[0] - (centres[1] - centres[0]) / 2 last = centres[-1] + (centres[-1] - centres[-2]) / 2 @@ -241,9 +247,6 @@ def _bin_edges(centres: numpy.ndarray) -> numpy.ndarray: # poll (200 x 200 is about 2.3 MB of JSON), so the caller falls back to the # coarse default binning. MAX_GRID_CELLS = int(os.getenv("DLS_TILED_VISR_MAX_GRID_CELLS", "40000")) -# The ViSR plot needs at least three points on each axis, so a scan narrower than -# that keeps the previous default binning. -MIN_CELLS_PER_AXIS = 3 class ScanGrid(NamedTuple): @@ -295,13 +298,23 @@ def _grid_from_spec(spec_json: str, motors_json: str) -> ScanGrid | None: y_setpoints = numpy.asarray(by_axis[axes[letters.index("y")]], dtype=float) xs = numpy.unique(numpy.round(x_setpoints, 9)) ys = numpy.unique(numpy.round(y_setpoints, 9)) - if min(len(xs), len(ys)) < MIN_CELLS_PER_AXIS: - return None # a line, or too few cells for the plot's axes + if not len(xs) or not len(ys): + return None # a spec with no points if len(xs) * len(ys) > min(n_total, MAX_GRID_CELLS): return None # more cells than points: not a grid + # An axis with one setpoint (a line scan, or a single point) has no step of + # its own, so its cell takes the other axis's step, or 1 if neither has one. + step_x = float(numpy.min(numpy.diff(xs))) if len(xs) > 1 else None + step_y = float(numpy.min(numpy.diff(ys))) if len(ys) > 1 else None + single = step_x or step_y or 1.0 named = None not in (_axis_name(a) for a in axes) return ScanGrid( - _bin_edges(xs), _bin_edges(ys), n_total, x_setpoints, y_setpoints, named + _bin_edges(xs, single), + _bin_edges(ys, single), + n_total, + x_setpoints, + y_setpoints, + named, ) diff --git a/tests/test_visr.py b/tests/test_visr.py index 21d1ed8..cd18253 100644 --- a/tests/test_visr.py +++ b/tests/test_visr.py @@ -332,10 +332,28 @@ def test_grid_is_none_when_it_has_more_cells_than_the_cap(monkeypatch): visr._grid_from_spec.cache_clear() -def test_grid_is_none_for_a_scan_narrower_than_three_setpoints(): - # a line, and a 2-wide grid: too few points on an axis for the UI's plot axes - assert _grid_or_none(Line("y", 0, 2, 3) * Line("x", 3, 3, 1)) is None - assert _grid_or_none(Line("y", 0, 2, 3) * Line("x", 0, 1, 2)) is None +def test_grid_of_a_two_by_two_scan(): + x_edges, y_edges, n_total, *_ = _grid(Line("y", 0, 2, 2) * ~Line("x", 0, 2, 2)) + + assert x_edges.tolist() == [-1.0, 1.0, 3.0] + assert y_edges.tolist() == [-1.0, 1.0, 3.0] + assert n_total == 4 + + +def test_grid_of_a_line_takes_its_cell_height_from_the_scan_step(): + # one setpoint in y: no step of its own, so its cell is as tall as x's step + x_edges, y_edges, *_ = _grid(Line("y", 5, 5, 1) * Line("x", 0, 2, 3)) + + assert x_edges.tolist() == [-0.5, 0.5, 1.5, 2.5] + assert y_edges.tolist() == [4.5, 5.5] + + +def test_grid_of_a_single_point(): + x_edges, y_edges, n_total, *_ = _grid(Line("y", 1, 1, 1) * Line("x", 2, 2, 1)) + + assert x_edges.tolist() == [1.5, 2.5] + assert y_edges.tolist() == [0.5, 1.5] + assert n_total == 1 def write_grid_scan(client, uid, n_points): @@ -392,6 +410,72 @@ def test_binned_without_a_spec_still_reports_no_total(client): assert body["n_total"] is None +def write_small_scan(client, uid, spec, x, y, x_shift=0.0, y_shift=0.0): + """A run with this spec and these recorded positions, values 0, 1, 2...""" + write_points( + client, + uid, + numpy.asarray(x, dtype=float) + x_shift, + numpy.asarray(y, dtype=float) + y_shift, + metadata={"start": {"spec": spec.serialize()}}, + ) + + +def test_binned_shows_a_two_by_two_scan_on_its_grid(client): + write_small_scan( + client, + "two", + Line("y", 0, 2, 2) * ~Line("x", 0, 2, 2), + [0, 2, 2, 0], + [0, 0, 2, 2], + ) + + body = client.context.http_client.get("/api/v1/binned/two").json() + + # snaked: the second row runs back, so its first point is on the right + assert body["RedTotal"] == [[0.0, 1.0], [3.0, 2.0]] + assert body["x_limits"] == [-1.0, 1.0, 3.0] + + +def test_binned_shows_a_single_point_as_one_cell(client): + write_small_scan(client, "one", Line("y", 1, 1, 1) * Line("x", 2, 2, 1), [2], [1]) + + body = client.context.http_client.get("/api/v1/binned/one").json() + + assert body["RedTotal"] == [[0.0]] + assert (body["x_limits"], body["y_limits"]) == ([1.5, 2.5], [0.5, 1.5]) + + +def test_binned_shows_a_line_scan_as_one_row(client): + write_small_scan( + client, "line", Line("y", 5, 5, 1) * Line("x", 0, 2, 3), [0, 1, 2], [5, 5, 5] + ) + + body = client.context.http_client.get("/api/v1/binned/line").json() + + assert body["RedTotal"] == [[0.0, 1.0, 2.0]] + assert body["y_limits"] == [4.5, 5.5] + + +def test_binned_keeps_the_default_binning_for_an_offset_line_scan(client): + # a single row never wraps, so an offset can't be told from a frame gained + # or lost at the start: no offset is taken, and the default binning stays + write_small_scan( + client, + "flyline", + Line("y", 5, 5, 1) * Line("x", 0, 2, 3), + [0, 1, 2], + [5, 5, 5], + x_shift=1.0, + y_shift=10.4, + ) + + body = client.context.http_client.get("/api/v1/binned/flyline").json() + + assert body["position_offset"] is None + assert len(body["RedTotal"]) == 10 + + # --- sharing work between polls ------------------------------------------------------ From 831578f493c0ad19c303981509c0efbfddfe8928 Mon Sep 17 00:00:00 2001 From: Hiran Wijesinghe Date: Wed, 14 Oct 2026 14:18:52 +0100 Subject: [PATCH 2/3] Leave out points without a position instead of failing with a 500 A run whose recorded positions include NaN (an x-only scan, where y is filled with NaN) made numpy's default binning fail to find a range, so /binned and /render answered 500. Points with no x or y position are now left out before binning; if that leaves none of a run's points, the answer is a 422 saying which positions are missing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BsMDFhupHcBFbVJPK8QAvR --- src/dls_tiled/visr.py | 24 ++++++++++++++++++++++++ tests/test_visr.py | 22 ++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/src/dls_tiled/visr.py b/src/dls_tiled/visr.py index 3a7be1e..c4bcf0a 100644 --- a/src/dls_tiled/visr.py +++ b/src/dls_tiled/visr.py @@ -889,6 +889,30 @@ def current_lengths() -> dict[str, int]: readbacks = readbacks[:, mask] data = {channel: d[mask] for channel, d in data.items()} + # A point with no position on x or y (NaN: a run that only moved x has no + # y readings, say) can't be placed in any bin, and numpy's default binning + # can't take a range from it. Drop such points; if that leaves none of a + # run's points, there is nothing to show, so say why. + positioned = numpy.isfinite(readbacks[x_dim_index]) & numpy.isfinite( + readbacks[y_dim_index] + ) + if not positioned.all(): + if not positioned.any(): + missing = [ + axis + for axis, dim in (("x", x_dim_index), ("y", y_dim_index)) + if not numpy.isfinite(readbacks[dim]).any() + ] + raise HTTPException( + status_code=HTTP_422_UNPROCESSABLE_CONTENT, + detail=( + f"Run '{uid}' has no recorded " + f"{' or '.join(missing) or 'x and y'} positions to bin by" + ), + ) + readbacks = readbacks[:, positioned] + data = {channel: d[positioned] for channel, d in data.items()} + assert isinstance(red_total, (H5Dataset, numpy.ndarray)) logger.debug( "readbacks shape %s, red_total shape %s (sliced=%s)", diff --git a/tests/test_visr.py b/tests/test_visr.py index cd18253..5fe484c 100644 --- a/tests/test_visr.py +++ b/tests/test_visr.py @@ -476,6 +476,28 @@ def test_binned_keeps_the_default_binning_for_an_offset_line_scan(client): assert len(body["RedTotal"]) == 10 +def test_binned_leaves_out_points_without_a_position(client): + # no spec, so the default binning, whose range a NaN used to break + write_points(client, "gap", [0.0, 1.0, numpy.nan, 1.0], [0.0, 0.0, 1.0, 1.0]) + + body = client.context.http_client.get( + "/api/v1/binned/gap", params={"width": 2, "height": 2} + ).json() + + assert body["RedTotal"] == [[0.0, 1.0], [None, 3.0]] + assert body["n_points"] == 3 + + +def test_binned_answers_422_for_a_run_with_no_y_positions(client): + # an x-only run: y is filled with NaN, which used to fail with a 500 + write_points(client, "xonly", [0.0, 1.1, 2.2], [numpy.nan] * 3) + + response = client.context.http_client.get("/api/v1/binned/xonly") + + assert response.status_code == 422 + assert "no recorded y positions" in response.json()["detail"] + + # --- sharing work between polls ------------------------------------------------------ From 8e6e3ccde929eb82caac6bbe9110e69edce878a1 Mon Sep 17 00:00:00 2001 From: Hiran Wijesinghe Date: Mon, 19 Oct 2026 12:17:43 +0100 Subject: [PATCH 3/3] Test that a spec with no points has no grid Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BsMDFhupHcBFbVJPK8QAvR --- tests/test_visr.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/tests/test_visr.py b/tests/test_visr.py index 5fe484c..4e5643f 100644 --- a/tests/test_visr.py +++ b/tests/test_visr.py @@ -6,7 +6,7 @@ import numpy import pytest from fastapi import HTTPException -from scanspec.specs import Line, Spiral +from scanspec.specs import Ellipse, Line, Spiral from tiled.adapters.utils import DataNotReadyError from tiled.catalog import in_memory from tiled.client import Context, from_context @@ -356,6 +356,22 @@ def test_grid_of_a_single_point(): assert n_total == 1 +def test_grid_is_none_for_a_spec_with_no_points(): + # an ellipse narrower than its step holds no setpoints at all + spec = Ellipse( + x_axis="x", + x_centre=0.3, + x_diameter=0.01, + x_step=1, + y_axis="y", + y_centre=0.3, + y_diameter=0.01, + y_step=1, + ) + + assert _grid_or_none(spec) is None + + def write_grid_scan(client, uid, n_points): """The first `n_points` of a 3 (x) by 3 (y) raster; its spec is in the start doc.""" spec = Line("Y", 0, 2, 3) * Line("X", 0, 2, 3)