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
188216import tkinter as tk
196224import atexit
197225import traceback
198226import platform
227+ import ctypes # HARD-1: Windows keep-awake during a run
199228from datetime import datetime
200229from collections import deque
201230from 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# ============================================================
621676class 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