diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py index 329a2b41..36c5ec29 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py @@ -710,6 +710,82 @@ class FlomniSampleTransferMixin: else: print("The rt feedback is \x1b[91mNOT\x1b[0m running.") + def interferometer_trigger_show(self): + """Show the detector-trigger stability criterion of the interferometer feedback. + + A step-scan point is triggered when the relative position sample vs FZP (interferometer + minus FZP cap sensor) is within the tolerance in x and in y, each averaged over 0.5 ms, + for the hold time without interruption. + """ + values = dev.rtx.controller.emitter_get_ontarget() + tol_nm = values["ontarget_tolerance_um"] * 1000 + if tol_nm > 0: + print(f"Detector trigger: |position error| x and y <= {tol_nm:.1f} nm " + f"for {values['ontarget_hold_ms']:.1f} ms.") + else: + print("Detector trigger: former criterion (on-target flags of the FZP PID loops).") + if values.get("trigger_on_move") is not None: + mode = "on" if values["trigger_on_move"] else "off" + print(f"Trigger after every position change (not only at scan points): {mode}.") + + def interferometer_trigger_set_tolerance_nm(self, tolerance_nm: float): + """Set the detector-trigger tolerance in nm (per axis, x and y). + + Typical: 30 nm for a fast low-resolution scan, 10 nm standard, 2 nm for the highest + resolution (needs a few ms more per point). 0 selects the former criterion (on-target flags + of the FZP PID loops). + + Args: + tolerance_nm (float): tolerance in nm, 0 or 1 ... 1000. + """ + if tolerance_nm != 0 and not 1 <= tolerance_nm <= 1000: + raise FlomniError( + "The trigger tolerance must be 0 (former criterion) or 1 ... 1000 nm." + ) + dev.rtx.controller.emitter_set_ontarget_tolerance(tolerance_nm / 1000) + self.interferometer_trigger_show() + + def interferometer_trigger_set_hold_ms(self, hold_ms: float): + """Set how long (ms) the position must stay within the tolerance before the trigger. + + Typical 3 ms. Longer = safer against a position that is only passing through the tolerance. + + Args: + hold_ms (float): hold time in ms, 0 ... 100. + """ + if not 0 <= hold_ms <= 100: + raise FlomniError("The trigger hold time must be 0 ... 100 ms.") + dev.rtx.controller.emitter_set_ontarget_hold_ms(hold_ms) + self.interferometer_trigger_show() + + def interferometer_trigger_set_after_move(self, enable: bool): + """Send one detector trigger after every position change, not only at scan points. + + With True every direct move of the sample stage (e.g. umv of rtx / rty / rtz, or + dev.rtx.controller.move_xyz) gives exactly one trigger, as soon as the position is stable + (same tolerance and hold time as for scan points). False: triggers at scan points only. + """ + dev.rtx.controller.emitter_set_trigger_on_move(1 if enable else 0) + self.interferometer_trigger_show() + + def interferometer_trigger_help(self): + """Print the detector-trigger commands with a short explanation.""" + lines = [ + "Detector trigger (step scans): a point is triggered when the relative position", + "sample vs FZP is within the tolerance in x and y (0.5 ms average) for the hold time.", + " flomni.interferometer_trigger_show() tolerance and hold time", + " flomni.interferometer_trigger_set_tolerance_nm(val) 30 coarse, 10 standard,", + " 2 finest; 0 = former", + " criterion (FZP PID flags)", + " flomni.interferometer_trigger_set_hold_ms(val) e.g. 3", + " flomni.interferometer_trigger_set_after_move(True) also one trigger after every", + " position change (False: off)", + " dev.rtx.controller.move_xyz(x, y, z) move all three axes at once", + "The values are live; after a restart of the control loop the start values", + "(10 nm, 3 ms) from the loop parameter file apply again.", + ] + print("\n".join(lines)) + def lights_off(self): self.device_manager.devices.fsamx.controller.lights_off() diff --git a/csaxs_bec/devices/omny/rt/rt_flomni_ophyd.py b/csaxs_bec/devices/omny/rt/rt_flomni_ophyd.py index 0cf66f39..afa49424 100644 --- a/csaxs_bec/devices/omny/rt/rt_flomni_ophyd.py +++ b/csaxs_bec/devices/omny/rt/rt_flomni_ophyd.py @@ -51,6 +51,12 @@ class RtFlomniController(Controller): "emitter_set_tracking_target_z", "emitter_set_intensity_threshold_laser", "emitter_set_psd_intensity_threshold_tracking_low", + "emitter_get_ontarget", + "emitter_set_ontarget_tolerance", + "emitter_set_ontarget_hold_ms", + "emitter_help", + "emitter_set_trigger_on_move", + "move_xyz", ] def __init__( @@ -432,8 +438,102 @@ class RtFlomniController(Controller): t.field_names = ["Name", "Value"] for key, val in self.emitter_get().items(): t.add_row([key, val]) + for key, val in self.emitter_get_ontarget().items(): + t.add_row([key, val]) print(t) + def emitter_get_ontarget(self) -> dict: + """Read the detector-trigger stability criterion (OnTargetTrigger in the control loop). + + A step-scan point is triggered when the relative position sample vs FZP is within + ``ontarget_tolerance_um`` in x and in y (each averaged over 0.5 ms) for ``ontarget_hold_ms`` + without interruption. + + Returns: + dict: ontarget_tolerance_um, ontarget_hold_ms, trigger_on_move (None from a server + that does not report it yet). + """ + ret = self.socket_put_and_receive("eg") + ret = ret.split("\n")[0] + values = [float(val) for val in ret.split(",")] + if len(values) not in (2, 3): + raise RtCommunicationError( + f"Expected to receive 2 or 3 on-target values. Instead received {values}" + ) + return { + "ontarget_tolerance_um": values[0], + "ontarget_hold_ms": values[1], + "trigger_on_move": values[2] if len(values) == 3 else None, + } + + def emitter_set_ontarget_tolerance(self, val_um: float) -> None: + """Set the detector-trigger tolerance in um, per axis. + + E.g. 0.030 for a low-resolution scan, 0.002 for the highest resolution. A value <= 0 selects + the former criterion (on-target flags of the FZP PID loops, set in their parameter files). + """ + self.socket_put(f"eo{val_um:.6f}") + + def emitter_set_ontarget_hold_ms(self, val_ms: float) -> None: + """Set the time in ms the position must stay within the tolerance before the trigger.""" + self.socket_put(f"eh{val_ms:.3f}") + + def emitter_set_trigger_on_move(self, val: int) -> None: + """1: send one detector trigger after every direct position change (pa / p), once on target + (same criterion as a step-scan point); 0: triggers at step-scan points only.""" + self.socket_put(f"em{1 if val else 0}") + + def move_xyz(self, x: float, y: float, z: float) -> None: + """Move rtx, rty and rtz with one command (CommunicationServer p,,). + + The three targets change in the same loop step, so with trigger_on_move there is exactly one + detector trigger for the move. Values as for the rtx / rty / rtz axes (axis sign applied). + Does not wait for the move. + """ + if self.laser_tracker_check_signalstrength() == "toolow": + raise RtError( + "The interferometer signal is too low for movements. Realignment required." + ) + targets = [] + for name, val in (("rtx", x), ("rty", y), ("rtz", z)): + axis = self.get_axis_by_name(name) + target_val = val * axis.sign + axis.user_setpoint.setpoint = target_val + targets.append(target_val) + self.socket_put(f"p{targets[0]:.4f},{targets[1]:.4f},{targets[2]:.4f}") + + def emitter_help(self) -> None: + """Print the ConstEmitter commands (loop runtime constants) with a short explanation.""" + t = PrettyTable() + t.title = "ConstEmitter commands (rt controller)" + t.field_names = ["Method", "Wire", "Explanation"] + t.align = "l" + rows = [ + ("emitter_show_all()", "es, eg", "print all runtime constants"), + ("emitter_get()", "es", "dict: tracking targets y/z, laser and PSD thresholds"), + ("emitter_get_ontarget()", "eg", "dict: detector-trigger tolerance (um), hold (ms)"), + ("emitter_set_tracking_target_y(val)", "ey", "target of the tracking loop y (PSD)"), + ("emitter_set_tracking_target_z(val)", "ez", "target of the tracking loop z (PSD)"), + ("emitter_set_intensity_threshold_laser(val)", "et", + "interferometer signal below this: slew limiters and x/y/z, FZP PIDs stop"), + ("emitter_set_psd_intensity_threshold_tracking_low(val)", "el", + "PSD intensity below this: tracking PIDs y/z stop"), + ("emitter_set_ontarget_tolerance(val_um)", "eo", + "detector trigger: |relative error| x and y <= val um (e.g. 0.030 coarse, 0.002 fine);" + " <= 0: FZP PID flags"), + ("emitter_set_ontarget_hold_ms(val_ms)", "eh", + "detector trigger: ... held for val ms without interruption"), + ("emitter_set_trigger_on_move(val)", "em", + "1: one detector trigger after every direct move (pa / p) once on target; 0: off"), + ] + for row in rows: + t.add_row(row) + print(t) + print( + "Values are live in the control loop; the start values after a loop restart come from" + " ConstEmitter.xmp." + ) + def emitter_set_tracking_target_y(self, val: float) -> None: """Set the y tracking target position (PID_tracking_y).""" self.socket_put(f"ey{val:.4f}") diff --git a/docs/user/ptychography/flomni.md b/docs/user/ptychography/flomni.md index b7fe0c6e..8031e5d2 100644 --- a/docs/user/ptychography/flomni.md +++ b/docs/user/ptychography/flomni.md @@ -218,6 +218,27 @@ _There is also an enable without reset, which is used during tomography scans, w - `flomni.feedback_disable()` - `flomni.feedback_status()` +#### Detector trigger: position stability + +In step scans the detector is triggered when the sample is stable at the scan point: the relative position +sample vs FZP (interferometer minus FZP cap sensor) must stay within a tolerance in x and in y (each averaged +over 0.5 ms) for a hold time. Both values are live and can be changed between scans: + +- `flomni.interferometer_trigger_show()` — current tolerance and hold time +- `flomni.interferometer_trigger_set_tolerance_nm(10)` — tolerance per axis in nm: about 30 for a fast + low-resolution scan, 10 standard, 2 for the highest resolution (a few ms more per point); + 0 selects the former criterion (on-target flags of the FZP loops) +- `flomni.interferometer_trigger_set_hold_ms(3)` — hold time in ms +- `flomni.interferometer_trigger_set_after_move(True)` — additionally one trigger after every position change + of the sample stage (e.g. `umv(dev.rtx, ...)`), as soon as the position is stable with the same tolerance and + hold time; `False` (default): triggers at scan points only +- `dev.rtx.controller.move_xyz(x, y, z)` — moves rtx, rty and rtz with one command (one trigger for the move) +- `flomni.interferometer_trigger_help()` — this list + +After a restart of the control loop the start values (10 nm, 3 ms) apply again. Measured with 3 µm steps +(2026-10-20): the trigger comes about 36 ms (x) / 48 ms (y) after the step at 10 nm, 40 / 55 ms at 2 nm; +after the trigger the position stays within about 4 nm even at a 30 nm tolerance. + ### Scanning in 2D and sample alignment #### flOMNI Fermat scan @@ -760,6 +781,14 @@ removed later. [See short version](flomni.md#sample-storage-and-transfer) +### Detector trigger and interferometer tuning + +The trigger tolerance and hold time: [Detector trigger: position stability](#detector-trigger-position-stability) +(`flomni.interferometer_trigger_*`). The control loops of the sample stage, the FZP scanner and the laser +tracker were characterized and retuned in October 2026; results, the present parameters and how to retune them +are documented in the flOMNI control repository (`FeedbackLoop/feedback_report.md` with PDF, +`FeedbackLoop/tuning_guide.md`). + ### Heater The [heater](https://www.dora.lib4ri.ch/psi/islandora/object/psi:45302) can be moved up and down by