Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ jobs:
exit 1
fi

# Guard against a stale lockfile: uv.lock must agree with pyproject.toml.
# `uv lock --check` fails if the lock is out of date (e.g. the version was
# bumped in pyproject.toml but uv.lock was never re-locked to match).
- name: Verify uv.lock is in sync with pyproject.toml
uses: astral-sh/setup-uv@v5
- name: uv lock --check
run: uv lock --check

# Pull the notes for this tag out of CHANGELOG.md. We match the heading
# whose version equals the tag without the leading "v" (so tag v1.2.3 maps
# to a "## [1.2.3]" / "## 1.2.3" heading) and emit everything up to the
Expand Down
40 changes: 27 additions & 13 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,31 @@ rather than from the lockfile.
mid-task-deterministic/
├── src/
│ └── mid_det/
│ ├── __init__.py # Version
│ ├── __main__.py # Entry point; wires all modules together
│ ├── config.py # All task constants (no cross-module imports)
│ ├── display.py # PsychoPy stimuli construction and draw helpers
│ ├── recorder.py # TrialRecord, ScanPhase, CSV writers, manifest
│ ├── scanner.py # HardwareBackend, EmulatedBackend, PulseCounter
│ ├── session.py # Startup dialog, screen setup, sequence loading
│ └── trial.py # Per-phase functions and run_trial()
│ ├── __init__.py # Version
│ ├── __main__.py # Entry point; wires all modules together
│ ├── _psychopy.py # PsychoPy import shim for headless testing
│ ├── config.py # All task constants (no cross-module imports)
│ ├── task/ # The experiment run + on-screen presentation
│ │ ├── trial.py # run_trial(); ties the phases together
│ │ ├── phases.py # Fixed-duration per-phase display loops
│ │ ├── response.py # Timing-critical response window
│ │ ├── flip_timer.py # FlipTimer per-flip target-display diagnostics
│ │ ├── calibration.py # Per-cue adaptive target-window staircase
│ │ ├── instructions.py # Self-paced instruction presentation
│ │ ├── display.py # PsychoPy stimuli construction and draw helpers
│ │ ├── console.py # Rich live-view trial table
│ │ └── debug.py # F3-toggleable debug overlay HUD
│ ├── io/ # Input/output boundary
│ │ ├── bootstrap.py # SessionInfo/ScreenDiagnostics, screen setup, run dir
│ │ ├── setup_wizard.py # Interactive terminal setup wizard
│ │ ├── scanner.py # HardwareBackend, EmulatedBackend, PulseCounter
│ │ ├── sequences.py # Sequence CSV loading and validation
│ │ └── recording/ # Data recording
│ │ ├── records.py # TrialRecord/TargetTimingRecord/ScanPhase + schemas
│ │ ├── csv_writers.py # CsvWriter + behavioral/target-timing/scan-log writers
│ │ ├── legacy.py # LegacyMidCsvWriter + MATLAB-format helpers
│ │ └── manifest.py # write_manifest / write_ratings_manifest
│ └── ratings/ # Standalone cue-ratings survey (mid-ratings-det)
├── sequences/
│ ├── run_1.csv # 54-trial sequence for run 1
│ ├── run_2.csv # 54-trial sequence for run 2
Expand All @@ -92,11 +109,8 @@ mid-task-deterministic/
| Module | Responsibility |
|--------|---------------|
| `config.py` | Single source of truth for all timing, keyboard, scanner, and target-duration constants |
| `session.py` | Startup GUI dialog, screen/monitor setup, sequence CSV loading, instruction display |
| `display.py` | Build all PsychoPy `Visual` objects; draw helpers for each phase (circle/square cue with magnitude line) |
| `scanner.py` | Abstract scanner backend; `HardwareBackend` (MCC DAQ) and `EmulatedBackend` (software clock) |
| `trial.py` | `run_trial()` and per-phase functions (`run_cue`, `run_fixation`, `run_response`, `run_outcome`, `run_iti`) |
| `recorder.py` | `TrialRecord` and `ScanPhase` dataclasses; CSV writers; `write_manifest()` |
| `task/` | The experiment run: `trial.run_trial()`, per-phase loops (`phases.py`), the timing-critical `response.py` + `flip_timer.py`, the adaptive `calibration.py`, on-screen `display.py`/`instructions.py`, and operator UI (`console.py`, `debug.py`) |
| `io/` | The I/O boundary: session `bootstrap.py` (screen setup, run dir, `SessionInfo`/`ScreenDiagnostics`), the terminal `setup_wizard.py`, `scanner.py` hardware, `sequences.py` loading, and the `recording/` package (records, CSV writers, legacy MATLAB format, manifests) |
| `__main__.py` | Orchestration: init → instructions → wait for scan → trial loop → cleanup |

