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 doingpid_x_correction -= (V - expected) * 0.006and movingfsamxtofsamx_in + cenx/1000*0.7 + pid_x_correction, thenlaser_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¶
- 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 uses0.006mm/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 constantcenxthe loop does not overshoot. - The first move of a sequence double counts. It applies the
cenxfeed-forward jump (0.7*Δcenxsince the last move, median 35 µm) and a correction increment-0.006*(V-e)computed from a reading that already contains thatcenxlag. 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). - 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 raisedRtError: Failed to reach laser target position. pid_x_correctionis lost on reload. After the 19:30 device reload it restarted from 0 (first PID part -0.009), whilefsamxitself stayed where it was.- 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
cenxand 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 newangleandcenxfollows it. - The
Expected PID voltagelog line is commented out (rt_flomni_ophyd.py:196). The expected value only appears once per restart asUsing 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)¶
- Newton step as above instead of the fixed small gain.
- Remove the double counting.
- Option A (preferred): compute the target from the actual position,
x_target = x_now - DAMP*s*(V-e), and storepid_x_correction = x_target - 0.7*cenx/1000, so thecenxlag is never accumulated as drift. - 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.
- Derive
x_nowfrom thefsamxreadback instead of a running sum, so a reload does not reset the bookkeeping. - 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. - Hysteresis. Start about 0.5 V, stop about 0.15 V (currently start 1.2 V,
stop 1.0 V, so the next
cenxstep re-triggers immediately). - Logging.
- Restore the
Expected PID voltageline next to eachCurrent PID voltage. - Log
angle,cenx, V and thefsamxreadback per scan point. - Log
laser_tracker_show_all()output whenlaser_tracker_wait_on_targettimes 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¶
- Is a threshold of 0.05 V realistic while the alternation is unexplained? The plan assumes about 0.15 V for the stop level.
- What is the true feed-forward factor: 0.7 (code) or about 0.8 (fit)?
- Option A or B for the double counting?
- Should the alternation be diagnosed first (angle dependence, backlash, the
v0/v1FZP feedback toggle) before any code change? - 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.
- 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.