Skip to content

Commit 9623715

Browse files
committed
refactor: guaranteed teardown via ExitStack in __main__
Wrap the run body in a contextlib.ExitStack, registering the CSV writers, logging, and window close as they are acquired. Previously the cleanup block ran only on the happy path, so a quit key (core.quit -> SystemExit from _poll_hotkeys) or a mid-run error skipped logging.flush()/win.close(). core.quit() is called after the block so genuine errors still propagate with a traceback.
1 parent 71ff9fb commit 9623715

1 file changed

Lines changed: 179 additions & 173 deletions

File tree

src/mid_det/__main__.py

Lines changed: 179 additions & 173 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from __future__ import annotations
66

77
import platform
8+
from contextlib import ExitStack
89
from datetime import datetime
910
from pathlib import Path
1011

@@ -186,187 +187,192 @@ def _flip_with_overlay(*args, **kwargs): # noqa: E306
186187
f"win-ratio threshold={config.WIN_RATIO_THRESHOLD}"
187188
)
188189

189-
# ── SETUP OUTPUT FILES ───────────────────────────────────────────────────
190+
# ── SETUP OUTPUT FILES + GUARANTEED TEARDOWN ─────────────────────────────
191+
# Register teardown on an ExitStack as each resource is acquired, so the
192+
# writers, logging, and the window are always closed — on normal completion,
193+
# a quit key (core.quit -> SystemExit from _poll_hotkeys), or a mid-run error
194+
# — not just the happy path. core.quit() is called after the block (not
195+
# registered) so a genuine error still propagates with its traceback.
190196
file_stem = f"{session_info.subject_id}_run{session_info.run_n}"
191-
behavioral_writer = recording.BehavioralCsvWriter(run_dir / f"behavioral_{file_stem}.csv")
192-
target_timing_writer = recording.TargetTimingCsvWriter(run_dir / f"target_timing_{file_stem}.csv")
193-
scan_log_writer = recording.ScanLogWriter(run_dir / f"scan_log_{file_stem}.csv")
194-
legacy_dir = data_dir / "legacy-fmt"
195-
legacy_dir.mkdir(parents=True, exist_ok=True)
196-
# MATLAB PartialParseData.m numbers trials continuously across blocks: block 1
197-
# is trials 1-42, so block 2 continues from 43. Our trial_n restarts at 1 each
198-
# run, so shift run 2 up by block 1's length (42) to restore that numbering.
199-
legacy_trial_offset = 42 if session_info.run_n == "2" else 0
200-
legacy_writer = recording.LegacyMidCsvWriter(
201-
legacy_dir / f"{session_info.legacy_name}_b{session_info.run_n}.csv",
202-
trial_offset=legacy_trial_offset,
203-
)
204-
recording.write_manifest(
205-
run_dir=run_dir,
206-
session_info=session_info,
207-
session_started_at=session_started_at,
208-
frame_rate=frame_rate,
209-
n_trials=n_trials,
210-
screen_diag=screen_diag,
211-
frame_dur_s=frame_dur_s,
212-
frame_dur_source=fps_source,
213-
win_res=win_res,
214-
priority_raised=(priority_status == "HIGH_PRIORITY_CLASS"),
215-
)
216-
217-
# ── KEYBOARD & MOUSE ─────────────────────────────────────────────────────
218-
# build_keyboard() returns the PTB Keyboard (muteOutsidePsychopy disabled so
219-
# keys register without the window holding OS focus). MID timing requires PTB.
220-
if KEYBOARD_BACKEND != "ptb":
221-
win.close()
222-
raise RuntimeError(
223-
f"Keyboard backend is '{KEYBOARD_BACKEND}', not 'ptb'. "
224-
"Install psychtoolbox: pip install psychtoolbox"
197+
with ExitStack() as stack:
198+
stack.callback(win.close)
199+
stack.callback(logging.flush)
200+
201+
behavioral_writer = recording.BehavioralCsvWriter(run_dir / f"behavioral_{file_stem}.csv")
202+
stack.callback(behavioral_writer.close)
203+
target_timing_writer = recording.TargetTimingCsvWriter(run_dir / f"target_timing_{file_stem}.csv")
204+
stack.callback(target_timing_writer.close)
205+
scan_log_writer = recording.ScanLogWriter(run_dir / f"scan_log_{file_stem}.csv")
206+
stack.callback(scan_log_writer.close)
207+
legacy_dir = data_dir / "legacy-fmt"
208+
legacy_dir.mkdir(parents=True, exist_ok=True)
209+
# MATLAB PartialParseData.m numbers trials continuously across blocks: block 1
210+
# is trials 1-42, so block 2 continues from 43. Our trial_n restarts at 1 each
211+
# run, so shift run 2 up by block 1's length (42) to restore that numbering.
212+
legacy_trial_offset = 42 if session_info.run_n == "2" else 0
213+
legacy_writer = recording.LegacyMidCsvWriter(
214+
legacy_dir / f"{session_info.legacy_name}_b{session_info.run_n}.csv",
215+
trial_offset=legacy_trial_offset,
216+
)
217+
stack.callback(legacy_writer.close)
218+
recording.write_manifest(
219+
run_dir=run_dir,
220+
session_info=session_info,
221+
session_started_at=session_started_at,
222+
frame_rate=frame_rate,
223+
n_trials=n_trials,
224+
screen_diag=screen_diag,
225+
frame_dur_s=frame_dur_s,
226+
frame_dur_source=fps_source,
227+
win_res=win_res,
228+
priority_raised=(priority_status == "HIGH_PRIORITY_CLASS"),
225229
)
226-
kb = build_keyboard()
227-
win.mouseVisible = False
228-
229-
# ── INSTRUCTIONS ─────────────────────────────────────────────────────────
230-
if session_info.show_instructions:
231-
instructions.display_instructions(win, stimuli_obj, kb, rcon)
232-
233-
# ── PULSE COUNTER ────────────────────────────────────────────────────────
234-
backend = scanner.make_backend(session_info.fmri)
235-
backend_name = "hardware (MCC DAQ)" if isinstance(backend, scanner.HardwareBackend) else "emulated"
236-
rcon.print(f"[bold]Scanner backend:[/bold] {backend_name}")
237-
logging.exp(f"Scanner backend: {backend_name}")
238-
pulse_counter = scanner.PulseCounter(backend)
239-
240-
# ── WAIT FOR SCAN START ──────────────────────────────────────────────────
241-
stimuli_obj.wait.draw()
242-
win.flip()
243-
244-
if session_info.fmri:
245-
rcon.print("[bold yellow]Waiting for first TR pulse...[/bold yellow]")
246-
logging.exp("Waiting for first TR pulse")
247-
pulse_counter.wait_for_start()
248-
else:
249-
start_key = config.START_KEYS[0]
250-
rcon.print(f"[bold yellow]Press '{start_key}' to start the experiment...[/bold yellow]")
251-
logging.exp(f"Waiting for '{start_key}' key to start experiment")
252-
wait_for_keys(kb, config.START_KEYS)
253-
backend.start()
254-
rcon.print("[bold green]Scan started[/bold green] — global clock reset")
255-
logging.exp("Scan started — global clock reset")
256-
257-
# ── GLOBAL CLOCK & INITIAL FIXATION ──────────────────────────────────────
258-
global_clock = core.Clock()
259-
global_clock.reset()
260-
261-
is_practice = session_info.run_n == "practice"
262-
leadin_s = config.PRACTICE_INITIAL_FIX_DUR_S if is_practice else config.INITIAL_FIX_DUR_S
263-
leadout_s = config.PRACTICE_CLOSING_FIX_DUR_S if is_practice else config.CLOSING_FIX_DUR_S
264-
265-
t_fix_end = leadin_s
266-
while global_clock.getTime() < t_fix_end:
267-
stimuli_obj.fix_o.draw()
268-
win.flip()
269-
if get_keys(kb, config.OVERLAY_TOGGLE_KEYS):
270-
debug_overlay.toggle()
271-
272-
nominal_time = global_clock.getTime()
273-
274-
# ── TRIAL LOOP ───────────────────────────────────────────────────────────
275-
pulse_ct = 0
276-
total_earned = 0
277-
n_hits = 0
278-
n_trials_done = 0
279-
280-
with TrialLiveView(rcon, n_trials) as view:
281-
for trial_idx, row in sequence.iterrows():
282-
trial_n = int(trial_idx) + 1
283-
n_iti = int(row["n_iti"])
284-
cue_lbl = config.cue_label(str(row["polarity"]), int(row["magnitude"]))
285-
286-
view.start_trial(trial_n, cue_lbl, n_hits, n_trials_done)
287-
288-
debug_overlay.state.trial_n = trial_n
289-
debug_overlay.state.n_hits = n_hits
290-
debug_overlay.state.n_trials_done = n_trials_done
291-
debug_overlay.state.total_earned = total_earned
292-
293-
rec, target_timing, scan_phases, nominal_time, total_earned = trial.run_trial(
294-
win=win,
295-
stimuli=stimuli_obj,
296-
kb=kb,
297-
global_clock=global_clock,
298-
row=row,
299-
trial_n=trial_n,
300-
n_trials=n_trials,
301-
n_iti_trs=n_iti,
302-
nominal_time=nominal_time,
303-
total_earned=total_earned,
304-
subject_id=session_info.subject_id,
305-
run_n=session_info.run_n,
306-
pulse_ct=pulse_ct,
307-
pulse_counter=pulse_counter,
308-
calibration=calibration,
309-
frame_dur_s=frame_dur_s,
310-
on_window=view.on_window,
311-
on_response=view.on_response,
312-
overlay=debug_overlay,
313-
)
314-
315-
if scan_phases:
316-
pulse_ct = scan_phases[-1].pulse_ct
317-
318-
n_trials_done += 1
319-
n_hits += rec.hit
320-
hit_rate = n_hits / n_trials_done * 100
321-
rt_str = f"{rec.rt_ms:.0f} ms" if rec.rt_ms != "" else "—"
322-
win_str = f"{rec.target_dur_ms_actual:.2f} ms" if rec.target_dur_ms_actual != "" else "—"
323-
result_label = "HIT" if rec.hit else ("early" if rec.early_press else "miss")
324230

325-
logging.exp(
326-
f" -> {result_label:<5} RT={rt_str:>6} win={win_str} "
327-
f"outcome={rec.reward_outcome:>4} total={f'${rec.total_earned}':>5} "
328-
f"hit_rate={hit_rate:3.0f}%"
231+
# ── KEYBOARD & MOUSE ─────────────────────────────────────────────────
232+
# build_keyboard() returns the PTB Keyboard (muteOutsidePsychopy disabled
233+
# so keys register without the window holding OS focus). MID requires PTB.
234+
if KEYBOARD_BACKEND != "ptb":
235+
raise RuntimeError(
236+
f"Keyboard backend is '{KEYBOARD_BACKEND}', not 'ptb'. "
237+
"Install psychtoolbox: pip install psychtoolbox"
329238
)
239+
kb = build_keyboard()
240+
win.mouseVisible = False
241+
242+
# ── INSTRUCTIONS ─────────────────────────────────────────────────────
243+
if session_info.show_instructions:
244+
instructions.display_instructions(win, stimuli_obj, kb, rcon)
245+
246+
# ── PULSE COUNTER ────────────────────────────────────────────────────
247+
backend = scanner.make_backend(session_info.fmri)
248+
backend_name = "hardware (MCC DAQ)" if isinstance(backend, scanner.HardwareBackend) else "emulated"
249+
rcon.print(f"[bold]Scanner backend:[/bold] {backend_name}")
250+
logging.exp(f"Scanner backend: {backend_name}")
251+
pulse_counter = scanner.PulseCounter(backend)
252+
253+
# ── WAIT FOR SCAN START ──────────────────────────────────────────────
254+
stimuli_obj.wait.draw()
255+
win.flip()
330256

331-
behavioral_writer.append(rec)
332-
target_timing_writer.append(target_timing)
333-
legacy_writer.append(rec)
334-
for sp in scan_phases:
335-
scan_log_writer.append(sp)
257+
if session_info.fmri:
258+
rcon.print("[bold yellow]Waiting for first TR pulse...[/bold yellow]")
259+
logging.exp("Waiting for first TR pulse")
260+
pulse_counter.wait_for_start()
261+
else:
262+
start_key = config.START_KEYS[0]
263+
rcon.print(f"[bold yellow]Press '{start_key}' to start the experiment...[/bold yellow]")
264+
logging.exp(f"Waiting for '{start_key}' key to start experiment")
265+
wait_for_keys(kb, config.START_KEYS)
266+
backend.start()
267+
rcon.print("[bold green]Scan started[/bold green] — global clock reset")
268+
logging.exp("Scan started — global clock reset")
269+
270+
# ── GLOBAL CLOCK & INITIAL FIXATION ──────────────────────────────────
271+
global_clock = core.Clock()
272+
global_clock.reset()
273+
274+
is_practice = session_info.run_n == "practice"
275+
leadin_s = config.PRACTICE_INITIAL_FIX_DUR_S if is_practice else config.INITIAL_FIX_DUR_S
276+
leadout_s = config.PRACTICE_CLOSING_FIX_DUR_S if is_practice else config.CLOSING_FIX_DUR_S
277+
278+
t_fix_end = leadin_s
279+
while global_clock.getTime() < t_fix_end:
280+
stimuli_obj.fix_o.draw()
281+
win.flip()
282+
if get_keys(kb, config.OVERLAY_TOGGLE_KEYS):
283+
debug_overlay.toggle()
284+
285+
nominal_time = global_clock.getTime()
286+
287+
# ── TRIAL LOOP ───────────────────────────────────────────────────────
288+
pulse_ct = 0
289+
total_earned = 0
290+
n_hits = 0
291+
n_trials_done = 0
292+
293+
with TrialLiveView(rcon, n_trials) as view:
294+
for trial_idx, row in sequence.iterrows():
295+
trial_n = int(trial_idx) + 1
296+
n_iti = int(row["n_iti"])
297+
cue_lbl = config.cue_label(str(row["polarity"]), int(row["magnitude"]))
298+
299+
view.start_trial(trial_n, cue_lbl, n_hits, n_trials_done)
300+
301+
debug_overlay.state.trial_n = trial_n
302+
debug_overlay.state.n_hits = n_hits
303+
debug_overlay.state.n_trials_done = n_trials_done
304+
debug_overlay.state.total_earned = total_earned
305+
306+
rec, target_timing, scan_phases, nominal_time, total_earned = trial.run_trial(
307+
win=win,
308+
stimuli=stimuli_obj,
309+
kb=kb,
310+
global_clock=global_clock,
311+
row=row,
312+
trial_n=trial_n,
313+
n_trials=n_trials,
314+
n_iti_trs=n_iti,
315+
nominal_time=nominal_time,
316+
total_earned=total_earned,
317+
subject_id=session_info.subject_id,
318+
run_n=session_info.run_n,
319+
pulse_ct=pulse_ct,
320+
pulse_counter=pulse_counter,
321+
calibration=calibration,
322+
frame_dur_s=frame_dur_s,
323+
on_window=view.on_window,
324+
on_response=view.on_response,
325+
overlay=debug_overlay,
326+
)
327+
328+
if scan_phases:
329+
pulse_ct = scan_phases[-1].pulse_ct
330+
331+
n_trials_done += 1
332+
n_hits += rec.hit
333+
hit_rate = n_hits / n_trials_done * 100
334+
rt_str = f"{rec.rt_ms:.0f} ms" if rec.rt_ms != "" else "—"
335+
win_str = f"{rec.target_dur_ms_actual:.2f} ms" if rec.target_dur_ms_actual != "" else "—"
336+
result_label = "HIT" if rec.hit else ("early" if rec.early_press else "miss")
337+
338+
logging.exp(
339+
f" -> {result_label:<5} RT={rt_str:>6} win={win_str} "
340+
f"outcome={rec.reward_outcome:>4} total={f'${rec.total_earned}':>5} "
341+
f"hit_rate={hit_rate:3.0f}%"
342+
)
343+
344+
behavioral_writer.append(rec)
345+
target_timing_writer.append(target_timing)
346+
legacy_writer.append(rec)
347+
for sp in scan_phases:
348+
scan_log_writer.append(sp)
349+
350+
rcon.print(
351+
f"\n[bold]Run complete:[/bold] {n_hits}/{n_trials_done} hits "
352+
f"([cyan]{n_hits / n_trials_done * 100:.0f}%[/cyan]) "
353+
f"total earned: [bold cyan]${total_earned}[/bold cyan]"
354+
)
355+
logging.exp(
356+
f"Run complete: {n_hits}/{n_trials_done} hits "
357+
f"({n_hits / n_trials_done * 100:.0f}%) total earned: ${total_earned}"
358+
)
336359

337-
rcon.print(
338-
f"\n[bold]Run complete:[/bold] {n_hits}/{n_trials_done} hits "
339-
f"([cyan]{n_hits / n_trials_done * 100:.0f}%[/cyan]) "
340-
f"total earned: [bold cyan]${total_earned}[/bold cyan]"
341-
)
342-
logging.exp(
343-
f"Run complete: {n_hits}/{n_trials_done} hits "
344-
f"({n_hits / n_trials_done * 100:.0f}%) total earned: ${total_earned}"
345-
)
360+
# ── CLOSING FIXATION ─────────────────────────────────────────────────
361+
t_close_start = global_clock.getTime()
362+
while global_clock.getTime() < t_close_start + leadout_s:
363+
stimuli_obj.fix_o.draw()
364+
win.flip()
365+
if get_keys(kb, config.OVERLAY_TOGGLE_KEYS):
366+
debug_overlay.toggle()
346367

347-
# ── CLOSING FIXATION ─────────────────────────────────────────────────────
348-
t_close_start = global_clock.getTime()
349-
while global_clock.getTime() < t_close_start + leadout_s:
350-
stimuli_obj.fix_o.draw()
368+
# ── END SCREEN ───────────────────────────────────────────────────────
369+
stimuli_obj.end.draw()
351370
win.flip()
352-
if get_keys(kb, config.OVERLAY_TOGGLE_KEYS):
353-
debug_overlay.toggle()
354-
355-
# ── END SCREEN ───────────────────────────────────────────────────────────
356-
stimuli_obj.end.draw()
357-
win.flip()
358-
end_key = config.END_KEYS[0]
359-
rcon.print(f"[bold yellow]Press '{end_key}' to exit the experiment...[/bold yellow]")
360-
logging.exp(f"Waiting for '{end_key}' key to exit experiment")
361-
wait_for_keys(kb, config.END_KEYS)
362-
363-
# ── CLEANUP ──────────────────────────────────────────────────────────────
364-
behavioral_writer.close()
365-
target_timing_writer.close()
366-
scan_log_writer.close()
367-
legacy_writer.close()
368-
logging.flush()
369-
win.close()
371+
end_key = config.END_KEYS[0]
372+
rcon.print(f"[bold yellow]Press '{end_key}' to exit the experiment...[/bold yellow]")
373+
logging.exp(f"Waiting for '{end_key}' key to exit experiment")
374+
wait_for_keys(kb, config.END_KEYS)
375+
370376
core.quit()
371377

372378

0 commit comments

Comments
 (0)