## Relationship to `mid-task`
Expand Down
2 changes: 1 addition & 1 deletion docs/timing.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ should_remove = (

`frame_dur_s` comes from `win.getActualFrameRate()`. If PsychoPy can't get a stable measurement (returns `None`, or a value outside 30–200 Hz), `__main__.run()` raises `RuntimeError` rather than guessing — a wrong frame period silently corrupts every target duration. The user can override with `--fps <hz>` if they know the refresh rate but VSYNC measurement is broken (e.g. macOS dev rigs, where the Cocoa compositor doesn't honor `set_vsync(True)`).

`session.py:setup_screen` passes `waitBlanking=True` and calls `winHandle.set_vsync(True)` so production Windows rigs flip on VSYNC.
`io/bootstrap.py:setup_screen` passes `waitBlanking=True` and calls `winHandle.set_vsync(True)` so production Windows rigs flip on VSYNC.

### macOS caveat

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ dependencies = [
"pandas>=2.0",
"rich>=14.3.3",
"questionary>=2.0",
"prompt_toolkit>=3.0",
"mcculw>=1.0.0",
"pyobjc-framework-quartz>=10; sys_platform == 'darwin'",
]
Expand Down
45 changes: 27 additions & 18 deletions src/mid_det/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@
from psychopy.hardware import keyboard
from rich.console import Console

from mid_det import config, display, recorder, scanner, sequences, session, setup_wizard, trial
from mid_det.calibration import CalibrationState
from mid_det.console import TrialLiveView
from mid_det.debug import DebugOverlay, DebugState
from mid_det import config
from mid_det.task import display, instructions, trial
from mid_det.io import bootstrap, recording, scanner, sequences, setup_wizard
from mid_det.task.calibration import CalibrationState
from mid_det.task.console import TrialLiveView
from mid_det.task.debug import DebugOverlay, DebugState


def _raise_process_priority() -> str | None:
Expand Down Expand Up @@ -58,18 +60,13 @@ def run() -> None:
# ── SCREEN & FRAME RATE ──────────────────────────────────────────────────
# Open the window first so we have a real frame duration to pass into the
# setup wizard (it uses it for RT-field defaults and frame-alignment hints).
win_res, win, screen_diag = session.setup_screen()

# Warm-up flips before frame-rate measurement: PsychoPy's detectingFrameDrops
# doc notes drops are common during startup as the GPU/driver settle.
for _ in range(30):
win.flip()
win_res, win, screen_diag = bootstrap.setup_screen()

