Skip to content

Commit 3e0d2a1

Browse files
committed
Merge PR 18
2 parents 35355a3 + ddd79ac commit 3e0d2a1

10 files changed

Lines changed: 80 additions & 49 deletions

File tree

psyflow/BlockUnit.py

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
1+
"""Block-level trial controller.
2+
3+
Manages condition generation (weighted, balanced, or custom), trial execution
4+
with lifecycle hooks, and per-block result aggregation.
5+
"""
6+
17
import numpy as np
2-
from typing import Callable, Any, List, Dict, Optional
8+
from typing import Callable, Any, List, Dict, Optional, overload
39
from psychopy import core, logging
410
from typing import Union, List, Dict, Literal
511
import re
@@ -198,7 +204,15 @@ def add_condition(self, condition_list: List[Any]) -> "BlockUnit":
198204
self.conditions = condition_list
199205
return self
200206

201-
def on_start(self, func: Optional[Callable[['BlockUnit'], None]] = None):
207+
@overload
208+
def on_start(self, func: None = None) -> Callable[[Callable[['BlockUnit'], None]], 'BlockUnit']:
209+
...
210+
211+
@overload
212+
def on_start(self, func: Callable[['BlockUnit'], None]) -> 'BlockUnit':
213+
...
214+
215+
def on_start(self, func: Optional[Callable[['BlockUnit'], None]] = None) -> Union['BlockUnit', Callable[[Callable[['BlockUnit'], None]], 'BlockUnit']]:
202216
"""
203217
Register a function to run at the start of the block.
204218
@@ -215,7 +229,15 @@ def decorator(f):
215229
self._on_start.append(func)
216230
return self
217231

218-
def on_end(self, func: Optional[Callable[['BlockUnit'], None]] = None):
232+
@overload
233+
def on_end(self, func: None = None) -> Callable[[Callable[['BlockUnit'], None]], 'BlockUnit']:
234+
...
235+
236+
@overload
237+
def on_end(self, func: Callable[['BlockUnit'], None]) -> 'BlockUnit':
238+
...
239+
240+
def on_end(self, func: Optional[Callable[['BlockUnit'], None]] = None) -> Union['BlockUnit', Callable[[Callable[['BlockUnit'], None]], 'BlockUnit']]:
219241
"""
220242
Register a function to run at the end of the block.
221243
@@ -232,7 +254,7 @@ def decorator(f):
232254
self._on_end.append(func)
233255
return self
234256

235-
def run_trial(self, func: Callable, **kwargs):
257+
def run_trial(self, func: Callable, **kwargs) -> "BlockUnit":
236258
"""
237259
Run all trials using a specified trial function.
238260
@@ -377,7 +399,7 @@ def match(value: str) -> bool:
377399
if negate ^ match(str(trial.get(key, '')))
378400
]
379401

380-
def logging_block_info(self):
402+
def logging_block_info(self) -> None:
381403
"""
382404
Log block metadata including ID, index, seed, trial count, and condition distribution.
383405
"""

psyflow/StimBank.py

Lines changed: 20 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
"""Stimulus registry with lazy instantiation.
2+
3+
Supports decorator-based and YAML/dict-based stimulus definitions, batch
4+
preview, text formatting, and text-to-speech conversion via edge-tts.
5+
"""
6+
17
from psychopy.visual import TextStim, Circle, Rect, Polygon, ImageStim, ShapeStim, TextBox2, MovieStim
28
from psychopy import event, core
39

@@ -72,7 +78,7 @@ def decorator(func: Callable[[Any], Any]):
7278
return func
7379
return decorator
7480

75-
def preload_all(self):
81+
def preload_all(self) -> "StimBank":
7682
"""Instantiate all registered stimuli.
7783
7884
Returns
@@ -214,7 +220,7 @@ def get_selected(self, keys: list[str]) -> Dict[str, Any]:
214220
"""
215221
return {k: self.get(k) for k in keys}
216222

217-
def preview_all(self, wait_keys: bool = True):
223+
def preview_all(self, wait_keys: bool = True) -> None:
218224
"""
219225
Preview all registered stimuli one by one.
220226
@@ -227,7 +233,7 @@ def preview_all(self, wait_keys: bool = True):
227233
for i, name in enumerate(keys):
228234
self._preview(name, wait_keys=wait_keys)
229235

230-
def preview_group(self, prefix: str, wait_keys: bool = True):
236+
def preview_group(self, prefix: str, wait_keys: bool = True) -> None:
231237
"""
232238
Preview all stimuli that match a name prefix.
233239
@@ -244,7 +250,7 @@ def preview_group(self, prefix: str, wait_keys: bool = True):
244250
for i, name in enumerate(matches):
245251
self._preview(name, wait_keys=(i == len(matches) - 1))
246252

247-
def preview_selected(self, keys: list[str], wait_keys: bool = True):
253+
def preview_selected(self, keys: list[str], wait_keys: bool = True) -> None:
248254
"""
249255
Preview selected stimuli by name.
250256
@@ -258,29 +264,7 @@ def preview_selected(self, keys: list[str], wait_keys: bool = True):
258264
for i, name in enumerate(keys):
259265
self._preview(name, wait_keys=(i == len(keys) - 1))
260266

