|
5 | 5 | from __future__ import annotations |
6 | 6 |
|
7 | 7 | import platform |
| 8 | +from contextlib import ExitStack |
8 | 9 | from datetime import datetime |
9 | 10 | from pathlib import Path |
10 | 11 |
|
@@ -186,187 +187,192 @@ def _flip_with_overlay(*args, **kwargs): # noqa: E306 |
186 | 187 | f"win-ratio threshold={config.WIN_RATIO_THRESHOLD}" |
187 | 188 | ) |
188 | 189 |
|
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. |
190 | 196 | 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"), |
225 | 229 | ) |
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") |
324 | 230 |
|
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" |
329 | 238 | ) |
| 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() |
330 | 256 |
|
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 | + ) |
336 | 359 |
|
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() |
346 | 367 |
|
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() |
351 | 370 | 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 | + |
370 | 376 | core.quit() |
371 | 377 |
|
372 | 378 |
|
|
0 commit comments