main_exp.py 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698
  1. #!/user/bin/env python
  2. # coding=utf-8
  3. """
  4. @author: Yannan Su
  5. @created at: 18.03.21 14:11
  6. A module for running the main experiment so called "color-order".
  7. Quick testing the experiment from bash:
  8. python main_exp.py --s test --c config/test_cfg.yaml --p config/co2x2_LH_test_a.yaml --r data --feedback True
  9. """
  10. import numpy as np
  11. import os
  12. import time
  13. from bisect import bisect_left
  14. from psychopy import monitors, visual, core, event, data, misc
  15. import argparse
  16. from pyiris.colorspace import ColorSpace
  17. import write_data
  18. from yml2dict import yml2dict
  19. class Exp:
  20. def __init__(self, subject, cfg_file, par_file, res_dir, feedback):
  21. """
  22. :param subject: subject, e.g. s00 - make sure you have colorspace files for s00
  23. :param cfg_file: config file path
  24. :param par_file: parameter file path
  25. :param res_dir: results directory
  26. :param feedback: if True, feedback of discrimination will be given
  27. """
  28. self.subject = subject
  29. if not res_dir:
  30. res_dir = 'data/'
  31. self.res_dir = res_dir
  32. self.cfg_file = cfg_file
  33. self.par_file = par_file
  34. self.feedback = feedback
  35. self.cfg = yml2dict(self.cfg_file)
  36. self.param = yml2dict(self.par_file)
  37. self.conditions = [dict({'stimulus': key}, **value)
  38. for key, value in self.param.items()
  39. if key.startswith('stimulus')]
  40. mon_settings = yml2dict(self.cfg['monitor_settings_file'])
  41. self.monitor = monitors.Monitor(name='ViewPixx Lite 2000A', distance=mon_settings['preferred_mode']['distance'])
  42. """
  43. # Only need to run and save the monitor setting once!!!
  44. # The saved setting json is stored in ~/.psychopy3/monitors and should have a backup in config/resources/
  45. self.monitor = monitors.Monitor(name=mon_settings['name'],
  46. distance=mon_settings['preferred_mode']['distance'])
  47. self.monitor.setSizePix((mon_settings['preferred_mode']['width'],
  48. mon_settings['preferred_mode']['height']))
  49. self.monitor.setWidth(mon_settings['size']['width']/10.)
  50. self.monitor.saveMon()
  51. """
  52. subject_path = self.cfg['subject_isolum_directory'] + '/' + 'colorspace_' + subject + '.json'
  53. self.bit_depth = self.cfg['depthBits']
  54. self.colorspace = ColorSpace(bit_depth=self.bit_depth,
  55. chromaticity=self.cfg['chromaticity'],
  56. calibration_path=self.cfg['calibration_file'],
  57. subject_path=subject_path)
  58. # self.gray = self.colorspace.lms_center # or use lms center
  59. self.gray = np.array([self.colorspace.gray_level, self.colorspace.gray_level, self.colorspace.gray_level])
  60. self.colorspace.create_color_list(hue_res=0.05,
  61. gray_level=self.colorspace.gray_level) # Make sure the reslution is fine enough - 0.05 should be good
  62. self.colorlist = self.colorspace.color_list[0.05]
  63. self.gray_pp = self.colorspace.color2pp(self.gray)[0]
  64. self.win = visual.window.Window(size=[mon_settings['preferred_mode']['width'],
  65. mon_settings['preferred_mode']['height']],
  66. monitor=self.monitor,
  67. units=self.cfg['window_units'],
  68. fullscr=True,
  69. colorSpace='rgb',
  70. color=self.gray_pp,
  71. mouseVisible=False)
  72. self.idx = time.strftime("%Y%m%dT%H%M", time.localtime()) # index as current date and time
  73. self.trial_dur = self.cfg['trial_dur']
  74. self.trial_nmb = self.cfg['trial_nmb']
  75. self.warmup_nmb = self.cfg['warmup_nmb']
  76. self.total_nmb = (self.trial_nmb + self.warmup_nmb) * len(self.conditions)
  77. self.text_height = self.cfg['text_height']
  78. def take_closest(self, arr, val):
  79. """
  80. Tool function:
  81. Assumes arr is sorted. Returns closest value to val (could be itself).
  82. If two numbers are equally close, return the smallest number.
  83. :param arr: sorted array
  84. :param val: desired value
  85. :return: [closest_val, closest_idx]
  86. """
  87. pos = bisect_left(arr, val)
  88. if pos == 0:
  89. return [arr[0], pos]
  90. if pos == len(arr):
  91. return [arr[-1], pos - 1]
  92. before = arr[pos - 1]
  93. after = arr[pos]
  94. if after - val < val - before:
  95. return [after, pos]
  96. else:
  97. return [before, pos - 1]
  98. def closest_hue(self, theta):
  99. """
  100. Tool function:
  101. Given a desired hue angle, to find the closest hue angle and the corresponding rgb value.
  102. :param theta: desired hue angle (in degree)
  103. :return: closest hue angle, closest rgb values
  104. """
  105. hue_angles = np.array(self.colorlist['hue_angles'])
  106. if theta < 0:
  107. theta += 360
  108. if theta >= 360:
  109. theta -= 360
  110. closest_theta, pos = np.array(self.take_closest(hue_angles, theta))
  111. closest_rgb = self.colorlist['rgb'][pos.astype(int)]
  112. closest_rgb = self.colorspace.color2pp(closest_rgb)[0]
  113. return np.round(closest_theta, 2), closest_rgb
  114. def get_disp_val(self, cond, rot):
  115. """
  116. Tool function:
  117. Check whether the stimuli are truly displayed in the given monitor resolution,
  118. and if not, the rotation given by a staircase should be corrected by realizable values.
  119. :param cond: a condition of a staircase [dictionary]
  120. :param rot: rotation angle [degree] relative to cond['standard'] given by a staircase
  121. :return: closest rotation angle [degree]
  122. """
  123. disp_standard, _ = self.closest_hue(cond['standard']) # actually displayed standard
  124. stair_test = cond['standard'] + rot # calculated test value
  125. if stair_test < 0:
  126. stair_test += 360
  127. disp_test, _ = self.closest_hue(stair_test) # actually displayed test value
  128. disp_intensity = disp_test - disp_standard # actually displayed intensity (i.e. difference)
  129. if disp_intensity > 300:
  130. disp_intensity = (disp_test + disp_standard) - 360
  131. return disp_intensity
  132. def create_bar(self, hue_range, hue_num):
  133. """
  134. Create a color bar stimulus covering a given hue angle range.
  135. :param hue_range: [min_hue_angle, max_hue_angle] in degree
  136. :param hue_num: the number of shown hues (default as 45; should be many to make the hues appear continuous)
  137. :return: a colorbar as a psychopy ImageStim stimulus
  138. """
  139. hue_calculated = np.linspace(hue_range[0], hue_range[1], hue_num)
  140. closest_theta, closest_rgb = zip(*[self.closest_hue(v) for v in hue_calculated])
  141. pos = [[x, self.cfg['bar_ypos']] for x
  142. in np.linspace(self.cfg['bar_xlim'][0], self.cfg['bar_xlim'][1], hue_num)]
  143. colorbar = visual.ElementArrayStim(win=self.win,
  144. fieldSize=self.cfg['bar_size'],
  145. xys=pos,
  146. nElements=hue_num,
  147. elementMask=None,
  148. elementTex=None,
  149. sizes=self.cfg['bar_size'][1])
  150. colorbar.colors = np.array(closest_rgb)
  151. return colorbar
  152. def patch_stim(self, xlim, ylim):
  153. """
  154. Set properties for standard and test stimuli.
  155. :param xlim: x-axis limit [x_1, x_2]
  156. :param ylim: y-axis limit [y_1, y_2]
  157. :return: an array of circular patches as a Psychopy ElementArrayStim stimulus
  158. """
  159. n = int(np.sqrt(self.cfg['patch_nmb']))
  160. pos = [[x, y]
  161. for x in np.linspace(xlim[0], xlim[1], n)
  162. for y in np.linspace(ylim[0], ylim[1], n)]
  163. patch = visual.ElementArrayStim(win=self.win,
  164. fieldSize=self.cfg['field_size'],
  165. xys=pos,
  166. nElements=self.cfg['patch_nmb'],
  167. elementMask='circle',
  168. elementTex=None,
  169. sizes=self.cfg['patch_size'])
  170. return patch
  171. def rand_color(self, theta, width, npatch):
  172. """
  173. Generate the hues of a stimulus array with noise.
  174. :param theta: the mean hue angle of all hues in the stimulus array
  175. :param width: the half width of a uniform distribution in the stimulus array
  176. :param npatch: the number of patches in the stimulus array
  177. :return:
  178. - angle: an array of npatch hue angles
  179. - rgb: an array of npatch rgb values
  180. """
  181. # Sample from uniform distribution with equivalent spaces
  182. noise = np.linspace(theta - width, theta + width, int(npatch), endpoint=True)
  183. # Shuffle the position of the array
  184. np.random.shuffle(noise)
  185. angle, rgb = zip(*[self.closest_hue(theta=n) for n in noise])
  186. return angle, np.array(rgb)
  187. def choose_con(self, noise, standard, test, width):
  188. """
  189. Generate stimuli color rgb values with the chosen noise condition.
  190. :param noise: noise condition as 'L-L', 'H-H', 'L-H', or 'H-L'
  191. :param standard: the (average) hue angle of the standard stimulus
  192. :param test: the (average) hue angle of the test stimulus
  193. :param width: the half width of a uniform distribution in the stimulus array
  194. :return:
  195. - sColor: an array of rgb values of the standard stimulus (shape as npatch x 1)
  196. - tColor: an array of rgb values of the test stimulus (shape as npatch x 1)
  197. """
  198. sColor = None
  199. tColor = None
  200. if noise == 'L-L': # low - low noise
  201. _, sColor = self.closest_hue(theta=standard)
  202. _, tColor = self.closest_hue(theta=test)
  203. elif noise == 'L-H': # low noise in standard, high noise in test
  204. _, sColor = self.closest_hue(theta=standard)
  205. _, tColor = self.rand_color(test, width, self.cfg['patch_nmb'])
  206. # elif noise == 'H-L': # high noise in standard, low noise in test
  207. # _, sColor = self.rand_color(test, width, self.cfg['patch_nmb'])
  208. # _, tColor = self.closest_hue(theta=test)
  209. elif noise == 'H-H': # high - high noise
  210. _, sColor = self.rand_color(standard, width, self.cfg['patch_nmb'])
  211. _, tColor = self.rand_color(test, width, self.cfg['patch_nmb'])
  212. else:
  213. print("No noise condition corresponds to the input!")
  214. return sColor, tColor
  215. def run_trial(self, rot, cond, patch_xlims, count, InitActivity, data_file):
  216. """
  217. Run a single trial.
  218. :param rot: rotation of hue angle relative to the standard stimulus [in degree]
  219. :param cond: stimulus and staircase condition of the this trial [dictionary]
  220. :param patch_xlims: xlim for defining standard and test patch positions [2x2 array]
  221. :param count: the count of trial
  222. :return:
  223. - judge: subject's response as 0 or 1
  224. - react_time: reaction time in sec
  225. - trial_time_start: the time stamp when a trial starts
  226. """
  227. ref = self.create_bar(cond['hue_range'], cond['hue_num'])
  228. sPatch_xlim, tPatch_xlim = patch_xlims
  229. sPatch = self.patch_stim(sPatch_xlim, self.cfg['standard_ylim'])
  230. tPatch = self.patch_stim(tPatch_xlim, self.cfg['test_ylim'])
  231. # Set colors of two stimuli
  232. standard = cond['standard'] # standard should be fixed
  233. test = standard + rot
  234. sPatch.colors, tPatch.colors = self.choose_con(cond['noise_condition'],
  235. standard,
  236. test,
  237. cond['width'])
  238. # Fixation cross & Number of trial
  239. fix = visual.TextStim(self.win,
  240. text="+",
  241. pos=[0, 0],
  242. height=0.6,
  243. color='black')
  244. num = visual.TextStim(self.win,
  245. text=f"trial {count} of {self.total_nmb} trials",
  246. pos=[0, -10],
  247. height=0.5,
  248. color='black')
  249. trial_time_start = time.time()
  250. # Present the standard and the test stimuli together with the reference
  251. fix.draw()
  252. num.draw()
  253. ref.draw()
  254. self.win.flip()
  255. core.wait(0.5)
  256. fix.draw()
  257. num.draw()
  258. ref.draw()
  259. sPatch.draw()
  260. tPatch.draw()
  261. self.win.flip()
  262. key_press = None
  263. judge = None
  264. react_time_stop = -1
  265. react_time_start = time.time()
  266. # Check whether the how the hue angle change from left stimulus to right stimulus:
  267. # - if increase, change_direction = up
  268. # - if decrease, change_direction = down
  269. # Notice that the corresponding changing direction of the reference bar is always "up", i.e. increasing.
  270. if (sum(sPatch_xlim) < 0 < rot) or (sum(sPatch_xlim) > 0 > rot):
  271. change_direction = 'up'
  272. else:
  273. change_direction = 'down'
  274. # Bonus!
  275. # Allow entering a pause mode by pressing 'p', either response or exit is possible in pause mode
  276. pre_start = time.time()
  277. mode_keys = event.waitKeys(maxWait=self.trial_dur)
  278. if mode_keys is not None:
  279. press_start = time.time()
  280. if 'p' in mode_keys:
  281. react_time_start = time.time()
  282. enter_text = visual.TextStim(self.win,
  283. text="Entered pause mode. Exit by pressing 'p'",
  284. pos=[0, -10],
  285. height=self.text_height,
  286. color='black')
  287. enter_text.draw()
  288. fix.draw()
  289. num.draw()
  290. ref.draw()
  291. sPatch.draw()
  292. tPatch.draw()
  293. self.win.flip()
  294. for wait_keys in event.waitKeys():
  295. if wait_keys == change_direction:
  296. judge = 1 # correct
  297. react_time_stop = time.time()
  298. elif (wait_keys == 'up' and change_direction == 'down') or \
  299. (wait_keys == 'down' and change_direction == 'up'):
  300. judge = 0 # incorrect
  301. react_time_stop = time.time()
  302. elif wait_keys == 'escape':
  303. InitActivity.write(self.cfg_file, self.par_file, data_file, status='escape')
  304. # write_data.WriteActivity(self.subject, self.idx, self.res_dir).stop(status='userbreak')
  305. core.quit()
  306. elif wait_keys == 'p':
  307. exit_text = visual.TextStim(self.win,
  308. text="Exit pause mode.",
  309. pos=[12, -16],
  310. height=self.text_height,
  311. color='black')
  312. exit_text.draw()
  313. fix.draw()
  314. num.draw()
  315. self.win.flip()
  316. else: # keep presenting if other keys are pressed by accident
  317. core.wait(self.trial_dur - (press_start - pre_start))
  318. # Refresh and show a colored checkerboard mask for 0.5 sec
  319. mask_dur = 0.5
  320. horiz_n = 35
  321. vertic_n = 25
  322. rect = visual.ElementArrayStim(self.win,
  323. units='norm',
  324. nElements=horiz_n * vertic_n,
  325. elementMask=None,
  326. elementTex=None,
  327. sizes=(2 / horiz_n, 2 / vertic_n))
  328. rect.xys = [(x, y)
  329. for x in np.linspace(-1, 1, horiz_n, endpoint=False) + 1 / horiz_n
  330. for y in np.linspace(-1, 1, vertic_n, endpoint=False) + 1 / vertic_n]
  331. rect.colors = [self.closest_hue(theta=x)[1]
  332. for x in
  333. np.random.randint(0, high=360, size=horiz_n * vertic_n)]
  334. rect.draw()
  335. self.win.flip()
  336. core.wait(mask_dur)
  337. # If response is given during the mask
  338. if judge is None:
  339. get_keys = event.getKeys(['up', 'down', 'escape'])
  340. key_press = get_keys
  341. if change_direction in get_keys:
  342. judge = 1 # correct
  343. react_time_stop = time.time()
  344. elif ('up' in get_keys and change_direction == 'down') or \
  345. ('down' in get_keys and change_direction == 'up'):
  346. judge = 0 # incorrect
  347. react_time_stop = time.time()
  348. elif 'escape' in get_keys:
  349. InitActivity.write(self.cfg_file, self.par_file, self.res_dir, data_file, status='escape')
  350. core.quit()
  351. # Refresh and wait for response (if no response was given in the pause mode or during mask)
  352. self.win.flip()
  353. fix.draw()
  354. num.draw()
  355. self.win.flip()
  356. # If no response in the pause mode or during the mask
  357. if judge is None:
  358. for wait_keys in event.waitKeys(keyList=['up', 'down', 'escape']):
  359. key_press = wait_keys
  360. if wait_keys == change_direction:
  361. judge = 1 # correct
  362. react_time_stop = time.time()
  363. elif (wait_keys == 'up' and change_direction == 'down') or \
  364. (wait_keys == 'down' and change_direction == 'up'):
  365. judge = 0 # incorrect
  366. react_time_stop = time.time()
  367. elif wait_keys == 'escape':
  368. InitActivity.write(self.cfg_file, self.par_file, self.res_dir, data_file, status='escape')
  369. core.quit()
  370. react_time = react_time_stop - react_time_start - self.trial_dur
  371. if self.feedback is True:
  372. feedback = visual.TextStim(self.win,
  373. pos=[0, 0],
  374. height=1.5,
  375. color='black')
  376. if judge == 1:
  377. feedback.text = ':)'
  378. elif judge == 0:
  379. feedback.text = ':('
  380. feedback.draw()
  381. self.win.flip()
  382. core.wait(0.5)
  383. fix.draw()
  384. num.draw()
  385. self.win.flip()
  386. return key_press, judge, react_time, trial_time_start
  387. def run_session(self):
  388. """
  389. Run a single session, save data and metadata.
  390. :return:
  391. """
  392. # Check paths
  393. path = os.path.join(self.res_dir, self.subject)
  394. if not os.path.exists(path):
  395. os.makedirs(path)
  396. # Welcome and wait to start
  397. welcome = visual.TextStim(self.win,
  398. 'Welcome! ' + '\n' + '\n'
  399. 'Your task is to observe the color bar and '
  400. 'judge whether the two dot arrays are in the same sequence as the color bar. ' + '\n' +
  401. 'If yes, press the Up arrow; ' + '\n' +
  402. 'If not, press the Down arrow. ' + '\n' +
  403. 'After giving your response, you can press any key to start the next trial.' + '\n' + '\n' +
  404. 'Ready? ' + '\n' +
  405. 'Press any key to start this session :)',
  406. color='black',
  407. pos=(0, 0),
  408. height=self.text_height)
  409. welcome.draw()
  410. self.win.mouseVisible = False
  411. self.win.flip()
  412. event.waitKeys()
  413. if feedback is True:
  414. fbk_msg = visual.TextStim(self.win,
  415. 'This is a practice session. ' + '\n' +
  416. 'You will get feedback for your response in each trial. ' + '\n' + '\n' +
  417. 'If you want to skip the current practice session and continue with the next session, '
  418. 'press the key "s" before starting a new trial (only available after completing the first 8 tirals).' + '\n' +
  419. 'Press any key to continue. ',
  420. color='black',
  421. pos=(0, 0),
  422. height=self.text_height)
  423. fbk_msg.draw()
  424. self.win.flip()
  425. event.waitKeys()
  426. # Initiate data files
  427. InitData = write_data.WriteData(self.subject, self.idx, self.res_dir)
  428. data_file = InitData.head(self.cfg_file, self.par_file)
  429. InitActivity = write_data.WriteActivity(self.subject, self.idx, self.res_dir)
  430. count = 0
  431. # Set the first few trials as warm-up
  432. warmup = []
  433. for n in range(self.warmup_nmb):
  434. for cond in self.conditions:
  435. warmup.append({'cond': cond, 'diff': np.random.randint(8, 10)})
  436. warmup_stair = data.TrialHandler(warmup, 1, method='sequential')
  437. for wp in warmup_stair:
  438. count += 1
  439. cond = wp['cond']
  440. rot = wp['diff'] * cond['stairDirection']
  441. patch_xlims = np.array([self.cfg['standard_xlim'], self.cfg['test_xlim']])
  442. np.random.shuffle(patch_xlims)
  443. press_key, judge, react_time, trial_time_start = self.run_trial(rot, cond, patch_xlims, count,
  444. InitActivity, data_file)
  445. disp_intensity = self.get_disp_val(cond, rot)
  446. if 'escape' in event.waitKeys():
  447. InitActivity.write(self.cfg_file, self.par_file, data_file, status='escape')
  448. # config_tools.write_xrl(self.subject, break_info='userbreak', dir_path=self.res_dir)
  449. core.quit()
  450. # Save data
  451. data_dict = {'trial_index': count,
  452. 'stimulus': cond['stimulus'],
  453. 'standard_stim': float(cond['standard']),
  454. 'test_stim': float(cond['standard'] + rot),
  455. 'standard_xlim': patch_xlims[0].tolist(),
  456. 'test_xlim': patch_xlims[1].tolist(),
  457. 'calculated_intensity': float(rot),
  458. 'actual_intensity': float(round(disp_intensity, 1)),
  459. 'press_key': press_key,
  460. 'judge': judge,
  461. 'react_time': react_time,
  462. 'trial_time_stamp': trial_time_start}
  463. InitData.write(count, 'warmup', data_dict)
  464. # Run staircase after the warm-up
  465. stairs = data.MultiStairHandler(stairType='simple',
  466. conditions=self.conditions,
  467. nTrials=self.trial_nmb,
  468. method='random')
  469. # Pseudo-shuffle: counterbalance the position patterns half to half before running trials
  470. pos_1_repeat = int(self.trial_nmb / 2)
  471. pos_2_repeat = self.trial_nmb - pos_1_repeat
  472. patchpos_1 = np.stack([np.array([self.cfg['standard_xlim'], self.cfg['test_xlim']])] * \
  473. pos_1_repeat)
  474. patchpos_2 = np.stack([np.array([self.cfg['test_xlim'], self.cfg['standard_xlim']])] * \
  475. pos_2_repeat)
  476. patchpos = np.concatenate([patchpos_1, patchpos_2])
  477. np.random.shuffle(patchpos)
  478. count_stairs = 0
  479. #data_dict_list = []
  480. for rot, cond in stairs:
  481. count += 1
  482. count_stairs += 1
  483. rot = rot * cond['stairDirection']
  484. # Avoid repeating the same value and sample more intensities after at least 10 trials:
  485. # if the intensity are consecutively small (less than 1.0)
  486. # with consecutively correct responses in the last three trials,
  487. # then the intensity for this trial will be reset to the startVal of the staircase
  488. #if rot < 1. and count_stairs > 10:
  489. # consecutive_intens = (data_dict_list[count_stairs - 1]['actual_intensity'] < 1.0
  490. # and data_dict_list[count_stairs - 2]['actual_intensity'] < 1.0
  491. # and data_dict_list[count_stairs - 3]['actual_intensity'] < 1.0)
  492. # consecutive_correct = (data_dict_list[count_stairs - 1]['judge']
  493. # and data_dict_list[count_stairs - 2]['judge'] == 1
  494. # and data_dict_list[count_stairs - 3]['judge'] == 1)
  495. # if consecutive_intens and consecutive_correct:
  496. # rot = cond['startVal'] * cond['stairDirection']
  497. # print("Consecutive repeats... Reset to the starting value!")
  498. patch_xlims = patchpos[int(np.floor((count_stairs - 1) / len(self.conditions)))]
  499. press_key, judge, react_time, trial_time_start = \
  500. self.run_trial(rot, cond, patch_xlims, count, InitActivity, data_file)
  501. # Check whether the stimuli are truly displayed in the given monitor resolution
  502. # - if not, the rotation given by staircase should be corrected by realizable values
  503. disp_intensity = self.get_disp_val(cond, stairs._nextIntensity * cond['stairDirection'])
  504. if disp_intensity == 0: # Repeat this trial if intensity is zero
  505. repeat_rot = (abs(rot) + 0.5) * cond['stairDirection']
  506. press_key, judge, react_time, trial_time_start = \
  507. self.run_trial(repeat_rot, cond, patch_xlims, count, InitActivity, data_file)
  508. disp_intensity = self.get_disp_val(cond, repeat_rot)
  509. # Add intensity-response pairs
  510. stairs.addResponse(judge, abs(disp_intensity))
  511. # Save data
  512. data_dict = {'trial_index': count,
  513. 'stimulus': cond['stimulus'],
  514. 'standard_stim': float(cond['standard']),
  515. 'test_stim': float(cond['standard'] + rot),
  516. 'standard_xlim': patch_xlims[0].tolist(),
  517. 'test_xlim': patch_xlims[1].tolist(),
  518. 'calculated_intensity': float(rot),
  519. 'actual_intensity': float(round(disp_intensity, 1)),
  520. 'press_key': press_key,
  521. 'judge': judge,
  522. 'react_time': react_time,
  523. 'trial_time_stamp': trial_time_start}
  524. #data_dict_list.append(data_dict)
  525. InitData.write(count_stairs, 'trial', data_dict)
  526. wait_keys = event.waitKeys()
  527. if 'escape' in wait_keys:
  528. InitActivity.write(self.cfg_file, self.par_file, data_file, status='escape')
  529. core.quit()
  530. if 's' in wait_keys and self.feedback is True:
  531. skip_info = visual.TextStim(self.win,
  532. 'You have not finished the practice session.'
  533. 'Are you sure to skip it?' + '\n' +
  534. 'Press to confirm (y/n) ...',
  535. color='black',
  536. units='deg',
  537. pos=(0, 0),
  538. height=self.text_height)
  539. skip_info.draw()
  540. self.win.flip()
  541. wait_skip = event.waitKeys(keyList=['y', 'n'])
  542. if 'y' in wait_skip:
  543. InitActivity.write(self.cfg_file, self.par_file, data_file, status='escape')
  544. return
  545. if self.feedback is True:
  546. InitActivity.write(self.cfg_file, self.par_file, data_file, status='practice')
  547. else:
  548. InitActivity.write(self.cfg_file, self.par_file, data_file, status='completed')
  549. def run_exp(subject, cfg_file, par_files, res_dir, feedback=False):
  550. """
  551. Run the experiment by giving inputs
  552. :param subject: subject name
  553. :param cfg_file: configuration file path
  554. :param par_files: a list of parameter file path
  555. :param res_dir: the path the store data, DEFAULT as 'data'
  556. :param feedback: whether giving feedback on the subject's response each trial, DEFAULT as False
  557. :return:
  558. """
  559. for pidx, pf in enumerate(par_files):
  560. Exp(subject, cfg_file, pf, res_dir, feedback).run_session() # run one session
  561. waitwin = Exp(subject, cfg_file, pf, res_dir, feedback).win
  562. # rest between sessions
  563. if pidx + 1 == len(par_files):
  564. msg = visual.TextStim(waitwin,
  565. 'Well done!' + '\n' +
  566. 'You have finished all sessions :)' + '\n' +
  567. 'Press any key to quit. ',
  568. color='black',
  569. units='deg',
  570. pos=(0, 0),
  571. height=0.8)
  572. else:
  573. msg = visual.TextStim(waitwin,
  574. 'Take a break!' + '\n' +
  575. 'Then press any key to start the next session :)',
  576. color='black',
  577. units='deg',
  578. pos=(0, 0),
  579. height=0.8)
  580. msg.draw()
  581. waitwin.flip()
  582. event.waitKeys()
  583. # """test"""
  584. # subject = 'test'
  585. # cfg_file = 'config/test_cfg.yaml'
  586. # par_files = ['config/co2x2_LL_set4.yaml']
  587. # res_dir = 'data'
  588. # feedback = False
  589. # run_exp(subject, cfg_file, par_files, res_dir, feedback)
  590. """ Run experiment from bash """
  591. if __name__ == '__main__':
  592. parser = argparse.ArgumentParser()
  593. parser.add_argument('--s', help='Subject')
  594. parser.add_argument('--c', help='Configuration file')
  595. parser.add_argument('--p', nargs='*', help='Parameter file')
  596. parser.add_argument('--r', help='Result directory')
  597. parser.add_argument('--f', type=bool, help='Whether give visual feedback, bool value')
  598. args = parser.parse_args()
  599. subject = args.s
  600. cfg_file = args.c
  601. par_file = args.p
  602. results_dir = args.r
  603. feedback = args.f
  604. run_exp(subject, cfg_file, par_file, results_dir, feedback)