Skip to content

Plan: improve the fsamx PID centering in move_samx_to_scan_region

Status: DRAFT — NOT FINAL, NEEDS FURTHER DISCUSSION. Not implemented. Written during live beamtime operation on request; no code changes made — see the conversation this plan came from. It rests on one day of logs (2026-09-18) and small samples (18 first-move cases, 25 later-iteration steps) and has not been tested on the instrument. The open questions at the bottom must be settled before any code is changed.

Context

RtFlomniController.move_samx_to_scan_region (csaxs_bec/devices/omny/rt/rt_flomni_ophyd.py, ~lines 181-230) centres fsamx after the rt stage has moved to cenx. It reads the PID X voltage (get_pid_x(), g command on the rt controller, range ±10 V) and compares it with the expected voltage (rtx user parameter rt_pid_voltage, stored by feedback_enable_with_reset).

  • If |V - expected| is below the start threshold: No correction of fsamx needed.
  • Otherwise loop until |V - expected| < 1, each iteration doing pid_x_correction -= (V - expected) * 0.006 and moving fsamx to fsamx_in + cenx/1000*0.7 + pid_x_correction, then laser_tracker_on().

Trigger for this plan. The start threshold was lowered from 2.0 V to 1.2 V (uncommitted edit in the production working tree when this was written) with the aim of reducing the target range below 0.05 V. Afterwards corrections rose from about 8.5 to about 49 per hour and the readings still swung by about ±1-2 V.

Data source. /var/log/bec/DeviceServer.log and ScanServer.log on x12sa-bec-001.psi.ch, 2026-09-18 08:00-20:19. These logs are not part of the repo. The relevant log messages come from the rt_flomni_ophyd logger (Current PID voltage, Moving fsamx to …, PID portion of that …, No correction of fsamx needed, No further correction needed).

Findings this plan rests on

  1. The loop gain is too small, not too large. The measured stage response is 13.4 µm/V (rms 0.18 V over 25 steps at constant cenx), while the code uses 0.006 mm/V = 6 µm/V. Each step therefore removes only about 45 % of the error (measured contraction ×0.57, predicted ×0.55). Going from 1.2 V to below 0.05 V takes about 6 iterations, each followed by a laser tracker wait. At constant cenx the loop does not overshoot.
  2. The first move of a sequence double counts. It applies the cenx feed-forward jump (0.7*Δcenx since the last move, median 35 µm) and a correction increment -0.006*(V-e) computed from a reading that already contains that cenx lag. The sign of V flips after the first move in 12 of 18 cases, e.g. V = -2.44 V with a +36.7 µm feed-forward jump and a +14.6 µm correction step gives V = +1.82 V. A two-term fit suggests the true feed-forward factor is nearer 0.8 than 0.7 (weak evidence, n = 18).
  3. Blind steps at the rail overshoot. In four cases (12:25, 13:56, 18:42, 19:04) a scan stop was followed by a reading pinned at -10.0 V. The loop then made three fixed +60 µm moves (0.006 * 10 V), the reading flipped to +10.0 V, and the laser tracker waited 37-100 s for the target. Twice the wait exceeded 100 polls and raised RtError: Failed to reach laser target position.
  4. pid_x_correction is lost on reload. After the 19:30 device reload it restarted from 0 (first PID part -0.009), while fsamx itself stayed where it was.
  5. About 1 V of scan-to-scan alternation is unexplained. Consecutive scan points alternate high/low with a period of about 46 s. It was present all day, including before the threshold change (08:00-08:30, 10:00-16:00). The cenx and last-fsamx-position model leaves a residual of about 1 V (R² 0.67 before 19:30, 0.30 after 19:36). This is a rotation-tomography run: every scan point is a new angle and cenx follows it.
  6. The Expected PID voltage log line is commented out (rt_flomni_ophyd.py:196). The expected value only appears once per restart as Using PID voltage from rtx user parameter.

The math

Notation: V = PID voltage, e = expected voltage, s = stage response (mm/V), x = fsamx position relative to fsamx_in (mm), c = cenx.

Plant (fits the in-loop data to 0.18 V rms):

V - e = (x - x*(c)) / s,        x*(c) = 0.7*c/1000 + drift,      s ≈ 0.0134 mm/V

