diff --git a/csaxs_bec/scans/scans_v4/aerotech_cont_grid.py b/csaxs_bec/scans/scans_v4/aerotech_cont_grid.py index 075daf4b..71626551 100644 --- a/csaxs_bec/scans/scans_v4/aerotech_cont_grid.py +++ b/csaxs_bec/scans/scans_v4/aerotech_cont_grid.py @@ -63,6 +63,9 @@ In Position. The step axis moves during the run-out and turnaround of the fast a soon as the fast axis has left the exposure range, the PSO window of the next line is set 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). @@ -110,6 +113,12 @@ DDG_TRIGGER_EDGES = {"rising": DDG_EXT_RISING_EDGE, "falling": DDG_EXT_FALLING_E # servo following error at the end of the ramp) RAMP_MARGIN_FRACTION = 1.0 +# Smallest run-up / run-out with minimal_run_up, in units of the fast axis: at low speed the +# ramp distance is sub-um (0.5 mm/s, 500 mm/s^2: 0.25 um), and the fast axis would stop and +# reverse right at the edge of the next line's PSO window, where ringing or overshoot could +# start the line before the axis moves. +MIN_RUN_UP = 0.01 + TRIGGER_SOURCES = ("ddg", "panda", None) # Poll interval of the fast axis position while waiting for the end of the exposures, s @@ -153,6 +162,7 @@ class AerotechContGrid(ScanBase): "exp_time", "relative", "trigger_source", + "minimal_run_up", ], } @@ -174,6 +184,7 @@ class AerotechContGrid(ScanBase): trigger_device: str = "pandatrig", shutter_delay: float | None = None, settle_time: float = 0.15, + minimal_run_up: bool = False, capture_device: str = "aerocapture", **kwargs, # fmt: on @@ -198,6 +209,7 @@ class AerotechContGrid(ScanBase): trigger_device (str): Name of the PandaLineTrigger device for trigger_source="panda". Default is "pandatrig". shutter_delay (float | None): Delay from the line trigger to the first exposure in s. Default: DDG1 shutter delay for "ddg", 0 otherwise. 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". Returns: @@ -253,6 +265,7 @@ class AerotechContGrid(ScanBase): self.trigger_source = trigger_source self.shutter_delay = shutter_delay self.settle_time = settle_time + self.minimal_run_up = minimal_run_up devices = self.device_manager.devices self.ddg1 = self.ddg2 = self.mcs = self.panda = None @@ -530,7 +543,8 @@ class AerotechContGrid(ScanBase): fwd, bwd = dev[0::2], dev[1::2] text = [ f"aerotech_cont_grid statistics ({len(lines)} lines x {n} points, v" - f" {self._line['target_velocity']:g} / s, readout {self._line['readout_time'] * 1e3:g} ms):", + f" {self._line['target_velocity']:g} / s, readout {self._line['readout_time'] * 1e3:g} ms" + f"{', minimal run-up' if self.minimal_run_up else ''}):", f" centre - nominal cell centre [um]: forward mean {fwd.mean():+.2f} rms" f" {fwd.std():.2f} max {np.abs(fwd).max():.2f}", ] @@ -632,7 +646,11 @@ class AerotechContGrid(ScanBase): " run-up distance of the line." ) ramp_distance = velocity**2 / (2 * line["acceleration"]) - margin = RAMP_MARGIN_FRACTION * ramp_distance + velocity * self.settle_time + if self.minimal_run_up: + # exposures start right after the ramp; the captured positions show the result + margin = max(MIN_RUN_UP - ramp_distance, 0.0) + else: + margin = RAMP_MARGIN_FRACTION * ramp_distance + velocity * self.settle_time lead = velocity * line["shutter_open_delay"] # centre each gate (exp_time - readout_time) in its cell: same centres in both directions gap = velocity * line["readout_time"] / 2 @@ -660,12 +678,18 @@ class AerotechContGrid(ScanBase): beyond = f"beyond the high limit {high} by {line['line_end'] - high:.4f}" else: beyond = f"beyond the low limit {low} by {low - line['line_start']:.4f}" + if self.minimal_run_up: + why = f"(acceleration {line['acceleration']:g} / s^2, minimal run-up)" + else: + why = ( + f"and settle (acceleration {line['acceleration']:g} / s^2, settle_time" + f" {self.settle_time:g} s)" + ) raise LimitError( f"The scan range of {name} {self.fast_start:.4f} .. {self.fast_end:.4f} is" f" within the limits, but {name} needs {extra:.4f} extra travel on each side" - f" to accelerate to the scan velocity {line['target_velocity']:g} / s and" - f" settle (acceleration {line['acceleration']:g} / s^2, settle_time" - f" {self.settle_time:g} s). It would travel {line['line_start']:.4f} .." + f" to accelerate to the scan velocity {line['target_velocity']:g} / s" + f" {why}. It would travel {line['line_start']:.4f} .." f" {line['line_end']:.4f}, {beyond}. Move the scan away from the limit or" " scan slower (larger exp_time or smaller fast_step_size)." ) diff --git a/tests/tests_scans/test_aerotech_cont_grid_scan.py b/tests/tests_scans/test_aerotech_cont_grid_scan.py index 833a6120..7eec820f 100644 --- a/tests/tests_scans/test_aerotech_cont_grid_scan.py +++ b/tests/tests_scans/test_aerotech_cont_grid_scan.py @@ -530,3 +530,36 @@ def test_scan_calls_only_user_access_methods(attribute, device_cls): signals = {"motor_velocity", "wait_in_position", "max_scan_velocity", "motor_acceleration"} assert called, f"no calls on self.{attribute} found" assert not (called - signals) - set(cls.USER_ACCESS) + + +def test_minimal_run_up_is_the_ramp_distance_only(v4_scan_assembler, device_manager): + scan = _assemble(v4_scan_assembler, device_manager, settle_time=0.1, minimal_run_up=True) + scan.prepare_scan() + line = scan._line + ramp = (0.5 / 0.1) ** 2 / (2 * 100.0) + assert ramp > 0.01 + assert line["line_start"] == pytest.approx(line["trigger_low"] - ramp) + assert line["line_end"] == pytest.approx(line["trigger_high"] + ramp) + + +def test_minimal_run_up_has_a_floor_at_low_speed(v4_scan_assembler, device_manager): + # 0.5 mm/s at 100 mm/s^2: ramp 1.25 um, run-up raised to 10 um + _add_devices(device_manager) + scan = v4_scan_assembler( + "aerotech_cont_grid", "aerox", -1.0, 1.0, 0.01, "aeroy", -2.0, 2.0, 2, + exp_time=0.02, minimal_run_up=True, + ) + scan.prepare_scan() + line = scan._line + assert line["trigger_low"] - line["line_start"] == pytest.approx(0.01) + assert line["line_end"] - line["trigger_high"] == pytest.approx(0.01) + + +def test_minimal_run_up_limit_message(v4_scan_assembler, device_manager): + _add_devices(device_manager, max_scan_velocity=50.0) + scan = v4_scan_assembler( + "aerotech_cont_grid", "aerox", -9.5, 9.5, 0.1, "aeroy", -2.0, 2.0, 2, + exp_time=0.005, minimal_run_up=True, + ) + with pytest.raises(LimitError, match="minimal run-up"): + scan.prepare_scan()