Skip to content

Commit 9eb8d5c

Browse files
Harden Fscan and Master cooldown written explicitly
1 parent 8852d59 commit 9eb8d5c

3 files changed

Lines changed: 277 additions & 14 deletions

File tree

pica/keysight/PPMS_Dielectric_Master_Tscan_Fscan_E4980A_GUI.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,11 @@
181181
extra clicks. Uses the existing Final cooldown (h:mm)
182182
duration; same timed end + loud tell as COOL-1. Its
183183
duration is now validated against the cooldown ramp
184-
time in this case too.
184+
time in this case too. The run LIST shows it as an
185+
explicit trailing line ("then (default): FINAL
186+
COOLDOWN to base (3:00) -> e.g. take M(H) while
187+
heating"), live-updated with the checkbox and the
188+
duration entry, so what will happen is never implicit.
185189
"""
186190

187191
import tkinter as tk
@@ -1379,6 +1383,12 @@ def _create_protocol_panel(self, parent, row):
13791383
cb.grid(row=9, column=0, columnspan=4, sticky="w",
13801384
padx=10, pady=(4, 0))
13811385
self._prerun_simple.append(cb)
1386+
# COOL-2: toggling the checkbox or editing the duration redraws
1387+
# the run list so its "then: FINAL COOLDOWN" line stays honest.
1388+
self.var_final_cd_always.trace_add(
1389+
"write", lambda *a: self._runs_render())
1390+
self.proto["final_cooldown"].bind(
1391+
"<KeyRelease>", lambda e: self._runs_render())
13821392

13831393
ttk.Label(frame, text="Tscan frequencies (Hz, comma-separated):"
13841394
).grid(row=10, column=0, columnspan=4, sticky="w",
@@ -1447,6 +1457,20 @@ def _runs_render(self):
14471457
tk.END,
14481458
f"Run {i}: {r['field_oe']:g} Oe "
14491459
f"(cooldown {r['cooldown']}) -> {label}")
1460+
# COOL-2: the default final cooldown is part of the protocol, so
1461+
# LIST it explicitly — the user must see what happens after the
1462+
# last run without having to remember the checkbox. Info line
1463+
# only (not in run_rows; Remove Selected ignores it).
1464+
if self.run_rows and getattr(self, "var_final_cd_always", None) \
1465+
and self.var_final_cd_always.get():
1466+
try:
1467+
dur = self.proto["final_cooldown"].get().strip() or "?"
1468+
except (KeyError, tk.TclError):
1469+
dur = "?"
1470+
self.runs_lb.insert(
1471+
tk.END,
1472+
f"then (default): FINAL COOLDOWN to base ({dur}) "
1473+
"-> e.g. take M(H) while heating")
14501474

14511475
def _add_run(self):
14521476
try:

pica/keysight/Step_Frequency_Scan_E4980A_GUI.py

Lines changed: 163 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,34 @@
183183
preset dropdown for manual use. Expect a slower final
184184
approach in exchange for reduced overshoot and much faster
185185
recovery.
186+
187+
============================================================
188+
v1.7 — UNATTENDED-RUN HARDENING (Passive v1.4 pattern)
189+
============================================================
190+
HARD-1 Windows keep-awake (SetThreadExecutionState) while a
191+
sequence runs — an overnight run can no longer be killed
192+
by system sleep. Display may still sleep.
193+
HARD-2 RETRY-FOREVER COMM RECOVERY. Any VISA/comm failure in the
194+
worker (Lakeshore status reads, E4980A measurements) now
195+
enters a reconnect loop with escalating backoff
196+
(5→10→30→60 s) instead of aborting the run: both sessions
197+
are closed and re-opened (the E4980A is fully
198+
re-configured, bias re-ramped; the Lakeshore reconnect is
199+
SESSION-ONLY and never re-sends setpoints — the 350 keeps
200+
executing its ramp while the link is down). The current
201+
frequency point is retried after recovery. Stop stays
202+
responsive throughout. REL-1's quick retries remain the
203+
first line of defense.
204+
HARD-3 FSYNC-PER-ROW writes: temperature-log rows, stabilization
205+
summary rows and every sweep data row are forced to the
206+
physical disk (flush + os.fsync) so a power cut cannot
207+
lose OS-buffered data.
208+
HARD-4 NO MODAL DIALOGS during/after a run (unattended policy):
209+
the "Sequence Complete" messagebox is replaced by the
210+
banner + log + triple beep; safety aborts and critical
211+
errors also beep. `_beep(times=N)` upgraded to the
212+
Passive v1.4 multi-beep helper. Start-time validation
213+
dialogs and the exit confirm (user-clicked) remain.
186214
"""
187215

188216
import tkinter as tk
@@ -196,6 +224,7 @@
196224
import atexit
197225
import traceback
198226
import platform
227+
import ctypes # HARD-1: Windows keep-awake during a run
199228
from datetime import datetime
200229
from collections import deque
201230
from multiprocessing import Process
@@ -361,6 +390,7 @@ class Lakeshore_Backend:
361390

362391
def __init__(self):
363392
self.lakeshore = None
393+
self.last_visa = None # HARD-2: remembered for reconnect()
364394
self.rm = None
365395
if pyvisa:
366396
try:
@@ -371,6 +401,7 @@ def __init__(self):
371401
def connect(self, visa_address):
372402
if not self.rm:
373403
raise ConnectionError("VISA Resource Manager unavailable.")
404+
self.last_visa = visa_address # HARD-2: for reconnect()
374405
self.lakeshore = self.rm.open_resource(visa_address)
375406
self.lakeshore.timeout = 10000
376407
self.lakeshore.write("*CLS") # do NOT *RST mid-run
@@ -380,6 +411,19 @@ def connect(self, visa_address):
380411
print(f"WARNING: IDN does not contain '350': {idn}")
381412
return idn
382413

414+
def reconnect(self):
415+
"""HARD-2: close and re-open the VISA session only. NEVER
416+
touches heater range / ramp / setpoint — the 350 keeps
417+
executing its last program while the PC link is down, and the
418+
retry-forever loop must not disturb it on recovery."""
419+
try:
420+
if self.lakeshore:
421+
self.lakeshore.close()
422+
except Exception:
423+
pass
424+
self.lakeshore = None
425+
return self.connect(self.last_visa)
426+
383427
def set_heater_range(self, output, heater_range):
384428
try:
385429
range_code = int(heater_range)
@@ -579,6 +623,17 @@ def initialize_instrument(self, p):
579623
self._check_errors("configuration")
580624
print(f" E4980A configured: {idn}")
581625

626+
def reconnect(self):
627+
"""HARD-2: close and fully re-initialize from the stored params
628+
(restores RX mode, aperture, ALC, corrections and the DC-bias
629+
ramp even after an instrument power-cycle)."""
630+
try:
631+
self.close_instrument()
632+
except Exception:
633+
pass
634+
self.instrument = None
635+
self.initialize_instrument(self.params)
636+
582637
def perform_measurement(self, freq, delay):
583638
if not self.instrument:
584639
raise ConnectionError("Instrument not connected.")
@@ -619,7 +674,11 @@ def close_instrument(self):
619674
# FRONTEND: Combined GUI
620675
# ============================================================
621676
class CombinedGUI:
622-
PROGRAM_VERSION = "1.6-Combined" # ultra-slow anti-windup PID below 100 K
677+
PROGRAM_VERSION = "1.7-Combined" # unattended hardening (HARD-1..4)
678+
679+
# HARD-1: SetThreadExecutionState flags (Windows keep-awake)
680+
ES_CONTINUOUS = 0x80000000
681+
ES_SYSTEM_REQUIRED = 0x00000001
623682
LEFT_PANEL_WIDTH = 480 # default sash position so the left panel starts fully visible
624683

625684
# --- FRZ-1 / FRZ-2 / FRZ-4 tuning knobs ---
@@ -1420,20 +1479,35 @@ def log(self, msg):
14201479
def _update_status_ui(self, text, color):
14211480
self.lbl_status.config(text=text, bg=color)
14221481

1423-
def _beep(self):
1482+
def _beep(self, times=1):
14241483
"""FRZ-3: MUST only be called from the main (Tk) thread.
14251484
winsound runs in a helper thread (it blocks); root.bell() is
1426-
called directly on the main thread — never from a worker."""
1485+
called directly on the main thread — never from a worker.
1486+
HARD-4: multi-beep (Passive v1.4 pattern) — used instead of
1487+
modal dialogs on unattended-run events; a messagebox nobody is
1488+
there to click must never linger over an overnight run."""
14271489
if HAS_WINSOUND and platform.system() == "Windows":
1428-
threading.Thread(
1429-
target=lambda: winsound.Beep(1000, 500), daemon=True
1430-
).start()
1490+
def _do_beep():
1491+
for _ in range(max(1, times)):
1492+
winsound.Beep(1000, 500)
1493+
time.sleep(0.2)
1494+
threading.Thread(target=_do_beep, daemon=True).start()
14311495
else:
14321496
try:
14331497
self.root.bell()
14341498
except Exception:
14351499
pass
14361500

1501+
def _set_keep_awake(self, enable):
1502+
"""HARD-1: stop Windows from sleeping mid-run (display may still
1503+
sleep). Best-effort no-op on other platforms."""
1504+
try:
1505+
flags = self.ES_CONTINUOUS | (
1506+
self.ES_SYSTEM_REQUIRED if enable else 0)
1507+
ctypes.windll.kernel32.SetThreadExecutionState(flags)
1508+
except Exception:
1509+
pass
1510+
14371511
# ------------------------------------------------------------
14381512
# Sequence builder helpers (fix #9, #10, #13)
14391513
# ------------------------------------------------------------
@@ -1691,6 +1765,7 @@ def start_sequence(self):
16911765

16921766
self.worker_thread = threading.Thread(target=self._hardware_worker_loop, daemon=True)
16931767
self.worker_thread.start()
1768+
self._set_keep_awake(True) # HARD-1: overnight run, no system sleep
16941769
self.root.after(50, self._process_gui_queue)
16951770

16961771
def stop_sequence(self, reason=""):
@@ -1825,7 +1900,9 @@ def _process_gui_queue(self):
18251900
elif t == "status":
18261901
self._update_status_ui(m["text"], m["color"])
18271902
elif t == "beep":
1828-
self._beep() # FRZ-3: beep executes on the Tk thread
1903+
# FRZ-3: beep executes on the Tk thread. HARD-4: the
1904+
# worker may ask for several (alarm-grade events).
1905+
self._beep(times=m.get("times", 1))
18291906
elif t == "temp_point":
18301907
self.plot_t.append(m["t"])
18311908
self.plot_temp.append(m["temp"])
@@ -1870,9 +1947,14 @@ def _process_gui_queue(self):
18701947
self.pid_preset_var.set("Custom")
18711948
elif t == "sequence_complete":
18721949
self.set_ui_state(running=False)
1873-
messagebox.showinfo("Sequence Complete", "All setpoints measured.")
1950+
# HARD-4: unattended policy — no modal on completion.
1951+
# The worker already set the COMPLETE banner; log +
1952+
# triple beep announce it audibly instead.
1953+
self.log("SEQUENCE COMPLETE — all setpoints measured.")
1954+
self._beep(times=3)
18741955
elif t == "worker_done":
18751956
self.set_ui_state(running=False)
1957+
self._set_keep_awake(False) # HARD-1: sleep allowed again
18761958
self._update_status_ui("IDLE", self.CLR_HEADER)
18771959
return # worker is gone; stop polling
18781960
except queue.Empty:
@@ -1918,6 +2000,58 @@ def calculate_impedance_parameters(self, f, R, X):
19182000
return [Q, D, G, B, Cp, Lp, Cs, Ls, Z_mag, theta_rad, X, R,
19192001
theta_deg, Rp, Y_mag, omega, Cp_dp, Cs_dp]
19202002

2003+
# ============================================================
2004+
# HARD-2: retry-forever comm recovery (worker thread only;
2005+
# embedded Passive v1.3/1.4 pattern — PICA programs do not
2006+
# import from each other)
2007+
# ============================================================
2008+
def _comm_recover(self, context, err):
2009+
"""Close and re-open BOTH sessions with escalating backoff
2010+
(5→10→30→60 s) until they respond or the user stops. The
2011+
Lakeshore keeps executing its last ramp while the link is
2012+
down — its reconnect is session-only and never re-sends
2013+
setpoints. Returns False only if Stop was requested."""
2014+
attempt = 1
2015+
self._put_gui_msg("log",
2016+
text=f"⚠️ COMM ERROR during {context}: {err} — entering "
2017+
"retry-forever reconnect (Stop stays responsive).")
2018+
self._put_gui_msg("beep", times=2)
2019+
backoffs = (5, 10, 30, 60)
2020+
while self.is_running:
2021+
delay_s = backoffs[min(attempt - 1, len(backoffs) - 1)]
2022+
self._put_gui_msg("status",
2023+
text=f"COMM LOST — reconnect #{attempt} in {delay_s} s",
2024+
color=self.CLR_ACCENT_RED)
2025+
deadline = time.time() + delay_s
2026+
while time.time() < deadline:
2027+
if self._process_cmd_queue() or not self.is_running:
2028+
return False
2029+
time.sleep(0.5)
2030+
try:
2031+
idn = self.ls_backend.reconnect()
2032+
self.lcr_backend.reconnect()
2033+
self._put_gui_msg("log",
2034+
text=f"✅ Reconnected after {attempt} attempt(s) "
2035+
f"(Lakeshore: {idn}). Resuming sequence.")
2036+
return True
2037+
except Exception as e:
2038+
self._put_gui_msg("log",
2039+
text=f"Reconnect attempt #{attempt} failed: {e}")
2040+
attempt += 1
2041+
return False
2042+
2043+
def _get_status_hardened(self):
2044+
"""HARD-2: get_status that survives a dead link. REL-1's quick
2045+
retries run first (inside get_status); on persistent failure the
2046+
retry-forever reconnect takes over. Raises RuntimeError only
2047+
when the user stops mid-recovery, so callers unwind cleanly."""
2048+
while True:
2049+
try:
2050+
return self.ls_backend.get_status()
2051+
except Exception as e:
2052+
if not self._comm_recover("Lakeshore status read", e):
2053+
raise RuntimeError("Stopped during comm recovery.")
2054+
19212055
# ============================================================
19222056
# WORKER THREAD (owns both instruments)
19232057
# ============================================================
@@ -1953,6 +2087,7 @@ def _hardware_worker_loop(self):
19532087
"Heater_pct", "Measuring", "Ramp_rate_K_min", "Phase"]
19542088
)
19552089
self.data_file.flush()
2090+
os.fsync(self.data_file.fileno()) # HARD-3
19562091
self._put_gui_msg("log", text=f"Temperature log: {tlog_path}")
19572092

19582093
# ADV-6: per-setpoint stabilization summary
@@ -1966,6 +2101,7 @@ def _hardware_worker_loop(self):
19662101
["Setpoint_K", "Ramp_rate_K_min", "Approach_mode",
19672102
"Start_time", "End_time", "Stabilization_s", "Outcome"])
19682103
self.summary_file.flush()
2104+
os.fsync(self.summary_file.fileno()) # HARD-3
19692105
self._put_gui_msg("log", text=f"Stabilization summary: {sum_path}")
19702106

19712107
total_pts = len(self.setpoint_floats) * len(self.sweep_frequencies)
@@ -1980,7 +2116,7 @@ def _hardware_worker_loop(self):
19802116
tol=self.params["tol"])
19812117

19822118
# ADV-1: adaptive (or fixed) ramp rate for this step
1983-
temp_now, _, _ = self.ls_backend.get_status()
2119+
temp_now, _, _ = self._get_status_hardened() # HARD-2
19842120
if self.params["adaptive"]:
19852121
rate, reason = compute_ramp_rate(
19862122
temp_now, target, self.params, is_first_step=(i == 0))
@@ -2041,10 +2177,12 @@ def _hardware_worker_loop(self):
20412177
text=f"SAFETY ABORT: {e} Ramp stopped, heater off.")
20422178
self._put_gui_msg("status", text="SAFETY ABORT (MAX TEMP)",
20432179
color=self.CLR_ACCENT_RED)
2180+
self._put_gui_msg("beep", times=5) # HARD-4: alarm, no modal
20442181
except Exception as e:
20452182
self._put_gui_msg("log",
20462183
text=f"CRITICAL: {e}\n{traceback.format_exc()}")
20472184
self._put_gui_msg("status", text="ERROR", color=self.CLR_ACCENT_RED)
2185+
self._put_gui_msg("beep", times=3) # HARD-4: alarm, no modal
20482186
finally:
20492187
# §3f: crash-safe shutdown
20502188
try:
@@ -2117,6 +2255,7 @@ def _write_summary_row(self, target, rate, t_start, elapsed, outcome):
21172255
datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
21182256
f"{elapsed:.1f}", outcome])
21192257
self.summary_file.flush()
2258+
os.fsync(self.summary_file.fileno()) # HARD-3
21202259
except Exception as e:
21212260
self._put_gui_msg("log", text=f"WARN: summary write failed: {e}")
21222261

@@ -2351,7 +2490,7 @@ def _log_temperature_point(self, target, measuring_flag):
23512490
this function always reads the heater itself via get_status().
23522491
Raises RuntimeError if the kill switch trips.
23532492
"""
2354-
temp, _, htr = self.ls_backend.get_status()
2493+
temp, _, htr = self._get_status_hardened() # HARD-2
23552494
if self.ls_backend.check_overtemp(temp):
23562495
self._put_gui_msg("log",
23572496
text=f"!!! KILL SWITCH: {temp:.2f} K >= 340 K. Heater OFF. Aborting.")
@@ -2372,6 +2511,7 @@ def _log_temperature_point(self, target, measuring_flag):
23722511
f"{self._current_rate:.3f}", phase]
23732512
)
23742513
self.data_file.flush()
2514+
os.fsync(self.data_file.fileno()) # HARD-3
23752515
# CRIT-2: heater value rides on the queue msg; no worker-side list.
23762516
self._put_gui_msg("temp_point", t=elapsed, temp=temp, target=target,
23772517
measuring=measuring_flag, heater=htr)
@@ -2391,7 +2531,11 @@ def _run_frequency_sweep(self, target_temp, done_pts, total_pts):
23912531
f"APER: {self.lcr_params['aper']}\n")
23922532
f.write(self.lcr_backend.DATA_HEADER + "\n")
23932533
self._worker_phase = "SWEEP"
2394-
for i, freq in enumerate(self.sweep_frequencies):
2534+
# HARD-2: while-loop so a frequency point can be RETRIED
2535+
# after a comm recovery instead of abandoning the sweep.
2536+
idx = 0
2537+
while idx < len(self.sweep_frequencies):
2538+
freq = self.sweep_frequencies[idx]
23952539
if not self.is_running:
23962540
break
23972541
# check for stop / pid updates mid-sweep
@@ -2420,8 +2564,12 @@ def _run_frequency_sweep(self, target_temp, done_pts, total_pts):
24202564
freq, self.lcr_params["delay"]
24212565
)
24222566
except Exception as e:
2423-
self._put_gui_msg("log", text=f"Meas error @ {freq} Hz: {e}")
2424-
break
2567+
# HARD-2: reconnect forever, then retry THIS point
2568+
# (was: log + abandon the rest of the sweep).
2569+
if self._comm_recover(
2570+
f"E4980A measurement @ {freq:g} Hz", e):
2571+
continue
2572+
break # stop requested during recovery
24252573
# MIN-6: surface non-zero E4980A status words. Bits indicate
24262574
# overload/out-of-range/bridge warnings per the E4980A manual.
24272575
if status != 0:
@@ -2431,6 +2579,7 @@ def _run_frequency_sweep(self, target_temp, done_pts, total_pts):
24312579
row = [freq] + vals + [target_temp]
24322580
f.write("\t".join(f"{v:.6E}" for v in row) + "\n")
24332581
f.flush()
2582+
os.fsync(f.fileno()) # HARD-3: survive a power cut
24342583
# Interleaved temperature log (flag=1) — §3c/§3d
24352584
try:
24362585
self._log_temperature_point(target_temp, measuring_flag=1)
@@ -2441,6 +2590,7 @@ def _run_frequency_sweep(self, target_temp, done_pts, total_pts):
24412590
done_pts += 1
24422591
self._put_gui_msg("scan_point", freq=freq, cp=vals[4], g=vals[2],
24432592
progress=done_pts)
2593+
idx += 1 # HARD-2: advance only after a completed point
24442594
self._worker_phase = None
24452595
self._put_gui_msg("log", text=f"Sweep saved: {fname}")
24462596
return done_pts

0 commit comments

Comments
 (0)