if args.fps is not None:
frame_rate: float = args.fps
frame_dur_s: float = 1.0 / args.fps
fps_source = "specified"
elif 1000.0 / 200.0 <= screen_diag.calib_median_ms <= 1000.0 / 30.0:
elif 1000.0 / config.MAX_REFRESH_HZ <= screen_diag.calib_median_ms <= 1000.0 / config.MIN_REFRESH_HZ:
# Prefer the 120-flip VSYNC-calibration median: it's a more reliable
# estimator than getActualFrameRate(), and the response loop's
# `round(t / frame_dur_s)` termination is sensitive to small drift in
Expand All @@ -83,7 +80,7 @@ def run() -> None:
# than silently degrading — a guessed rate would corrupt every target
# duration. Use --fps to override.
measured_fps = win.getActualFrameRate()
if measured_fps is None or not (30.0 <= measured_fps <= 200.0):
if measured_fps is None or not (config.MIN_REFRESH_HZ <= measured_fps <= config.MAX_REFRESH_HZ):
win.close()
raise RuntimeError(
f"Could not measure a stable refresh rate "
Expand All @@ -101,7 +98,7 @@ def run() -> None:

# ── LOGGING ──────────────────────────────────────────────────────────────
data_dir = Path("data")
run_dir = session.make_run_dir(data_dir, session_info, session_time)
run_dir = bootstrap.make_run_dir(data_dir, session_info, session_time)
logging.LogFile(str(run_dir / "experiment.log"), level=logging.EXP)
logging.console.setLevel(logging.WARNING)

Expand Down Expand Up @@ -179,10 +176,20 @@ def _flip_with_overlay(*args, **kwargs): # noqa: E306

# ── SETUP OUTPUT FILES ───────────────────────────────────────────────────
file_stem = f"{session_info.subject_id}_run{session_info.run_n}"
behavioral_writer = recorder.BehavioralCsvWriter(run_dir / f"behavioral_{file_stem}.csv")
target_timing_writer = recorder.TargetTimingCsvWriter(run_dir / f"target_timing_{file_stem}.csv")
scan_log_writer = recorder.ScanLogWriter(run_dir / f"scan_log_{file_stem}.csv")
recorder.write_manifest(
behavioral_writer = recording.BehavioralCsvWriter(run_dir / f"behavioral_{file_stem}.csv")
target_timing_writer = recording.TargetTimingCsvWriter(run_dir / f"target_timing_{file_stem}.csv")
scan_log_writer = recording.ScanLogWriter(run_dir / f"scan_log_{file_stem}.csv")
legacy_dir = data_dir / "legacy-fmt"
legacy_dir.mkdir(parents=True, exist_ok=True)
# MATLAB PartialParseData.m numbers trials continuously across blocks: block 1
# is trials 1-42, so block 2 continues from 43. Our trial_n restarts at 1 each
# run, so shift run 2 up by block 1's length (42) to restore that numbering.
legacy_trial_offset = 42 if session_info.run_n == "2" else 0
legacy_writer = recording.LegacyMidCsvWriter(
legacy_dir / f"{session_info.legacy_name}_b{session_info.run_n}.csv",
trial_offset=legacy_trial_offset,
)
recording.write_manifest(
run_dir=run_dir,
session_info=session_info,
session_time=session_time,
Expand Down Expand Up @@ -212,7 +219,7 @@ def _flip_with_overlay(*args, **kwargs): # noqa: E306

# ── INSTRUCTIONS ─────────────────────────────────────────────────────────
if session_info.show_instructions:
session.display_instructions(win, stimuli_obj, session_info, kb, rcon)
instructions.display_instructions(win, stimuli_obj, session_info, kb, rcon)

# ── PULSE COUNTER ────────────────────────────────────────────────────────
backend = scanner.make_backend(session_info.fmri)
Expand Down Expand Up @@ -314,6 +321,7 @@ def _flip_with_overlay(*args, **kwargs): # noqa: E306

behavioral_writer.append(rec)
target_timing_writer.append(target_timing)
legacy_writer.append(rec)
for sp in scan_phases:
scan_log_writer.append(sp)

Expand Down Expand Up @@ -346,6 +354,7 @@ def _flip_with_overlay(*args, **kwargs): # noqa: E306
behavioral_writer.close()
target_timing_writer.close()
scan_log_writer.close()
legacy_writer.close()
logging.flush()
win.close()
core.quit()
Expand Down
23 changes: 23 additions & 0 deletions src/mid_det/_psychopy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""
Shared PsychoPy import shim.

Keeps the per-phase, response, and orchestration modules importable in
headless/CI environments without PsychoPy, so the pure-logic and timing code
stays testable. `core` is a namespace with the attributes those paths reference
— tests patch core.Clock; real runs always have PsychoPy.
"""
from __future__ import annotations

try:
from psychopy import core, logging, visual
from psychopy.hardware import keyboard
except ModuleNotFoundError:
import types

visual = keyboard = None # type: ignore[assignment]
logging = types.SimpleNamespace(exp=lambda *a, **k: None) # type: ignore[assignment]
core = types.SimpleNamespace( # type: ignore[assignment]
Clock=None, CountdownTimer=None, quit=lambda *a, **k: None
)

__all__ = ["core", "logging", "visual", "keyboard"]
13 changes: 13 additions & 0 deletions src/mid_det/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@
"iti": 2.0,
}

# Nominal duration of the four fixed slides (cue+fixation+response+outcome) that
# precede the ITI. Used as the drift baseline, mirroring MATLAB main.m's hardcoded
# `- 8.0` (see trial.py timing_drift_ms).
PRE_ITI_NOMINAL_S: float = sum(
STUDY_TIMES_S[k] for k in ("cue", "fixation", "response", "outcome")
)

# Polarity → shape (and reward sign). "polarity" is the gain/loss dimension;
# kept distinct from the affective "valence" rated in the cue-ratings survey.
POLARITIES: list[str] = ["gain", "loss"]
Expand Down Expand Up @@ -57,6 +64,12 @@
JITTER_MIN_S: float = 0.25
JITTER_MAX_S: float = 1.0

# Plausible display refresh rates. Used to sanity-check measured/calibrated
# rates before we trust them for timing — anything outside this band is treated
# as a failed measurement rather than a real refresh rate.
MIN_REFRESH_HZ: float = 30.0
MAX_REFRESH_HZ: float = 200.0

# Scanner settings
SCANNER_PULSE_RATE: int = 46
BOARD_NUM: int = 0
Expand Down
2 changes: 2 additions & 0 deletions src/mid_det/io/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
"""The input/output boundary: session bootstrap, the terminal setup wizard,
scanner hardware, sequence loading, and data recording."""
77 changes: 12 additions & 65 deletions src/mid_det/session.py → src/mid_det/io/bootstrap.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""
Session initialisation: dialog, screen setup, output directory, and instruction
display.
Session bootstrap: the SessionInfo / ScreenDiagnostics dataclasses, screen +
frame-timing setup, and output-directory creation. Instruction presentation
lives in mid_det.task.instructions.
"""
from __future__ import annotations

Expand All @@ -13,15 +14,9 @@

import pyglet
from psychopy import core, monitors, visual
from psychopy.hardware import keyboard
from rich.console import Console

from mid_det import config

_PACKAGE_DIR = Path(__file__).parent # src/mid_det/
_PROJECT_ROOT = _PACKAGE_DIR.parent.parent # project root
_TEXT_DIR = _PROJECT_ROOT / "text"


@dataclass
class SessionInfo:
Expand All @@ -31,6 +26,7 @@ class SessionInfo:
show_instructions: bool
base_rt_s: float
rt_change_s: float = config.RT_CHANGE_S # staircase step; set by wizard
legacy_name: str = "" # NAME for legacy-fmt/{NAME}_b{run}.csv


@dataclass
Expand Down Expand Up @@ -82,7 +78,12 @@ def setup_screen() -> tuple[list[int], visual.Window, ScreenDiagnostics]:
# percentile is well above one frame period, vsync is not actually blocking
# — typical on Windows under DWM composition or borderless fullscreen.
intervals_ms: list[float] = []
win.flip() # warm-up; first interval after a stale context can be misleading
# Warm-up flips before measurement: PsychoPy's detectingFrameDrops doc notes
# drops are common during startup as the GPU/driver/compositor settle. Run
# these before the calibration loop so the median feeding frame_dur_s is
# measured on a settled context, not a cold one.
for _ in range(30):
win.flip()
last_t = core.getTime()
for _ in range(120):
win.flip()
Expand All @@ -94,8 +95,8 @@ def setup_screen() -> tuple[list[int], visual.Window, ScreenDiagnostics]:
p99 = intervals_ms[int(0.99 * len(intervals_ms)) - 1]
mx = intervals_ms[-1]

# Enable PsychoPy's frame interval recording so trial.run_response can read
# win.nDroppedFrames and isolate on-screen drops from measurement artefacts.
# Enable PsychoPy's frame interval recording so response.run_response can read
# win.nDroppedFrames and isolate on-screen drops from measurement artifacts.
win.refreshThreshold = (median / 1000.0) * 1.5
win.recordFrameIntervals = True

Expand All @@ -119,57 +120,3 @@ def make_run_dir(data_dir: Path, session_info: SessionInfo, session_time: dateti
run_dir = data_dir / f"{session_info.subject_id}_run{session_info.run_n}_{ts}"
run_dir.mkdir(parents=True, exist_ok=True)
return run_dir


def display_instructions(
win: visual.Window,
stimuli, # Stimuli dataclass from display.py; avoid circular import
session_info: SessionInfo,
kb: keyboard.Keyboard,
rcon: Console,
) -> None:
"""Display instructions from text/instructions_MID.txt one page at a time."""
keys_map = config.KEYS_FMRI if session_info.fmri else config.KEYS_BEHAVIORAL
forward_key = keys_map["forward"]
start_key = keys_map["start"]
end_key = keys_map["end"]

inst_path = _TEXT_DIR / "instructions_MID.txt"
pages: list[str] = []
with open(inst_path) as f:
for line in f:
stripped = line.rstrip()
if stripped:
pages.append(stripped)

if not pages:
return

kb.clearEvents()
page_idx = 0

while True:
stimuli.instr_prompt.text = pages[page_idx]
stimuli.instr_prompt.draw()
stimuli.instr_first.draw()
win.flip()

pressed = kb.getKeys(keyList=[forward_key, end_key], waitRelease=False)
if not pressed:
continue
key_name = pressed[0].name
if key_name == end_key:
core.quit()
elif key_name == forward_key:
page_idx += 1
if page_idx >= len(pages):
break

rcon.print(
f"[bold yellow]End of instructions — press '{start_key}' to continue...[/bold yellow]"
)
while True:
stimuli.instr_finish.draw()
win.flip()
if kb.getKeys(keyList=[start_key], waitRelease=False):
break
Loading
Loading