261-
# def _preview(self, name: str, wait_keys: bool = True):
262-
# """
263-
# Internal utility to preview a single stimulus.
264-
265-
# Parameters
266-
# ----------
267-
# name : str
268-
# Stimulus name.
269-
# wait_keys : bool
270-
# Wait for key press after preview.
271-
# """
272-
# try:
273-
# stim = self.get(name)
274-
# self.win.flip(clearBuffer=True)
275-
# stim.draw()
276-
# self.win.flip()
277-
# print(f"Preview: '{name}'")
278-
# if wait_keys:
279-
# event.waitKeys()
280-
# except Exception as e:
281-
# print(f"[Preview Error] Could not preview '{name}': {e}")
282-
283-
def _preview(self, name: str, wait_keys: bool = True):
267+
def _preview(self, name: str, wait_keys: bool = True) -> None:
284268
"""
285269
Internal utility to preview a single stimulus (image or sound).
286270
@@ -337,7 +321,7 @@ def has(self, name: str) -> bool:
337321
"""
338322
return name in self._registry
339323

340-
def describe(self, name: str):
324+
def describe(self, name: str) -> None:
341325
"""
342326
Print accepted arguments for a registered stimulus.
343327
@@ -370,7 +354,7 @@ def describe(self, name: str):
370354
default = "required" if v.default is inspect.Parameter.empty else f"default={v.default!r}"
371355
print(f" - {k}: {default}")
372356

373-
def export_to_yaml(self, path: str):
357+
def export_to_yaml(self, path: str) -> None:
374358
"""
375359
Export YAML-defined stimuli (but not decorator-defined) to file.
376360
@@ -382,6 +366,8 @@ def export_to_yaml(self, path: str):
382366
yaml_defs = {}
383367
for name, factory in self._registry.items():
384368
try:
369+
# Factories created by add_from_dict() capture their source
370+
# dict in a closure. Inspect it to recover the original spec.
385371
source = factory.__closure__[0].cell_contents
386372
if not isinstance(source, dict):
387373
continue
@@ -393,7 +379,7 @@ def export_to_yaml(self, path: str):
393379
yaml.dump(yaml_defs, f)
394380
print(f"[OK] Exported {len(yaml_defs)} YAML stimuli to {path}")
395381

396-
def make_factory(self, cls, base_kwargs: dict, name: str):
382+
def make_factory(self, cls: type, base_kwargs: dict, name: str) -> Callable:
397383
"""
398384
Create a factory function for a given stimulus class.
399385
@@ -426,7 +412,7 @@ def _factory(win, **override_kwargs):
426412
raise ValueError(f"[StimBank] Failed to build '{name}': {e}")
427413
return _factory
428414

429-
def add_from_dict(self, named_specs: Optional[dict] = None, **kwargs):
415+
def add_from_dict(self, named_specs: Optional[dict] = None, **kwargs) -> "StimBank":
430416
"""
431417
Add stimuli from a dictionary or keyword-based specifications.
432418
@@ -452,7 +438,7 @@ def add_from_dict(self, named_specs: Optional[dict] = None, **kwargs):
452438
self._registry[name] = self.make_factory(stim_class, kwargs, name)
453439
return self
454440