Current step: Δx = -0.006 * (V - e), i.e. gain g = 0.006 and per-step error ratio r = 1 - g/s ≈ 0.55. To reach a residual ε from V0 needs n = ln(ε/V0) / ln(r) steps: 6 steps for 1.2 V → 0.05 V.

Proposed step (Newton, damped): Δx = -DAMP * s * (V - e) with DAMP ≈ 0.85. With a 15 % gain error the ratio is about 0.15, so 1.5 V → 0.03 V in two iterations. s may be adapted from the last two iterations (s_est = Δx_prev / (V_prev - V_now)), clamped to 8-20 µm/V.

Double counting: on the first move V - e already contains -(0.7*(c - c_prev)/1000)/s, which the feed-forward jump removes. Adding -0.006*(V-e) on top overshoots.

Proposed changes (all in move_samx_to_scan_region)

  1. Newton step as above instead of the fixed small gain.
  2. Remove the double counting.
  3. Option A (preferred): compute the target from the actual position, x_target = x_now - DAMP*s*(V-e), and store pid_x_correction = x_target - 0.7*cenx/1000, so the cenx lag is never accumulated as drift.
  4. Option B: apply the feed-forward move first, re-read V, then correct. Simpler, but costs about 1 s plus a tracker wait per correction.
  5. Derive x_now from the fsamx readback instead of a running sum, so a reload does not reset the bookkeeping.
  6. Clamp steps at the rail. When |V| >= 9.9, cap the step at about 30 µm and re-read instead of making blind ±60 µm steps.
  7. Hysteresis. Start about 0.5 V, stop about 0.15 V (currently start 1.2 V, stop 1.0 V, so the next cenx step re-triggers immediately).
  8. Logging.
  9. Restore the Expected PID voltage line next to each Current PID voltage.
  10. Log angle, cenx, V and the fsamx readback per scan point.
  11. Log laser_tracker_show_all() output when laser_tracker_wait_on_target times out (the tracker's beam position, target and piezo state are not logged today).

Sketch (not tested):

S, DAMP, FF, VCLIP = 0.0134, 0.85, 0.7e-3, 8.0   # mm/V, -, mm per cenx unit, V
x_now = fsamx.obj.readback.get() - fsamx_in           # actual position (mm)
for _ in range(MAX_ITER):
    v = self.get_pid_x() - expected_voltage
    if abs(v) < V_STOP:
        break
    x_now -= DAMP * S * np.clip(v, -VCLIP, VCLIP)
    fsamx.obj.pid_x_correction = x_now - FF * cenx    # drift not explained by cenx
    fsamx.obj.move(fsamx_in + x_now, wait=True)
    time.sleep(0.1)
    self.laser_tracker_on()

What this will not fix

The unexplained ~1 V scan-to-scan alternation (finding 5) is not addressed. A 0.05 V threshold would chase it with a move on nearly every scan point, and each move costs a laser tracker wait. The plan therefore assumes about 0.15 V as the stop level until the alternation is understood.

Verification (once approved)

  • Offline replay: run the proposed step logic against the logged (V, cenx, position) sequences from 2026-09-18 and compare iterations per correction and residual V with what happened.
  • Bench check: on the instrument, force a known offset of about 2 V and confirm convergence to below 0.15 V in at most 3 iterations with no sign flip; repeat with about 5 V.
  • Rail case: stop and restart a scan mid-way and confirm no ±10 V overshoot and no tracker timeout.
  • Rate check: confirm corrections per hour do not rise above the 8.5/hour baseline.
  • Unit tests: the step function should be a pure function so it can be tested without hardware (see tests/tests_devices).

Open questions for discussion

  1. Is a threshold of 0.05 V realistic while the alternation is unexplained? The plan assumes about 0.15 V for the stop level.
  2. What is the true feed-forward factor: 0.7 (code) or about 0.8 (fit)?
  3. Option A or B for the double counting?
  4. Should the alternation be diagnosed first (angle dependence, backlash, the v0/v1 FZP feedback toggle) before any code change?
  5. Is the 13.4 µm/V response stable across days? Only 2026-09-18 was analysed; the rotated logs from 2026-09-11 to 2026-09-17 could be checked.
  6. Should the already-made 1.2 V threshold change be kept, reverted, or left until this is settled? It is currently uncommitted in the production working tree.