From 663a6cf518ff6057f23261ea45e40912e5cc6ba5 Mon Sep 17 00:00:00 2001 From: x12sa Date: Tue, 3 Nov 2026 12:10:23 +0100 Subject: [PATCH 1/6] feat(csaxs): csaxs.aerotech.plot() title shows the plotted scan number The history plot uses the given scan number; the live plot the last scan at start and then the number of each followed aerotech_cont_grid scan. The title is set in the redraw thread, only when the scan changes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017NrH3poJsYBCTVq2Y9fuHZ --- .../plugins/cSAXS/aerotech.py | 29 +++++++++++++++---- .../test_cSAXS_aerotech.py | 23 +++++++++++++++ 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/cSAXS/aerotech.py b/csaxs_bec/bec_ipython_client/plugins/cSAXS/aerotech.py index 22882e59..cfeee28f 100644 --- a/csaxs_bec/bec_ipython_client/plugins/cSAXS/aerotech.py +++ b/csaxs_bec/bec_ipython_client/plugins/cSAXS/aerotech.py @@ -53,8 +53,9 @@ class cSAXSAerotech: waveform, curves = self._setup_plot() if scan_number is not None: _draw(curves, self._history_lines(scan_number)) + waveform.title = _title(scan_number) return waveform - self._live = _LivePositions(self, curves) + self._live = _LivePositions(self, waveform, curves) self._live.start() return waveform @@ -74,7 +75,7 @@ class cSAXSAerotech: if waveform is None: waveform = window.new("Waveform", object_name=self.PLOT, timeout=self.GUI_RPC_TIMEOUT) waveform.clear_all() - waveform.title = "aerotech_cont_grid: exposure centres" + waveform.title = _title(None) waveform.x_label = "X centre (aerox)" waveform.y_label = "Y centre (aeroy)" waveform.lock_aspect_ratio = True @@ -96,6 +97,13 @@ class cSAXSAerotech: return _lines_from_file(scan._msg.file_path) +def _title(scan_number: int | None) -> str: + """Plot title with the number of the plotted scan.""" + if scan_number is None: + return "aerotech_cont_grid: exposure centres" + return f"aerotech_cont_grid scan {scan_number}: exposure centres" + + def _lines_from_file(file_path: str) -> list[tuple[np.ndarray, np.ndarray]]: """Exposure centres per line from an aerotech_cont_grid data file.""" with h5py.File(file_path, "r") as f: @@ -150,12 +158,15 @@ def _markers_only(x: np.ndarray, y: np.ndarray) -> tuple[np.ndarray, np.ndarray] class _LivePositions: """Follows aerotech_cont_grid scans and redraws the exposure centres per line.""" - def __init__(self, parent: cSAXSAerotech, curves: dict): + def __init__(self, parent: cSAXSAerotech, waveform, curves: dict): self.parent = parent self.connector = parent.client.connector + self.waveform = waveform self.curves = curves self._lock = threading.Lock() self._lines: list[tuple[np.ndarray, np.ndarray]] = [] + self._scan_number: int | None = None + self._shown_scan_number: int | None = None self._scan_id = None self._stream_topic = None self._dirty = threading.Event() @@ -177,7 +188,8 @@ class _LivePositions: def _show_last_scan(self) -> None: try: - self._set_lines(_lines_from_file(self.parent.client.history[-1]._msg.file_path)) + msg = self.parent.client.history[-1]._msg + self._set_lines(_lines_from_file(msg.file_path), scan_number=msg.scan_number) except Exception: # no scan yet, or the last one is not an aerotech_cont_grid pass @@ -191,7 +203,7 @@ class _LivePositions: return self._unfollow() self._scan_id = status.scan_id - self._set_lines([]) + self._set_lines([], scan_number=status.scan_number) self._stream_topic = MessageEndpoints.device_async_signal( scan_id=status.scan_id, device=self.parent.CAPTURE_DEVICE, @@ -216,9 +228,10 @@ class _LivePositions: self._lines.append((x, y)) self._dirty.set() - def _set_lines(self, lines) -> None: + def _set_lines(self, lines, scan_number: int | None = None) -> None: with self._lock: self._lines = list(lines) + self._scan_number = scan_number self._dirty.set() def _redraw_loop(self) -> None: @@ -229,8 +242,12 @@ class _LivePositions: self._dirty.clear() with self._lock: lines = list(self._lines) + scan_number = self._scan_number try: _draw(self.curves, lines) + if scan_number != self._shown_scan_number: + self.waveform.title = _title(scan_number) + self._shown_scan_number = scan_number except Exception as exc: # window or plot closed, GUI restarted # stop following instead of failing on every line of every scan logger.warning( diff --git a/tests/tests_bec_ipython_client/test_cSAXS_aerotech.py b/tests/tests_bec_ipython_client/test_cSAXS_aerotech.py index 49e8a053..21e5b169 100644 --- a/tests/tests_bec_ipython_client/test_cSAXS_aerotech.py +++ b/tests/tests_bec_ipython_client/test_cSAXS_aerotech.py @@ -76,6 +76,7 @@ def test_plot_history_draws_forward_and_backward_curves(): "Waveform", object_name="aerotech_positions", timeout=20 ) assert waveform.lock_aspect_ratio is True + assert waveform.title == "aerotech_cont_grid scan 8494: exposure centres" for curve in (forward, backward): x, y = curve.set_data.call_args.args # markers only: a NaN after every point, X finite and increasing @@ -132,9 +133,31 @@ def test_live_follows_an_aerotech_scan_line_by_line(): x, _ = backward.set_data.call_args.args assert len(x) == 2 * 5 + assert waveform.title == "aerotech_cont_grid scan 1: exposure centres" client.connector.unregister.assert_called() +def test_live_title_shows_the_last_scan(): + client = _client() + waveform = _waveform(client) + forward, backward = mock.MagicMock(), mock.MagicMock() + waveform.plot.side_effect = [forward, backward] + client.history.__getitem__.return_value._msg = SimpleNamespace( + file_path="scan.h5", scan_number=4897 + ) + tool = cSAXSAerotech(client) + with mock.patch( + "csaxs_bec.bec_ipython_client.plugins.cSAXS.aerotech._lines_from_file", + return_value=_snake(), + ): + tool.plot() + deadline = time.time() + 3 + while time.time() < deadline and "4897" not in str(waveform.title): + time.sleep(0.05) + tool.stop_plot() + assert waveform.title == "aerotech_cont_grid scan 4897: exposure centres" + + def test_live_ignores_other_scans(): client = _client() client.history.__getitem__.side_effect = IndexError -- 2.54.0 From a33d089d3df5a18399c31fe5348fbaf3f4ff7be6 Mon Sep 17 00:00:00 2001 From: x12sa Date: Tue, 3 Nov 2026 12:34:01 +0100 Subject: [PATCH 2/6] feat(aerotech): trigger calibration aligns forward and backward lines The backward exposure centres are offset from the forward ones by a velocity dependent amount (DDG1: +2 um at 2 mm/s, +19 um at 10 mm/s, +26 um at 20 mm/s, -9 um at 40 mm/s backward - forward, independent of exp_time). With trigger_calibration=True (default) the scan shifts trigger_low by +h and trigger_high by -h, h = half of backward - forward from the table userParameter "trigger_calibration" of the fast axis: linear between the calibrated velocities, proportional below the lowest, none above the highest. The applied offset is stored in the scan info (trigger_offset, scan_velocity); the statistics print the table entry the scan measured, for training scans with trigger_calibration=False. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017NrH3poJsYBCTVq2Y9fuHZ --- .../scans/scans_v4/aerotech_cont_grid.py | 80 +++++++++++++++ docs/developer/aerotech_stages.md | 23 +++++ .../saxs/aerotech_stages/aerotech_stages.md | 41 ++++++++ .../test_aerotech_cont_grid_scan.py | 98 ++++++++++++++++++- 4 files changed, 240 insertions(+), 2 deletions(-) diff --git a/csaxs_bec/scans/scans_v4/aerotech_cont_grid.py b/csaxs_bec/scans/scans_v4/aerotech_cont_grid.py index 6a8c089e..6d61d26a 100644 --- a/csaxs_bec/scans/scans_v4/aerotech_cont_grid.py +++ b/csaxs_bec/scans/scans_v4/aerotech_cont_grid.py @@ -59,6 +59,19 @@ of motion for the moving fast axis). At the end the scan reports statistics of t centres of the fast axis (deviation from the nominal cell centres per direction, backward - forward offset and its slope along the line) and of the line times. +Trigger calibration: in a snake the backward exposure centres are offset from the forward +ones, by an amount that depends on the scan velocity (DDG1 at the beamline, 2026-11-03: +backward - forward +2 um at 2 mm/s, +19 um at 10 mm/s, +26 um at 20 mm/s, -9 um at +40 mm/s; independent of exp_time within the scatter). With trigger_calibration=True the +scan shifts the trigger positions by the calibrated offset h (half of backward - forward): +trigger_low by +h, trigger_high by -h, so that the centres of both directions are on the +same cells. h comes from the table userParameter "trigger_calibration" of the fast axis +(device config, a list of {velocity, offset [um]}): linear between the calibrated +velocities, proportional to the velocity below the lowest one, no correction above the +highest one. The statistics at the end show the applied offset and the table entry this +scan measured (applied + half of the remaining backward - forward), for new or better +calibration points; the applied offset is stored in the scan info (trigger_offset). + Run-up / run-out of each line: twice the ramp distance v^2 / (2 * acceleration) + v * settle_time beyond the trigger positions. The whole range including run-up / run-out is checked against the soft limits before the scan starts. The scan velocity may be higher @@ -122,6 +135,24 @@ class _DeviceExample(str): DDG_EXT_RISING_EDGE = 1 DDG_EXT_FALLING_EDGE = 2 DDG_SINGLE_SHOT = 5 + + +def interpolate_trigger_offset(table: list[dict], velocity: float) -> float | None: + """ + Trigger offset h [um] for a scan velocity from a calibration table of + {"velocity": ..., "offset": ...} entries: linear between the calibrated velocities, + proportional to the velocity below the lowest one (no offset at rest), None above the + highest one or without a table. + """ + points = sorted((float(e["velocity"]), float(e["offset"])) for e in table) + if not points: + return None + velocities, offsets = zip(*points) + if velocity > velocities[-1] * (1 + 1e-9): + return None + if velocity <= velocities[0]: + return offsets[0] * velocity / velocities[0] + return float(np.interp(velocity, velocities, offsets)) DDG_TRIGGER_EDGES = {"rising": DDG_EXT_RISING_EDGE, "falling": DDG_EXT_FALLING_EDGE} # Extra run-up / run-out beyond the ramp distance, as a fraction of it (headroom for the @@ -136,6 +167,9 @@ MIN_RUN_UP = 0.01 TRIGGER_SOURCES = ("ddg", "panda", None) +# userParameter of the fast axis with the trigger calibration table, see the module docstring +TRIGGER_CALIBRATION_PARAMETER = "trigger_calibration" + # Poll interval of the fast axis position while waiting for the end of the exposures, s STEP_POLL_INTERVAL = 0.01 @@ -178,6 +212,7 @@ class AerotechContGrid(ScanBase): "relative", "trigger_source", "minimal_run_up", + "trigger_calibration", ], } @@ -201,6 +236,7 @@ class AerotechContGrid(ScanBase): settle_time: float = 0.15, minimal_run_up: bool = False, capture_device: str = "aerocapture", + trigger_calibration: bool = True, **kwargs, # fmt: on ): @@ -226,6 +262,7 @@ class AerotechContGrid(ScanBase): settle_time (float): Run-up at constant velocity before the trigger position, in s. Default is 0.15. minimal_run_up (bool): Run-up / run-out only the ramp distance v^2 / (2 * acceleration), at least 0.01, without the 2x margin and settle_time: faster lines, but the exposures start right after the ramp while the fast axis may still oscillate, so the captured positions are less evenly spaced. Default is False. capture_device (str): Name of the A3200PositionCapture device (always used). Default is "aerocapture". + trigger_calibration (bool): Shift the trigger positions by the calibrated offset of the fast axis for the scan velocity, so that forward and backward exposure centres are on the same cells (userParameter "trigger_calibration"). False for training scans of new calibration points. Default is True. Returns: ScanReport @@ -282,6 +319,7 @@ class AerotechContGrid(ScanBase): self._ddg1_shutter_delay = None # DDG1 value to restore after the scan self.settle_time = settle_time self.minimal_run_up = minimal_run_up + self.trigger_calibration = trigger_calibration devices = self.device_manager.devices self.ddg1 = self.ddg2 = self.mcs = self.panda = None @@ -358,6 +396,8 @@ class AerotechContGrid(ScanBase): computed_positions=positions, num_lines=num_lines, readout_time=self._line["readout_time"], + scan_velocity=self._line["target_velocity"], + trigger_offset=self._line["trigger_offset"], ) # progress bar in the client, which also prints the statistics at the end: the @@ -598,6 +638,7 @@ class AerotechContGrid(ScanBase): f" backward - forward [um]: mean {diff.mean():+.2f}, slope {slope:+.2f} / mm," f" peak-peak {np.ptp(diff):.2f}", ] + text.append(self._calibration_summary(diff.mean())) if self._line_starts and self._last_line_end is not None: # line period: start to start of the next line (motion, trigger check, capture # readout, wait for the step); the last line ends with its capture readout @@ -614,6 +655,40 @@ class AerotechContGrid(ScanBase): ) return "\n".join(text) + def _trigger_offset(self, velocity: float) -> float | None: + """Calibrated trigger offset [um] for the velocity, None if not applied.""" + if not self.trigger_calibration: + return None + # the device config, as user_parameter of DeviceBaseWithConfig (not on every device class) + config = getattr(self.fast_axis, "_config", None) # pylint: disable=protected-access + parameters = config.get("userParameter") if isinstance(config, dict) else None + table = ( + parameters.get(TRIGGER_CALIBRATION_PARAMETER) if isinstance(parameters, dict) else None + ) + offset = interpolate_trigger_offset(table or [], velocity) + if offset is None: + logger.warning( + f"aerotech_cont_grid: no trigger calibration of {self.fast_axis.name} for" + f" {velocity:g} / s, forward and backward exposures are not aligned." + ) + return offset + + def _calibration_summary(self, backward_minus_forward: float) -> str: + """Applied offset and the calibration entry measured by this scan.""" + line = self._line + applied = line.get("trigger_offset") + measured = (applied or 0.0) + backward_minus_forward / 2 + if applied is not None: + state = f"offset {applied:+.2f} um applied" + elif self.trigger_calibration: + state = "no calibration for this velocity" + else: + state = "off" + return ( + f" trigger calibration [um]: {state}; this scan measured" + f" {{velocity: {line['target_velocity']:g}, offset: {measured:.2f}}}" + ) + def _trigger_window(self, line_index: int) -> tuple[float, float]: """PSO window of a line: entered at the trigger position, open beyond the line end.""" line = self._line @@ -711,6 +786,11 @@ class AerotechContGrid(ScanBase): line["exposure_end"] = self.fast_start + self.frames_per_line * self.fast_step_size line["trigger_low"] = self.fast_start - lead + gap line["trigger_high"] = line["exposure_end"] + lead - gap + # calibrated offset [um]: later trigger in the direction of motion, both directions + line["trigger_offset"] = self._trigger_offset(velocity) + shift = (line["trigger_offset"] or 0.0) * 1e-3 + line["trigger_low"] += shift + line["trigger_high"] -= shift line["line_start"] = line["trigger_low"] - ramp_distance - margin line["line_end"] = line["trigger_high"] + ramp_distance + margin diff --git a/docs/developer/aerotech_stages.md b/docs/developer/aerotech_stages.md index b12d22d4..c1d12019 100644 --- a/docs/developer/aerotech_stages.md +++ b/docs/developer/aerotech_stages.md @@ -108,6 +108,29 @@ of -24 / +10 / +4.5 / -21 um (2026-10-29). against the soft limits before anything moves. The message separates the scan range from the run-up so a user can see why a scan inside the limits does not fit. +### Trigger calibration + +The backward exposure centres are offset from the forward ones by a velocity +dependent amount (DDG1 at the beamline, 2026-11-03, scans 4897-4917: backward minus +forward +0.9 / +2.1 / +9.3 / +18.6 / +25.7 / +3.0 / -8.8 um at 1 / 2 / 4 / 10 / 20 / 30 / +40 mm/s; 5 vs 20 ms exposure time the same within the scatter; repeats within 0.1 um +at 1 mm/s and 0.2 um at 40 mm/s). It is not a constant delay: about 0.5-1 ms up to +20 mm/s, then it falls and changes sign. With the PandA (2026-10-29) it was below 1 um at +20 mm/s. + +`trigger_calibration=True` (default) shifts `trigger_low` by `+h` and `trigger_high` +by `-h` (later in the direction of motion), with `h` = half of backward minus forward +from the table `userParameter.trigger_calibration` of the fast axis +(`interpolate_trigger_offset`: linear between the points, proportional to the velocity +below the lowest one, `None` above the highest one). The device config is read from +`_config` of the fast axis (the scan server's devices are `DeviceBaseWithConfig`, +whose `user_parameter` reads the same). The applied `h` is in the scan info +(`trigger_offset`, with `scan_velocity`); the statistics print the entry the scan +measured, `h_applied + (backward - forward) / 2`. Only the mean offset per direction is +corrected; the variation along the line at high velocity (oscillation after the ramp) +remains. The calibration is for the configured acceleration (500 mm/s^2) and +`settle_time` 0.15 s. + ### Step during the run-out As soon as X has left the exposure range of a line, the PSO window of the next line diff --git a/docs/user/saxs/aerotech_stages/aerotech_stages.md b/docs/user/saxs/aerotech_stages/aerotech_stages.md index c6f8185b..f5d22ede 100644 --- a/docs/user/saxs/aerotech_stages/aerotech_stages.md +++ b/docs/user/saxs/aerotech_stages/aerotech_stages.md @@ -74,6 +74,7 @@ scans.aerotech_cont_grid( | `settle_time` | constant velocity time before the first exposure of a line, default 0.15 s (leave it) | | `shutter_delay` | time from the line trigger to the first exposure, for the shutter to open. Default: the value of DDG1 (8 ms); a given value is set on DDG1 for this scan only | | `minimal_run_up` | `True`: run-up only the acceleration distance (at least 10 um), no settling. Faster lines, but the exposures are less evenly spaced; use the captured positions. Default `False` | +| `trigger_calibration` | `True` (default): forward and backward lines are aligned with the calibration of `aerox` (see below). `False` only for training scans of new calibration points | The number of points per line is `ceil(|fast_end - fast_start| / fast_step_size) + 1`, the number of lines `ceil(|stepper_stop - stepper_start| / stepper_step_size) + 1`. @@ -97,6 +98,46 @@ csaxs.aerotech.stop_plot() # stop following (the plot stays) Every exposure is shown as a marker. Forward and backward lines alternate, so the two colours alternate in Y by one line spacing. +### Forward and backward alignment (trigger calibration) + +Without correction the backward lines are offset from the forward lines along X, by +an amount that depends on the scan velocity (measured 2026-11-03 at the beamline, +backward minus forward): + +| Velocity [mm/s] | 0.5 | 1 | 2 | 4 | 8 | 10 | 20 | 30 | 40 | +|---|---|---|---|---|---|---|---|---|---| +| Offset [um] | -0.6 | +0.9 | +2.1 | +9.3 | +15.8 | +18.6 | +25.7 | +3.0 | -8.8 | + +It does not depend on the exposure time and is reproducible. The scan therefore moves +the trigger positions of both directions by half of it, so that forward and backward +exposures land on the same cells. The calibration is a table in the device config of +`aerox` (`userParameter` `trigger_calibration`, velocity and offset), interpolated +between the calibrated velocities. Above 40 mm/s there is no calibration: the scan +runs uncorrected and says so in its summary. + +The summary of each scan shows the applied offset and the table entry the scan +measured, for example: + +```text +trigger calibration [um]: offset +9.29 um applied; this scan measured {velocity: 10, offset: 9.41} +``` + +A calibrated scan should show a backward - forward mean of a few um at most. What +remains along a line at high velocity (rms per direction 4-7 um from 10 mm/s on) is +the motion of X and cannot be corrected by the trigger. + +**New calibration point** (other acceleration, settle time, or a velocity outside the +table): run a short scan with `trigger_calibration=False` at that velocity, for example +X +-1 mm, 6 lines: + +```python +scans.aerotech_cont_grid(dev.aerox, -1.0, 1.0, 0.1, dev.aeroy, -0.1, 0.1, 0.04, exp_time=0.005, relative=True, trigger_calibration=False) # 20 mm/s +``` + +and add the printed `{velocity: ..., offset: ...}` to `trigger_calibration` of `aerox` +in `sastt_aerotech.yaml` (ask the beamline staff to commit it). For a quick test without +a config change: `dev.aerox.update_user_parameter({...})`. + ### Examples Fields of view centred on the current position (`relative=True`), X fast, Y step: diff --git a/tests/tests_scans/test_aerotech_cont_grid_scan.py b/tests/tests_scans/test_aerotech_cont_grid_scan.py index 6e59cdd0..b8bcae7d 100644 --- a/tests/tests_scans/test_aerotech_cont_grid_scan.py +++ b/tests/tests_scans/test_aerotech_cont_grid_scan.py @@ -4,6 +4,7 @@ import numpy as np import pytest from bec_server.scan_server.errors import LimitError from bec_server.scan_server.tests.scan_fixtures import MockCustomDevice +from csaxs_bec.scans.scans_v4.aerotech_cont_grid import interpolate_trigger_offset from bec_server.scan_server.tests.scan_hook_tests import ( DEFAULT_HOOK_TESTS, PREMOVE_HOOK_TESTS, @@ -18,7 +19,12 @@ def _signal(obj_name, kind="config"): def _add_devices( - device_manager, capture=True, acceleration=100.0, panda=True, max_scan_velocity=10.0 + device_manager, + capture=True, + acceleration=100.0, + panda=True, + max_scan_velocity=10.0, + user_parameter=None, ): aerox = MockCustomDevice( "aerox", @@ -37,6 +43,7 @@ def _add_devices( "aerox_wait_in_position": True, }, ) + aerox._config = {"userParameter": user_parameter or {}} aerox.arm_line_trigger = mock.MagicMock() aerox.disarm_line_trigger = mock.MagicMock() aerox.set_line_trigger_window = mock.MagicMock() @@ -101,7 +108,7 @@ def _assemble(v4_scan_assembler, device_manager, **kwargs): device_manager, **{ k: kwargs.pop(k) - for k in ("capture", "acceleration", "panda", "max_scan_velocity") + for k in ("capture", "acceleration", "panda", "max_scan_velocity", "user_parameter") if k in kwargs }, ) @@ -681,3 +688,90 @@ def test_progress_bar_from_the_capture_without_mcs(v4_scan_assembler, device_man scan = _assemble(v4_scan_assembler, device_manager, trigger_source="panda") scan.prepare_scan() assert _progress_devices(scan) == ["aerocapture"] + + +CALIBRATION = [ + {"velocity": 10.0, "offset": 4.0}, + {"velocity": 2.0, "offset": 1.0}, + {"velocity": 20.0, "offset": -2.0}, +] + + +@pytest.mark.parametrize( + ("velocity", "expected"), + [ + (0.0, 0.0), + (1.0, 0.5), # below the lowest point: proportional to the velocity + (2.0, 1.0), + (6.0, 2.5), # linear between the points (table unsorted) + (15.0, 1.0), + (20.0, -2.0), + (20.0 * (1 + 1e-12), -2.0), # rounding of fast_step_size / exp_time + (25.0, None), # above the highest point: not calibrated + ], +) +def test_interpolate_trigger_offset(velocity, expected): + offset = interpolate_trigger_offset(CALIBRATION, velocity) + if expected is None: + assert offset is None + else: + assert offset == pytest.approx(expected) + + +def test_interpolate_trigger_offset_without_table(): + assert interpolate_trigger_offset([], 5.0) is None + + +def _trigger_positions(v4_scan_assembler, device_manager, **kwargs): + scan = _assemble(v4_scan_assembler, device_manager, **kwargs) + scan.prepare_scan() + return scan, scan._line["trigger_low"], scan._line["trigger_high"] + + +def test_trigger_calibration_shifts_both_directions(v4_scan_assembler, device_manager): + # 5 / s: offset 2.125 um between the points at 2 and 10 / s + _, low, high = _trigger_positions(v4_scan_assembler, device_manager, trigger_calibration=False) + scan, low_cal, high_cal = _trigger_positions( + v4_scan_assembler, + device_manager, + user_parameter={"trigger_calibration": CALIBRATION}, + ) + # later in the direction of motion: forward triggers at a higher, backward at a lower position + assert low_cal - low == pytest.approx(2.125e-3) + assert high - high_cal == pytest.approx(2.125e-3) + info = scan.scan_info.additional_scan_parameters + assert info["trigger_offset"] == pytest.approx(2.125) + assert info["scan_velocity"] == pytest.approx(5.0) + + +def test_trigger_calibration_off_or_not_calibrated(v4_scan_assembler, device_manager): + _, low, high = _trigger_positions(v4_scan_assembler, device_manager) # no table + scan, low_off, high_off = _trigger_positions( + v4_scan_assembler, + device_manager, + user_parameter={"trigger_calibration": CALIBRATION}, + trigger_calibration=False, + ) + assert (low_off, high_off) == pytest.approx((low, high)) + assert scan._line["trigger_offset"] is None + # 5 / s is above a table that ends at 2 / s + scan, low_high, _ = _trigger_positions( + v4_scan_assembler, + device_manager, + user_parameter={"trigger_calibration": CALIBRATION[1:2]}, + ) + assert low_high == pytest.approx(low) + assert scan._line["trigger_offset"] is None + + +def test_calibration_summary_reports_the_measured_entry(v4_scan_assembler, device_manager): + scan, _, _ = _trigger_positions( + v4_scan_assembler, device_manager, user_parameter={"trigger_calibration": CALIBRATION} + ) + # applied 2.125 um, still +1 um backward - forward: the entry is 2.625 um + summary = scan._calibration_summary(1.0) + assert "offset +2.12 um applied" in summary + assert "{velocity: 5, offset: 2.62}" in summary + scan.trigger_calibration = False + scan._line["trigger_offset"] = None + assert "off; this scan measured {velocity: 5, offset: 0.50}" in scan._calibration_summary(1.0) -- 2.54.0 From 56715b46b0ed29d608bafe505ec5bc99eb847653 Mon Sep 17 00:00:00 2001 From: x12sa Date: Tue, 3 Nov 2026 12:34:01 +0100 Subject: [PATCH 3/6] config(sastt): aerox trigger calibration from the 2026-11-03 training scans Half of backward - forward exposure centre offset per velocity, scans 4897-4917 (trigger_calibration off, DDG1, 500 mm/s^2, settle_time 0.15 s). Scan 4885 is left out (before the PSO double trigger fix). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017NrH3poJsYBCTVq2Y9fuHZ --- csaxs_bec/device_configs/sastt_aerotech.yaml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/csaxs_bec/device_configs/sastt_aerotech.yaml b/csaxs_bec/device_configs/sastt_aerotech.yaml index 861a99de..28da6af8 100644 --- a/csaxs_bec/device_configs/sastt_aerotech.yaml +++ b/csaxs_bec/device_configs/sastt_aerotech.yaml @@ -38,6 +38,21 @@ aerox: - -50 - 150 sign: 1 + userParameter: + # aerotech_cont_grid trigger calibration: offset [um] = half of the backward - forward + # exposure centre offset per scan velocity [mm/s], measured with trigger_calibration=False + # (DDG1 trigger, 0.15 s settle_time, 500 mm/s^2; exp_time 5 / 20 ms gave the same within + # the scatter). Linear in between, proportional below 1 mm/s, none above 40 mm/s. + # The statistics of each scan print the entry it measured. + trigger_calibration: + - {velocity: 1.0, offset: 0.43, scans: [4901, 4906]} + - {velocity: 2.0, offset: 1.03, scans: [4902, 4908]} + - {velocity: 4.0, offset: 4.64, scans: [4903]} + - {velocity: 8.0, offset: 7.87, scans: [4897]} + - {velocity: 10.0, offset: 9.29, scans: [4898]} + - {velocity: 20.0, offset: 12.85, scans: [4899, 4907]} + - {velocity: 30.0, offset: 1.50, scans: [4916]} + - {velocity: 40.0, offset: -4.39, scans: [4900, 4917]} enabled: true onFailure: buffer readOnly: false -- 2.54.0 From ca3446eca02e3e8aaaf785de4e72cd105ff56e5f Mon Sep 17 00:00:00 2001 From: x12sa Date: Tue, 3 Nov 2026 12:43:31 +0100 Subject: [PATCH 4/6] config(sastt): aerox trigger calibration from calibrated scans at 8-30 mm/s The trigger shift moves backward - forward by ~1.15 x 2 * offset, so the entries measured with the calibration applied (4921, 4918, 4919, 4920) are used for 8 / 10 / 20 / 30 mm/s. 20 and 30 mm/s are good to about +-5 um only: uncorrected backward - forward at 20 mm/s varied from +18 to +28 um. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017NrH3poJsYBCTVq2Y9fuHZ --- csaxs_bec/device_configs/sastt_aerotech.yaml | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/csaxs_bec/device_configs/sastt_aerotech.yaml b/csaxs_bec/device_configs/sastt_aerotech.yaml index 28da6af8..c30b7340 100644 --- a/csaxs_bec/device_configs/sastt_aerotech.yaml +++ b/csaxs_bec/device_configs/sastt_aerotech.yaml @@ -40,18 +40,21 @@ aerox: sign: 1 userParameter: # aerotech_cont_grid trigger calibration: offset [um] = half of the backward - forward - # exposure centre offset per scan velocity [mm/s], measured with trigger_calibration=False - # (DDG1 trigger, 0.15 s settle_time, 500 mm/s^2; exp_time 5 / 20 ms gave the same within - # the scatter). Linear in between, proportional below 1 mm/s, none above 40 mm/s. - # The statistics of each scan print the entry it measured. + # exposure centre offset per scan velocity [mm/s] (DDG1 trigger, 0.15 s settle_time, + # 500 mm/s^2; exp_time 5 / 20 ms gave the same within the scatter). Linear in between, + # proportional below 1 mm/s, none above 40 mm/s. The statistics of each scan print the + # entry it measured; take it from a scan with the calibration applied: the trigger shift + # moves backward - forward by ~1.15 x 2 * offset, the measured entry includes that. + # 20 and 30 mm/s: uncorrected backward - forward not reproducible (20 mm/s: +28.5 / +23.0 + # / +18.3 um in 4899 / 4907 / 4923), good to about +-5 um. trigger_calibration: - {velocity: 1.0, offset: 0.43, scans: [4901, 4906]} - {velocity: 2.0, offset: 1.03, scans: [4902, 4908]} - {velocity: 4.0, offset: 4.64, scans: [4903]} - - {velocity: 8.0, offset: 7.87, scans: [4897]} - - {velocity: 10.0, offset: 9.29, scans: [4898]} - - {velocity: 20.0, offset: 12.85, scans: [4899, 4907]} - - {velocity: 30.0, offset: 1.50, scans: [4916]} + - {velocity: 8.0, offset: 6.52, scans: [4897, 4921]} + - {velocity: 10.0, offset: 7.87, scans: [4898, 4918, 4922]} + - {velocity: 20.0, offset: 9.06, scans: [4899, 4907, 4919, 4923]} + - {velocity: 30.0, offset: -2.91, scans: [4916, 4920]} - {velocity: 40.0, offset: -4.39, scans: [4900, 4917]} enabled: true onFailure: buffer -- 2.54.0 From 27a67b5a1f0655a79e22f9335198640e9fe13e8b Mon Sep 17 00:00:00 2001 From: x12sa Date: Tue, 3 Nov 2026 12:55:44 +0100 Subject: [PATCH 5/6] feat(aerotech): settle_time 0.3 s default, trigger calibration per settle time At the beamline 0.15 s settle left a forward / backward offset of +18 .. +28 um at 20 mm/s that changed between repeats (oscillation after the ramp); with 0.3 s it is -2 .. -5 um and reproducible, at ~0.22 s more per line. settle_time and minimal_run_up stay options. Calibration entries now carry settle_time (and optionally minimal_run_up); a scan only uses the entries of its own settle time and run-up mode, so a table measured with one settling is never applied to another. The printed entry includes settle_time. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017NrH3poJsYBCTVq2Y9fuHZ --- csaxs_bec/devices/aerotech/README.md | 8 ++- .../scans/scans_v4/aerotech_cont_grid.py | 70 ++++++++++++------- docs/developer/aerotech_stages.md | 54 ++++++++------ .../saxs/aerotech_stages/aerotech_stages.md | 66 ++++++++++------- .../test_aerotech_cont_grid_scan.py | 49 ++++++++++--- 5 files changed, 163 insertions(+), 84 deletions(-) diff --git a/csaxs_bec/devices/aerotech/README.md b/csaxs_bec/devices/aerotech/README.md index ac20b489..0862454d 100644 --- a/csaxs_bec/devices/aerotech/README.md +++ b/csaxs_bec/devices/aerotech/README.md @@ -59,7 +59,7 @@ Documentation: A3200 6.04.004 help (`A3200.chm`, topic "ASCII Command Interface" ```python scans.aerotech_cont_grid(dev.aerox, 0, 1, 0.01, dev.aeroy, 0, 1, 0.1, exp_time=0.01, - trigger_source="ddg", settle_time=0.15) + trigger_source="ddg", settle_time=0.3) ``` - fast axis X moves continuously at `fast_step_size / exp_time`, the step axis Y steps between @@ -95,8 +95,10 @@ scans.aerotech_cont_grid(dev.aerox, 0, 1, 0.01, dev.aeroy, 0, 1, 0.1, exp_time=0 output (not tested). The PandA has the same choice as device config `trigger_edge` (default `"rising"`, opto wiring). - run-up / run-out = twice the ramp distance `v^2 / (2 * acceleration)` + `v * settle_time` - (default 0.15 s, measured 2026-10-28: the following error needs ~100-200 ms after the - ramp at 1 mm/s / 10 mm/s^2). In a snake the run-out of a line is the run-up of the next. + (default 0.3 s since 2026-11-03, was 0.15 s; measured 2026-10-28: the following error + needs ~100-200 ms after the ramp at 1 mm/s / 10 mm/s^2; at 20 mm/s 0.15 s left a + forward / backward offset that changed between repeats, see the trigger calibration in + `docs/developer/aerotech_stages.md`). In a snake the run-out of a line is the run-up of the next. - at the turnarounds X does not wait for In Position (`wait_in_position` signal off during the scan); X reaches 1 um within ~60 ms but 10 nm only after 0.5-2 s - positions are always captured: device `aerocapture` (must be enabled) latches the diff --git a/csaxs_bec/scans/scans_v4/aerotech_cont_grid.py b/csaxs_bec/scans/scans_v4/aerotech_cont_grid.py index 6d61d26a..d7c3ffe6 100644 --- a/csaxs_bec/scans/scans_v4/aerotech_cont_grid.py +++ b/csaxs_bec/scans/scans_v4/aerotech_cont_grid.py @@ -60,17 +60,22 @@ centres of the fast axis (deviation from the nominal cell centres per direction, backward - forward offset and its slope along the line) and of the line times. Trigger calibration: in a snake the backward exposure centres are offset from the forward -ones, by an amount that depends on the scan velocity (DDG1 at the beamline, 2026-11-03: -backward - forward +2 um at 2 mm/s, +19 um at 10 mm/s, +26 um at 20 mm/s, -9 um at -40 mm/s; independent of exp_time within the scatter). With trigger_calibration=True the -scan shifts the trigger positions by the calibrated offset h (half of backward - forward): +ones, by an amount that depends on the scan velocity and the settling (DDG1 at the +beamline, 2026-11-03, backward - forward, independent of exp_time within the scatter): +with settle_time 0.15 s +2 / +19 / +26 / -9 um at 2 / 10 / 20 / 40 mm/s, at 20 mm/s +varying by 10 um between repeats (the oscillation after the ramp has not decayed); with +0.3 s -3 / -8 / -3 / -1 um, at 20 mm/s within 3 um. With trigger_calibration=True the scan +shifts the trigger positions by the calibrated offset h (half of backward - forward): trigger_low by +h, trigger_high by -h, so that the centres of both directions are on the same cells. h comes from the table userParameter "trigger_calibration" of the fast axis -(device config, a list of {velocity, offset [um]}): linear between the calibrated -velocities, proportional to the velocity below the lowest one, no correction above the -highest one. The statistics at the end show the applied offset and the table entry this -scan measured (applied + half of the remaining backward - forward), for new or better -calibration points; the applied offset is stored in the scan info (trigger_offset). +(device config, a list of {velocity, settle_time, offset [um]}, optionally +minimal_run_up: true): only entries of the scan's settle_time and run-up mode, linear +between their velocities, proportional to the velocity below the lowest one, no +correction above the highest one or without entries. The statistics at the end show the +applied offset and the table entry this scan measured (applied + half of the remaining +backward - forward); the trigger shift moves backward - forward by about 1.15 x 2h, so +take new entries from scans with the calibration applied. The applied offset is stored in +the scan info (trigger_offset). Run-up / run-out of each line: twice the ramp distance v^2 / (2 * acceleration) + v * settle_time beyond the trigger positions. The whole range including run-up / run-out is @@ -85,9 +90,11 @@ and the step axis starts; the next line starts when both have arrived. minimal_run_up=True reduces the run-up / run-out to the ramp distance alone (no margin, no settle_time; at least MIN_RUN_UP = 0.01 so the axis never turns at a PSO window edge), for data analysis that works with unevenly spaced (captured) positions. -settle_time stays 0.15 s also at high speed: at 20 mm/s / 500 mm/s^2 shorter values -(0.08 / 0.04 / 0.02 / 0 s) gave backward - forward offsets of -24 / +10 / +4.5 / -21 um, -a ~10 Hz oscillation after the ramp that decays within ~0.15 s (measured 2026-10-29). +settle_time is 0.3 s by default: at 20 mm/s / 500 mm/s^2 shorter values (0.08 / 0.04 / +0.02 / 0 s) gave backward - forward offsets of -24 / +10 / +4.5 / -21 um, a ~10 Hz +oscillation after the ramp (measured 2026-10-29); at the beamline (2026-11-03) 0.15 s +still left +18 .. +28 um at 20 mm/s, different in every repeat, 0.3 s -2 .. -5 um. 0.3 s +costs ~0.22-0.28 s per line compared with 0.15 s. Scan procedure: - prepare_scan @@ -137,14 +144,23 @@ DDG_EXT_FALLING_EDGE = 2 DDG_SINGLE_SHOT = 5 -def interpolate_trigger_offset(table: list[dict], velocity: float) -> float | None: +def interpolate_trigger_offset( + table: list[dict], velocity: float, settle_time: float, minimal_run_up: bool = False +) -> float | None: """ Trigger offset h [um] for a scan velocity from a calibration table of - {"velocity": ..., "offset": ...} entries: linear between the calibrated velocities, - proportional to the velocity below the lowest one (no offset at rest), None above the - highest one or without a table. + {"velocity": ..., "settle_time": ..., "offset": ...} entries (optionally + "minimal_run_up": True). Only the entries of the given settle_time and run-up mode are + used: linear between their velocities, proportional to the velocity below the lowest + one (no offset at rest), None above the highest one or without entries. """ - points = sorted((float(e["velocity"]), float(e["offset"])) for e in table) + points = sorted( + (float(e["velocity"]), float(e["offset"])) + for e in table + if bool(e.get("minimal_run_up", False)) == bool(minimal_run_up) + and e.get("settle_time") is not None + and np.isclose(float(e["settle_time"]), settle_time) + ) if not points: return None velocities, offsets = zip(*points) @@ -233,7 +249,7 @@ class AerotechContGrid(ScanBase): trigger_edge: str = "rising", trigger_device: str | None = None, shutter_delay: float | None = None, - settle_time: float = 0.15, + settle_time: float = 0.3, minimal_run_up: bool = False, capture_device: str = "aerocapture", trigger_calibration: bool = True, @@ -259,10 +275,10 @@ class AerotechContGrid(ScanBase): trigger_edge (str): DDG1 external trigger edge, "rising" (opto output, J7-6/7) or "falling" (open collector / differential output). Default is "rising". trigger_device (str | None): Name of the PandaLineTrigger device for trigger_source="panda"; ignored otherwise. Default is None ("pandatrig"). shutter_delay (float | None): Delay from the line trigger to the first exposure (time for the shutter to open) in s. Default: the value of the trigger hardware (DDG1 / PandA shutter-to-open delay, 8 ms), 0 without trigger hardware. With "ddg" a given value is set on DDG1 for this scan and restored afterwards. - settle_time (float): Run-up at constant velocity before the trigger position, in s. Default is 0.15. + settle_time (float): Run-up at constant velocity before the trigger position, in s; the trigger calibration is per settle_time. Default is 0.3. minimal_run_up (bool): Run-up / run-out only the ramp distance v^2 / (2 * acceleration), at least 0.01, without the 2x margin and settle_time: faster lines, but the exposures start right after the ramp while the fast axis may still oscillate, so the captured positions are less evenly spaced. Default is False. capture_device (str): Name of the A3200PositionCapture device (always used). Default is "aerocapture". - trigger_calibration (bool): Shift the trigger positions by the calibrated offset of the fast axis for the scan velocity, so that forward and backward exposure centres are on the same cells (userParameter "trigger_calibration"). False for training scans of new calibration points. Default is True. + trigger_calibration (bool): Shift the trigger positions by the calibrated offset of the fast axis for the scan velocity, settle_time and run-up mode, so that forward and backward exposure centres are on the same cells (userParameter "trigger_calibration"). False for training scans of new calibration points. Default is True. Returns: ScanReport @@ -665,11 +681,15 @@ class AerotechContGrid(ScanBase): table = ( parameters.get(TRIGGER_CALIBRATION_PARAMETER) if isinstance(parameters, dict) else None ) - offset = interpolate_trigger_offset(table or [], velocity) + offset = interpolate_trigger_offset( + table or [], velocity, self.settle_time, self.minimal_run_up + ) if offset is None: logger.warning( f"aerotech_cont_grid: no trigger calibration of {self.fast_axis.name} for" - f" {velocity:g} / s, forward and backward exposures are not aligned." + f" {velocity:g} / s, settle_time {self.settle_time:g} s" + f"{', minimal run-up' if self.minimal_run_up else ''}: forward and backward" + " exposures are not aligned." ) return offset @@ -681,12 +701,14 @@ class AerotechContGrid(ScanBase): if applied is not None: state = f"offset {applied:+.2f} um applied" elif self.trigger_calibration: - state = "no calibration for this velocity" + state = "no calibration for this velocity / settle_time" else: state = "off" + mode = ", minimal_run_up: true" if self.minimal_run_up else "" return ( f" trigger calibration [um]: {state}; this scan measured" - f" {{velocity: {line['target_velocity']:g}, offset: {measured:.2f}}}" + f" {{velocity: {line['target_velocity']:g}, settle_time: {self.settle_time:g}," + f" offset: {measured:.2f}{mode}}}" ) def _trigger_window(self, line_index: int) -> tuple[float, float]: diff --git a/docs/developer/aerotech_stages.md b/docs/developer/aerotech_stages.md index c1d12019..9ed09dd9 100644 --- a/docs/developer/aerotech_stages.md +++ b/docs/developer/aerotech_stages.md @@ -99,10 +99,12 @@ trigger_high = exposure_end + v * shutter_delay - v * readout_time / 2 (backwa ### Run-up, settling and limits Run-up / run-out per side: `2 * v^2 / (2 * acceleration) + v * settle_time`. In a -snake the run-out of a line is the run-up of the next. `settle_time` stays 0.15 s: at -20 mm/s and 500 mm/s^2 there is a ~10 Hz oscillation after the ramp that decays within -~0.15 s; shorter values (0.08 / 0.04 / 0.02 / 0 s) gave backward minus forward offsets -of -24 / +10 / +4.5 / -21 um (2026-10-29). +snake the run-out of a line is the run-up of the next. `settle_time` is 0.3 s by +default (was 0.15 s until 2026-11-03): at 20 mm/s and 500 mm/s^2 there is a ~10 Hz +oscillation after the ramp; shorter values (0.08 / 0.04 / 0.02 / 0 s) gave backward +minus forward offsets of -24 / +10 / +4.5 / -21 um (2026-10-29), and at the beamline +0.15 s left +18 .. +28 um at 20 mm/s, different in every repeat, 0.3 s -2 .. -5 um +(2026-11-03). 0.3 s costs ~0.22-0.28 s per line. `prepare_scan` checks the whole travel range including run-up, and the Y range, against the soft limits before anything moves. The message separates the scan range @@ -110,26 +112,38 @@ from the run-up so a user can see why a scan inside the limits does not fit. ### Trigger calibration -The backward exposure centres are offset from the forward ones by a velocity -dependent amount (DDG1 at the beamline, 2026-11-03, scans 4897-4917: backward minus -forward +0.9 / +2.1 / +9.3 / +18.6 / +25.7 / +3.0 / -8.8 um at 1 / 2 / 4 / 10 / 20 / 30 / -40 mm/s; 5 vs 20 ms exposure time the same within the scatter; repeats within 0.1 um -at 1 mm/s and 0.2 um at 40 mm/s). It is not a constant delay: about 0.5-1 ms up to -20 mm/s, then it falls and changes sign. With the PandA (2026-10-29) it was below 1 um at -20 mm/s. +The backward exposure centres are offset from the forward ones by an amount that +depends on the velocity and the settling (DDG1 at the beamline, 2026-11-03, backward +minus forward; 5 vs 20 ms exposure time the same within the scatter): + +| v [mm/s] | 1 | 2 | 4 | 10 | 20 | 30 | 40 | +|---|---|---|---|---|---|---|---| +| settle 0.15 s | +0.9 | +2.1 | +9.3 | +18.6 | +18 .. +28 | +3.0 | -8.8 | +| settle 0.3 s | | -3.2 | -5.8 | -8.2 | -4.6 / -1.7 | -1.1 / -1.6 | -0.7 | + +With 0.15 s it repeats within 0.5 um up to 10 mm/s and at 40 mm/s, but not at 20 mm/s: +the oscillation after the ramp has not decayed. It is not a constant delay (about +0.5-1 ms up to 20 mm/s at 0.15 s, then it changes sign). With the PandA (2026-10-29) it +was below 1 um at 20 mm/s. `trigger_calibration=True` (default) shifts `trigger_low` by `+h` and `trigger_high` by `-h` (later in the direction of motion), with `h` = half of backward minus forward from the table `userParameter.trigger_calibration` of the fast axis -(`interpolate_trigger_offset`: linear between the points, proportional to the velocity -below the lowest one, `None` above the highest one). The device config is read from -`_config` of the fast axis (the scan server's devices are `DeviceBaseWithConfig`, -whose `user_parameter` reads the same). The applied `h` is in the scan info -(`trigger_offset`, with `scan_velocity`); the statistics print the entry the scan -measured, `h_applied + (backward - forward) / 2`. Only the mean offset per direction is -corrected; the variation along the line at high velocity (oscillation after the ramp) -remains. The calibration is for the configured acceleration (500 mm/s^2) and -`settle_time` 0.15 s. +(`interpolate_trigger_offset`): only the entries of the scan's `settle_time` (and +`minimal_run_up: true` entries only with `minimal_run_up`), linear between their +velocities, proportional to the velocity below the lowest one, `None` above the +highest one or without entries. The device config is read from `_config` of the fast +axis (the scan server's devices are `DeviceBaseWithConfig`, whose `user_parameter` +reads the same). The applied `h` is in the scan info (`trigger_offset`, with +`scan_velocity`); the statistics print the entry the scan measured, +`h_applied + (backward - forward) / 2`. + +The shift acts slightly more than 1:1: backward minus forward changed by about +1.15 x 2h at 8 and 10 mm/s. New entries are therefore taken from scans with the +calibration applied, or as the zero crossing of two calibrated scans. Only the mean +offset per direction is corrected; the variation along the line (rms 3-6 um per +direction from 10 mm/s on, peak-to-peak 13-16 um) does not change with the settle time +or the trigger shift. The calibration is for the configured acceleration (500 mm/s^2). ### Step during the run-out diff --git a/docs/user/saxs/aerotech_stages/aerotech_stages.md b/docs/user/saxs/aerotech_stages/aerotech_stages.md index f5d22ede..6057732f 100644 --- a/docs/user/saxs/aerotech_stages/aerotech_stages.md +++ b/docs/user/saxs/aerotech_stages/aerotech_stages.md @@ -71,7 +71,7 @@ scans.aerotech_cont_grid( | `exp_time` | exposure time per cell in s; the X velocity is `fast_step_size / exp_time` | | `relative` | `True`: positions are offsets from the current X / Y position, and both axes return there after the scan | | `trigger_source` | `"ddg"` (default, beamline timing) | -| `settle_time` | constant velocity time before the first exposure of a line, default 0.15 s (leave it) | +| `settle_time` | constant velocity time before the first exposure of a line, default 0.3 s; 0.15 s is faster but less precise at high velocity (see below) | | `shutter_delay` | time from the line trigger to the first exposure, for the shutter to open. Default: the value of DDG1 (8 ms); a given value is set on DDG1 for this scan only | | `minimal_run_up` | `True`: run-up only the acceleration distance (at least 10 um), no settling. Faster lines, but the exposures are less evenly spaced; use the captured positions. Default `False` | | `trigger_calibration` | `True` (default): forward and backward lines are aligned with the calibration of `aerox` (see below). `False` only for training scans of new calibration points | @@ -101,42 +101,54 @@ colours alternate in Y by one line spacing. ### Forward and backward alignment (trigger calibration) Without correction the backward lines are offset from the forward lines along X, by -an amount that depends on the scan velocity (measured 2026-11-03 at the beamline, -backward minus forward): +an amount that depends on the scan velocity and on the settle time (measured +2026-11-03 at the beamline, backward minus forward in um): -| Velocity [mm/s] | 0.5 | 1 | 2 | 4 | 8 | 10 | 20 | 30 | 40 | -|---|---|---|---|---|---|---|---|---|---| -| Offset [um] | -0.6 | +0.9 | +2.1 | +9.3 | +15.8 | +18.6 | +25.7 | +3.0 | -8.8 | +| Velocity [mm/s] | 2 | 4 | 10 | 20 | 30 | 40 | +|---|---|---|---|---|---|---| +| `settle_time=0.3` (default) | -3.2 | -5.8 | -8.2 | -4.6 / -1.7 | -1.1 / -1.6 | -0.7 | +| `settle_time=0.15` | +2.1 | +9.3 | +18.6 | +18 .. +28 | +3.0 | -8.8 | -It does not depend on the exposure time and is reproducible. The scan therefore moves -the trigger positions of both directions by half of it, so that forward and backward -exposures land on the same cells. The calibration is a table in the device config of -`aerox` (`userParameter` `trigger_calibration`, velocity and offset), interpolated -between the calibrated velocities. Above 40 mm/s there is no calibration: the scan -runs uncorrected and says so in its summary. +It does not depend on the exposure time. With 0.15 s the offset is larger and, from +20 mm/s on, different in every repeat: X still oscillates after the acceleration when +the exposures start. With 0.3 s it is small and reproducible; this costs about 0.22 s +more per line (about 27 % on fast scans, negligible on slow high-resolution scans). + +The scan moves the trigger positions of both directions by half of the offset, so that +forward and backward exposures land on the same cells. The calibration is a table in +the device config of `aerox` (`userParameter` `trigger_calibration`: velocity, settle +time, offset), with entries for 0.3 s and 0.15 s. A scan uses the entries of its own +`settle_time`, interpolated between the calibrated velocities. Without entries for its +settle time, with `minimal_run_up=True`, or above 40 mm/s the scan runs uncorrected and +says so in its summary. The summary of each scan shows the applied offset and the table entry the scan measured, for example: ```text -trigger calibration [um]: offset +9.29 um applied; this scan measured {velocity: 10, offset: 9.41} +trigger calibration [um]: offset -4.09 um applied; this scan measured {velocity: 10, settle_time: 0.3, offset: -3.80} ``` A calibrated scan should show a backward - forward mean of a few um at most. What -remains along a line at high velocity (rms per direction 4-7 um from 10 mm/s on) is -the motion of X and cannot be corrected by the trigger. +remains along a line at high velocity (rms per direction 3-6 um from 10 mm/s on) is +the motion of X and cannot be corrected by the trigger or the settle time. -**New calibration point** (other acceleration, settle time, or a velocity outside the -table): run a short scan with `trigger_calibration=False` at that velocity, for example -X +-1 mm, 6 lines: +**Other settings:** `settle_time` and `minimal_run_up` stay available as options. For a +faster scan use `settle_time=0.15` (calibrated, but at 20-30 mm/s only to about ++-5 um). For any other settle time or `minimal_run_up=True` there is no calibration: +the lines are offset as in the table above. + +**New calibration point** (other settle time, acceleration, or velocity): run a short +scan at that velocity and settle time, for example X +-1 mm, 6 lines: ```python scans.aerotech_cont_grid(dev.aerox, -1.0, 1.0, 0.1, dev.aeroy, -0.1, 0.1, 0.04, exp_time=0.005, relative=True, trigger_calibration=False) # 20 mm/s ``` -and add the printed `{velocity: ..., offset: ...}` to `trigger_calibration` of `aerox` -in `sastt_aerotech.yaml` (ask the beamline staff to commit it). For a quick test without -a config change: `dev.aerox.update_user_parameter({...})`. +and add the printed `{velocity: ..., settle_time: ..., offset: ...}` to +`trigger_calibration` of `aerox` in `sastt_aerotech.yaml` (ask the beamline staff to +commit it). Then repeat the scan with the calibration on and take the entry it prints: +the trigger shift acts slightly more than 1:1, so the second value is the better one. ### Examples @@ -164,12 +176,12 @@ scans.aerotech_cont_grid(dev.aerox, -1.75, 1.75, 0.01, dev.aeroy, -1.5, 1.5, 0.0 ### Run-up and soft limits To pass through the field of view at constant velocity, X travels beyond it on both -sides: twice the acceleration distance `v^2 / (2 * 500 mm/s^2)` plus `v * 0.15 s` to -settle. The run-up per side is +sides: twice the acceleration distance `v^2 / (2 * 500 mm/s^2)` plus `v * settle_time` +(0.3 s) to settle. The run-up per side is -| Scan velocity | 0.5 mm/s | 2 mm/s | 20 mm/s | -|---------------|----------|--------|---------| -| Run-up per side | 0.08 mm | 0.31 mm | 3.8 mm | +| Scan velocity | 0.5 mm/s | 2 mm/s | 20 mm/s | 40 mm/s | +|---------------|----------|--------|---------|---------| +| Run-up per side | 0.15 mm | 0.61 mm | 6.8 mm | 15.2 mm | Before anything moves, the scan checks the whole range including the run-up against the soft limits. If it does not fit, the scan stops with a message that says by how @@ -177,7 +189,7 @@ much and on which side, for example ```text The scan range of aerox 146.5000 .. 150.0000 is within the limits, but aerox needs -3.8400 extra travel on each side to accelerate to the scan velocity 20 / s and settle +6.9580 extra travel on each side to accelerate to the scan velocity 20 / s and settle ... ``` diff --git a/tests/tests_scans/test_aerotech_cont_grid_scan.py b/tests/tests_scans/test_aerotech_cont_grid_scan.py index b8bcae7d..6fcc475a 100644 --- a/tests/tests_scans/test_aerotech_cont_grid_scan.py +++ b/tests/tests_scans/test_aerotech_cont_grid_scan.py @@ -206,10 +206,21 @@ def test_velocity_above_configured_velocity_aborts(v4_scan_assembler, device_man def test_scan_faster_than_move_velocity_restores_move_velocity(v4_scan_assembler, device_manager): - # 20 mm/s scan, move velocity 10 mm/s, max_scan_velocity 50 mm/s + # 20 mm/s scan, move velocity 10 mm/s, max_scan_velocity 50 mm/s; settle_time 0.15 s + # so that the run-up fits the +-10 limits at 100 mm/s^2 _add_devices(device_manager, max_scan_velocity=50.0) scan = v4_scan_assembler( - "aerotech_cont_grid", "aerox", -1.0, 1.0, 0.1, "aeroy", -2.0, 2.0, 2, exp_time=0.005 + "aerotech_cont_grid", + "aerox", + -1.0, + 1.0, + 0.1, + "aeroy", + -2.0, + 2.0, + 2, + exp_time=0.005, + settle_time=0.15, ) scan.prepare_scan() assert scan._line["target_velocity"] == pytest.approx(20.0) @@ -222,7 +233,7 @@ def test_scan_faster_than_move_velocity_restores_move_velocity(v4_scan_assembler def test_run_up_outside_limits_aborts(v4_scan_assembler, device_manager): - # 20 mm/s at 100 mm/s^2: 2 * 2 mm ramp + 3 mm settle run-up, beyond the +-10 limits + # 20 mm/s at 100 mm/s^2: 2 * 2 mm ramp + 6 mm settle run-up, beyond the +-10 limits _add_devices(device_manager, max_scan_velocity=50.0) scan = v4_scan_assembler( "aerotech_cont_grid", "aerox", -4.0, 4.0, 0.1, "aeroy", -2.0, 2.0, 2, exp_time=0.005 @@ -691,9 +702,12 @@ def test_progress_bar_from_the_capture_without_mcs(v4_scan_assembler, device_man CALIBRATION = [ - {"velocity": 10.0, "offset": 4.0}, - {"velocity": 2.0, "offset": 1.0}, - {"velocity": 20.0, "offset": -2.0}, + {"velocity": 10.0, "settle_time": 0.3, "offset": 4.0}, + {"velocity": 2.0, "settle_time": 0.3, "offset": 1.0}, + {"velocity": 20.0, "settle_time": 0.3, "offset": -2.0}, + # other settling and run-up mode: never mixed into the 0.3 s entries + {"velocity": 5.0, "settle_time": 0.15, "offset": 50.0}, + {"velocity": 5.0, "settle_time": 0.3, "minimal_run_up": True, "offset": 60.0}, ] @@ -711,7 +725,7 @@ CALIBRATION = [ ], ) def test_interpolate_trigger_offset(velocity, expected): - offset = interpolate_trigger_offset(CALIBRATION, velocity) + offset = interpolate_trigger_offset(CALIBRATION, velocity, 0.3) if expected is None: assert offset is None else: @@ -719,7 +733,20 @@ def test_interpolate_trigger_offset(velocity, expected): def test_interpolate_trigger_offset_without_table(): - assert interpolate_trigger_offset([], 5.0) is None + assert interpolate_trigger_offset([], 5.0, 0.3) is None + + +def test_interpolate_trigger_offset_per_settle_time_and_run_up_mode(): + assert interpolate_trigger_offset(CALIBRATION, 5.0, 0.15) == pytest.approx(50.0) + assert interpolate_trigger_offset(CALIBRATION, 5.0, 0.3, minimal_run_up=True) == 60.0 + assert interpolate_trigger_offset(CALIBRATION, 5.0, 0.2) is None + # entries without settle_time are not used + assert interpolate_trigger_offset([{"velocity": 5.0, "offset": 1.0}], 5.0, 0.3) is None + + +def test_default_settle_time_is_0p3(v4_scan_assembler, device_manager): + scan = _assemble(v4_scan_assembler, device_manager) + assert scan.settle_time == 0.3 def _trigger_positions(v4_scan_assembler, device_manager, **kwargs): @@ -771,7 +798,9 @@ def test_calibration_summary_reports_the_measured_entry(v4_scan_assembler, devic # applied 2.125 um, still +1 um backward - forward: the entry is 2.625 um summary = scan._calibration_summary(1.0) assert "offset +2.12 um applied" in summary - assert "{velocity: 5, offset: 2.62}" in summary + assert "{velocity: 5, settle_time: 0.3, offset: 2.62}" in summary scan.trigger_calibration = False scan._line["trigger_offset"] = None - assert "off; this scan measured {velocity: 5, offset: 0.50}" in scan._calibration_summary(1.0) + assert "off; this scan measured {velocity: 5, settle_time: 0.3, offset: 0.50}" in ( + scan._calibration_summary(1.0) + ) -- 2.54.0 From b189e2e4c7b67cb62260f57326deb537f46b733f Mon Sep 17 00:00:00 2001 From: x12sa Date: Tue, 3 Nov 2026 12:55:44 +0100 Subject: [PATCH 6/6] config(sastt): aerox trigger calibration for settle_time 0.3 and 0.15 s 0.3 s (new default) from uncorrected scans at 2-40 mm/s, to be refined with calibrated scans; 0.15 s entries kept, 8 / 10 mm/s as the zero crossing of two calibrated scans each. No scan numbers in the table. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017NrH3poJsYBCTVq2Y9fuHZ --- csaxs_bec/device_configs/sastt_aerotech.yaml | 40 ++++++++++++-------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/csaxs_bec/device_configs/sastt_aerotech.yaml b/csaxs_bec/device_configs/sastt_aerotech.yaml index c30b7340..ab579ee0 100644 --- a/csaxs_bec/device_configs/sastt_aerotech.yaml +++ b/csaxs_bec/device_configs/sastt_aerotech.yaml @@ -40,22 +40,32 @@ aerox: sign: 1 userParameter: # aerotech_cont_grid trigger calibration: offset [um] = half of the backward - forward - # exposure centre offset per scan velocity [mm/s] (DDG1 trigger, 0.15 s settle_time, - # 500 mm/s^2; exp_time 5 / 20 ms gave the same within the scatter). Linear in between, - # proportional below 1 mm/s, none above 40 mm/s. The statistics of each scan print the - # entry it measured; take it from a scan with the calibration applied: the trigger shift - # moves backward - forward by ~1.15 x 2 * offset, the measured entry includes that. - # 20 and 30 mm/s: uncorrected backward - forward not reproducible (20 mm/s: +28.5 / +23.0 - # / +18.3 um in 4899 / 4907 / 4923), good to about +-5 um. + # exposure centre offset, per scan velocity [mm/s] and settle_time [s] (DDG1 trigger, + # 500 mm/s^2; exp_time 5 / 20 ms gave the same within the scatter, 2026-11-03). A scan uses + # the entries of its settle_time (and minimal_run_up: true entries only with + # minimal_run_up): linear in between, proportional below the lowest velocity, none above + # the highest. The statistics of each scan print the entry it measured. The trigger + # shift moves backward - forward by ~1.15 x 2 * offset: take entries from scans with the + # calibration applied, or the value where two such scans cross zero. trigger_calibration: - - {velocity: 1.0, offset: 0.43, scans: [4901, 4906]} - - {velocity: 2.0, offset: 1.03, scans: [4902, 4908]} - - {velocity: 4.0, offset: 4.64, scans: [4903]} - - {velocity: 8.0, offset: 6.52, scans: [4897, 4921]} - - {velocity: 10.0, offset: 7.87, scans: [4898, 4918, 4922]} - - {velocity: 20.0, offset: 9.06, scans: [4899, 4907, 4919, 4923]} - - {velocity: 30.0, offset: -2.91, scans: [4916, 4920]} - - {velocity: 40.0, offset: -4.39, scans: [4900, 4917]} + # settle_time 0.3 s (default): from uncorrected scans, to be refined with calibrated ones + - {velocity: 2.0, settle_time: 0.3, offset: -1.61} + - {velocity: 4.0, settle_time: 0.3, offset: -2.89} + - {velocity: 10.0, settle_time: 0.3, offset: -4.09} + - {velocity: 20.0, settle_time: 0.3, offset: -1.58} + - {velocity: 30.0, settle_time: 0.3, offset: -0.67} + - {velocity: 40.0, settle_time: 0.3, offset: -0.36} + # settle_time 0.15 s: 8 / 10 mm/s zero crossing of two calibrated scans each; 20 and + # 30 mm/s good to about +-5 um only (uncorrected backward - forward at 20 mm/s varied + # from +18 to +28 um between repeats) + - {velocity: 1.0, settle_time: 0.15, offset: 0.43} + - {velocity: 2.0, settle_time: 0.15, offset: 1.03} + - {velocity: 4.0, settle_time: 0.15, offset: 4.64} + - {velocity: 8.0, settle_time: 0.15, offset: 7.02} + - {velocity: 10.0, settle_time: 0.15, offset: 8.45} + - {velocity: 20.0, settle_time: 0.15, offset: 9.06} + - {velocity: 30.0, settle_time: 0.15, offset: -2.91} + - {velocity: 40.0, settle_time: 0.15, offset: -4.39} enabled: true onFailure: buffer readOnly: false -- 2.54.0