455-
def validate_dict(self, config: dict, strict: bool = False):
441+
def validate_dict(self, config: dict, strict: bool = False) -> None:
456442
"""
457443
Validate a dictionary of stimulus definitions.
458444
@@ -506,7 +492,7 @@ def validate_dict(self, config: dict, strict: bool = False):
506492
def convert_to_voice(self,
507493
keys: list[str] | str,
508494
overwrite: bool = False,
509-
voice: str = "zh-CN-YunyangNeural"):
495+
voice: str = "zh-CN-YunyangNeural") -> "StimBank":
510496
"""
511497
Convert specified TextStim/TextBox2 stimuli to speech (MP3) and register them
512498
as new Sound stimuli in this StimBank.
@@ -578,7 +564,7 @@ def add_voice(self,
578564
stim_label: str,
579565
text: str,
580566
overwrite: bool = False,
581-
voice: str = "zh-CN-XiaoxiaoNeural"):
567+
voice: str = "zh-CN-XiaoxiaoNeural") -> "StimBank":
582568
"""
583569
Convert arbitrary text to speech (MP3) and register it as a new Sound stimulus.
584570

psyflow/StimUnit.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
"""Trial-level stimulus executor.
2+
3+
Encapsulates stimulus presentation, response capture, event triggers,
4+
timing control, and lifecycle hooks. Adapts automatically to simulation
5+
mode via :class:`~psyflow.sim.adapter.ResponderAdapter`.
6+
"""
7+
18
from psychopy import core, visual, logging, sound
29
from psychopy.hardware.keyboard import Keyboard
310
from typing import Callable, Optional, List, Dict, Any, Sequence, TypeAlias, Union

psyflow/SubInfo.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
"""Participant information dialog.
2+
3+
Presents a configurable PsychoPy GUI dialog to collect and validate
4+
participant metadata (subject ID, demographics, etc.) with optional
5+
localization support.
6+
"""
7+
18
from psychopy import gui
29

310
class SubInfo:

psyflow/TaskSettings.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
"""Experiment configuration container.
2+
3+
Holds window display, block/trial structure, seeding strategy, and per-subject
4+
output paths. Instantiate directly or via :meth:`TaskSettings.from_dict` with
5+
a YAML-loaded dictionary.
6+
"""
7+
18
from dataclasses import dataclass, field
29
from typing import List, Optional, Any, Dict
310
from math import ceil
@@ -70,7 +77,7 @@ def __post_init__(self):
7077
if self.seed_mode == 'same_across_sub' and all(seed is None for seed in self.block_seed):
7178
self.set_block_seed(self.overall_seed)
7279

73-
def set_block_seed(self, seed_base: Optional[int]):
80+
def set_block_seed(self, seed_base: Optional[int]) -> None:
7481
"""
7582
Generate a list of per-block seeds from a base seed.
7683
@@ -142,7 +149,7 @@ def resolve_condition_weights(self) -> list[float] | None:
142149
raise ValueError(f"condition_weights sum must be > 0, got {weights}")
143150
return weights
144151

145-
def add_subinfo(self, subinfo: Dict[str, Any]):
152+
def add_subinfo(self, subinfo: Dict[str, Any]) -> None:
146153
"""
147154
Add subject-specific information and set seed/file names accordingly.
148155
@@ -187,14 +194,14 @@ def add_subinfo(self, subinfo: Dict[str, Any]):
187194
self.res_file = os.path.join(self.save_path, f"{basename}.csv")
188195
self.json_file = os.path.join(self.save_path, f"{basename}.json")
189196

190-
def __repr__(self):
197+
def __repr__(self) -> str:
191198
"""
192199
Return a clean string representation of the current TaskSettings.
193200
"""
194201
base = {k: v for k, v in self.__dict__.items() if not k.startswith('_')}
195202
return f"{self.__class__.__name__}({base})"
196203

197-
def save_to_json(self):
204+
def save_to_json(self) -> None:
198205
"""
199206
Save the current TaskSettings instance to a JSON file.
200207
"""
@@ -218,7 +225,7 @@ def save_to_json(self):
218225

219226

220227
@classmethod
221-
def from_dict(cls, config: dict):
228+
def from_dict(cls, config: dict) -> "TaskSettings":
222229
"""
223230
Create a TaskSettings instance from a flat dictionary.
224231

psyflow/utils/display.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from psychopy import core, visual
44

55

6-
def count_down(win, seconds=3, **stim_kwargs):
6+
def count_down(win: "visual.Window", seconds: int = 3, **stim_kwargs) -> None:
77
"""Display a frame-accurate countdown using TextStim."""
88
cd_clock = core.Clock()
99
for i in reversed(range(1, seconds + 1)):

psyflow/utils/ports.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Serial port helper utilities."""
22

33

4-
def show_ports():
4+
def show_ports() -> None:
55
"""List all available serial ports."""
66
import serial.tools.list_ports
77

psyflow/utils/templates.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import importlib.resources as pkg_res
55

66

7-
def taps(task_name: str, template: str = "cookiecutter-psyflow"):
7+
def taps(task_name: str, template: str = "cookiecutter-psyflow") -> str:
88
"""Generate a task skeleton using the bundled template."""
99
tmpl_dir = pkg_res.files("psyflow.templates") / template
1010
cookiecutter(

psyflow/utils/trials.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
"""Trial ID generation and deadline resolution utilities."""
2+
13
from typing import Any
24

35
_SESSION_TRIAL_COUNTER = 0
@@ -8,7 +10,7 @@ def next_trial_id() -> int:
810
_SESSION_TRIAL_COUNTER += 1
911
return _SESSION_TRIAL_COUNTER
1012

11-
def reset_trial_counter(start_at: int = 0):
13+
def reset_trial_counter(start_at: int = 0) -> None:
1214
"""Reset the global trial counter."""
1315
global _SESSION_TRIAL_COUNTER
1416
_SESSION_TRIAL_COUNTER = start_at

psyflow/utils/voices.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ async def _list_supported_voices_async(filter_lang: Optional[str] = None):
1717
def list_supported_voices(
1818
filter_lang: Optional[str] = None,
1919
human_readable: bool = False,
20-
):
20+
) -> list[dict] | None:
2121
"""Query available edge-tts voices."""
2222
voices = asyncio.run(_list_supported_voices_async(filter_lang))
2323
if not human_readable:

0 commit comments

Comments
 (0)