Coverage for tasks/ided3d.py: 69%

135 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2022-11-08 23:14 +0000

1#!/usr/bin/env python 

2 

3""" 

4camcops_server/tasks/ided3d.py 

5 

6=============================================================================== 

7 

8 Copyright (C) 2012, University of Cambridge, Department of Psychiatry. 

9 Created by Rudolf Cardinal (rnc1001@cam.ac.uk). 

10 

11 This file is part of CamCOPS. 

12 

13 CamCOPS is free software: you can redistribute it and/or modify 

14 it under the terms of the GNU General Public License as published by 

15 the Free Software Foundation, either version 3 of the License, or 

16 (at your option) any later version. 

17 

18 CamCOPS is distributed in the hope that it will be useful, 

19 but WITHOUT ANY WARRANTY; without even the implied warranty of 

20 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

21 GNU General Public License for more details. 

22 

23 You should have received a copy of the GNU General Public License 

24 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>. 

25 

26=============================================================================== 

27 

28""" 

29 

30from typing import Any, List, Optional, Type 

31 

32import cardinal_pythonlib.rnc_web as ws 

33from sqlalchemy.sql.schema import Column 

34from sqlalchemy.sql.sqltypes import Boolean, Float, Integer, Text 

35 

36from camcops_server.cc_modules.cc_constants import CssClass 

37from camcops_server.cc_modules.cc_db import ( 

38 ancillary_relationship, 

39 GenericTabletRecordMixin, 

40 TaskDescendant, 

41) 

42from camcops_server.cc_modules.cc_html import ( 

43 answer, 

44 get_yes_no_none, 

45 identity, 

46 tr, 

47 tr_qa, 

48) 

49from camcops_server.cc_modules.cc_request import CamcopsRequest 

50from camcops_server.cc_modules.cc_sqla_coltypes import ( 

51 BIT_CHECKER, 

52 CamcopsColumn, 

53 PendulumDateTimeAsIsoTextColType, 

54) 

55from camcops_server.cc_modules.cc_sqlalchemy import Base 

56from camcops_server.cc_modules.cc_task import Task, TaskHasPatientMixin 

57from camcops_server.cc_modules.cc_text import SS 

58 

59 

60# ============================================================================= 

61# Helper functions 

62# ============================================================================= 

63 

64 

65def a(x: Any) -> str: 

66 """ 

67 Answer formatting for this task. 

68 """ 

69 return answer(x, formatter_answer=identity, default="") 

70 

71 

72# ============================================================================= 

73# IDED3D 

74# ============================================================================= 

75 

76 

77class IDED3DTrial(GenericTabletRecordMixin, TaskDescendant, Base): 

78 __tablename__ = "ided3d_trials" 

79 

80 ided3d_id = Column( 

81 "ided3d_id", Integer, nullable=False, comment="FK to ided3d" 

82 ) 

83 trial = Column( 

84 "trial", Integer, nullable=False, comment="Trial number (1-based)" 

85 ) 

86 stage = Column("stage", Integer, comment="Stage number (1-based)") 

87 

88 # Locations 

89 correct_location = Column( 

90 "correct_location", 

91 Integer, 

92 comment="Location of correct stimulus " 

93 "(0 top, 1 right, 2 bottom, 3 left)", 

94 ) 

95 incorrect_location = Column( 

96 "incorrect_location", 

97 Integer, 

98 comment="Location of incorrect stimulus " 

99 "(0 top, 1 right, 2 bottom, 3 left)", 

100 ) 

101 

102 # Stimuli 

103 correct_shape = Column( 

104 "correct_shape", Integer, comment="Shape# of correct stimulus" 

105 ) 

106 correct_colour = CamcopsColumn( 

107 "correct_colour", 

108 Text, 

109 exempt_from_anonymisation=True, 

110 comment="HTML colour of correct stimulus", 

111 ) 

112 correct_number = Column( 

113 "correct_number", 

114 Integer, 

115 comment="Number of copies of correct stimulus", 

116 ) 

117 incorrect_shape = Column( 

118 "incorrect_shape", Integer, comment="Shape# of incorrect stimulus" 

119 ) 

120 incorrect_colour = CamcopsColumn( 

121 "incorrect_colour", 

122 Text, 

123 exempt_from_anonymisation=True, 

124 comment="HTML colour of incorrect stimulus", 

125 ) 

126 incorrect_number = Column( 

127 "incorrect_number", 

128 Integer, 

129 comment="Number of copies of incorrect stimulus", 

130 ) 

131 

132 # Trial 

133 trial_start_time = Column( 

134 "trial_start_time", 

135 PendulumDateTimeAsIsoTextColType, 

136 comment="Trial start time / stimuli presented at (ISO-8601)", 

137 ) 

138 

139 # Response 

140 responded = CamcopsColumn( 

141 "responded", 

142 Boolean, 

143 permitted_value_checker=BIT_CHECKER, 

144 comment="Did the subject respond?", 

145 ) 

146 response_time = Column( 

147 "response_time", 

148 PendulumDateTimeAsIsoTextColType, 

149 comment="Time of response (ISO-8601)", 

150 ) 

151 response_latency_ms = Column( 

152 "response_latency_ms", Integer, comment="Response latency (ms)" 

153 ) 

154 correct = CamcopsColumn( 

155 "correct", 

156 Boolean, 

157 permitted_value_checker=BIT_CHECKER, 

158 comment="Response was correct", 

159 ) 

160 incorrect = CamcopsColumn( 

161 "incorrect", 

162 Boolean, 

163 permitted_value_checker=BIT_CHECKER, 

164 comment="Response was incorrect", 

165 ) 

166 

167 @classmethod 

168 def get_html_table_header(cls) -> str: 

169 return f""" 

170 <table class="{CssClass.EXTRADETAIL}"> 

171 <tr> 

172 <th>Trial#</th> 

173 <th>Stage#</th> 

174 <th>Correct location</th> 

175 <th>Incorrect location</th> 

176 <th>Correct shape</th> 

177 <th>Correct colour</th> 

178 <th>Correct number</th> 

179 <th>Incorrect shape</th> 

180 <th>Incorrect colour</th> 

181 <th>Incorrect number</th> 

182 <th>Trial start time</th> 

183 <th>Responded?</th> 

184 <th>Response time</th> 

185 <th>Response latency (ms)</th> 

186 <th>Correct?</th> 

187 <th>Incorrect?</th> 

188 </tr> 

189 """ 

190 

191 def get_html_table_row(self) -> str: 

192 return tr( 

193 a(self.trial), 

194 a(self.stage), 

195 a(self.correct_location), 

196 a(self.incorrect_location), 

197 a(self.correct_shape), 

198 a(self.correct_colour), 

199 a(self.correct_number), 

200 a(self.incorrect_shape), 

201 a(self.incorrect_colour), 

202 a(self.incorrect_number), 

203 a(self.trial_start_time), 

204 a(self.responded), 

205 a(self.response_time), 

206 a(self.response_latency_ms), 

207 a(self.correct), 

208 a(self.incorrect), 

209 ) 

210 

211 # ------------------------------------------------------------------------- 

212 # TaskDescendant overrides 

213 # ------------------------------------------------------------------------- 

214 

215 @classmethod 

216 def task_ancestor_class(cls) -> Optional[Type["Task"]]: 

217 return IDED3D 

218 

219 def task_ancestor(self) -> Optional["IDED3D"]: 

220 return IDED3D.get_linked(self.ided3d_id, self) 

221 

222 

223class IDED3DStage(GenericTabletRecordMixin, TaskDescendant, Base): 

224 __tablename__ = "ided3d_stages" 

225 

226 ided3d_id = Column( 

227 "ided3d_id", Integer, nullable=False, comment="FK to ided3d" 

228 ) 

229 stage = Column( 

230 "stage", Integer, nullable=False, comment="Stage number (1-based)" 

231 ) 

232 

233 # Config 

234 stage_name = CamcopsColumn( 

235 "stage_name", 

236 Text, 

237 exempt_from_anonymisation=True, 

238 comment="Name of the stage (e.g. SD, EDr)", 

239 ) 

240 relevant_dimension = CamcopsColumn( 

241 "relevant_dimension", 

242 Text, 

243 exempt_from_anonymisation=True, 

244 comment="Relevant dimension (e.g. shape, colour, number)", 

245 ) 

246 correct_exemplar = CamcopsColumn( 

247 "correct_exemplar", 

248 Text, 

249 exempt_from_anonymisation=True, 

250 comment="Correct exemplar (from relevant dimension)", 

251 ) 

252 incorrect_exemplar = CamcopsColumn( 

253 "incorrect_exemplar", 

254 Text, 

255 exempt_from_anonymisation=True, 

256 comment="Incorrect exemplar (from relevant dimension)", 

257 ) 

258 correct_stimulus_shapes = CamcopsColumn( 

259 "correct_stimulus_shapes", 

260 Text, 

261 exempt_from_anonymisation=True, 

262 comment="Possible shapes for correct stimulus " 

263 "(CSV list of shape numbers)", 

264 ) 

265 correct_stimulus_colours = CamcopsColumn( 

266 "correct_stimulus_colours", 

267 Text, 

268 exempt_from_anonymisation=True, 

269 comment="Possible colours for correct stimulus " 

270 "(CSV list of HTML colours)", 

271 ) 

272 correct_stimulus_numbers = CamcopsColumn( 

273 "correct_stimulus_numbers", 

274 Text, 

275 exempt_from_anonymisation=True, 

276 comment="Possible numbers for correct stimulus " 

277 "(CSV list of numbers)", 

278 ) 

279 incorrect_stimulus_shapes = CamcopsColumn( 

280 "incorrect_stimulus_shapes", 

281 Text, 

282 exempt_from_anonymisation=True, 

283 comment="Possible shapes for incorrect stimulus " 

284 "(CSV list of shape numbers)", 

285 ) 

286 incorrect_stimulus_colours = CamcopsColumn( 

287 "incorrect_stimulus_colours", 

288 Text, 

289 exempt_from_anonymisation=True, 

290 comment="Possible colours for incorrect stimulus " 

291 "(CSV list of HTML colours)", 

292 ) 

293 incorrect_stimulus_numbers = CamcopsColumn( 

294 "incorrect_stimulus_numbers", 

295 Text, 

296 exempt_from_anonymisation=True, 

297 comment="Possible numbers for incorrect stimulus " 

298 "(CSV list of numbers)", 

299 ) 

300 

301 # Results 

302 first_trial_num = Column( 

303 "first_trial_num", 

304 Integer, 

305 comment="Number of the first trial of the stage (1-based)", 

306 ) 

307 n_completed_trials = Column( 

308 "n_completed_trials", Integer, comment="Number of trials completed" 

309 ) 

310 n_correct = Column( 

311 "n_correct", Integer, comment="Number of trials performed correctly" 

312 ) 

313 n_incorrect = Column( 

314 "n_incorrect", 

315 Integer, 

316 comment="Number of trials performed incorrectly", 

317 ) 

318 stage_passed = CamcopsColumn( 

319 "stage_passed", 

320 Boolean, 

321 permitted_value_checker=BIT_CHECKER, 

322 comment="Subject met criterion and passed stage", 

323 ) 

324 stage_failed = CamcopsColumn( 

325 "stage_failed", 

326 Boolean, 

327 permitted_value_checker=BIT_CHECKER, 

328 comment="Subject took too many trials and failed stage", 

329 ) 

330 

331 @classmethod 

332 def get_html_table_header(cls) -> str: 

333 return f""" 

334 <table class="{CssClass.EXTRADETAIL}"> 

335 <tr> 

336 <th>Stage#</th> 

337 <th>Stage name</th> 

338 <th>Relevant dimension</th> 

339 <th>Correct exemplar</th> 

340 <th>Incorrect exemplar</th> 

341 <th>Shapes for correct</th> 

342 <th>Colours for correct</th> 

343 <th>Numbers for correct</th> 

344 <th>Shapes for incorrect</th> 

345 <th>Colours for incorrect</th> 

346 <th>Numbers for incorrect</th> 

347 <th>First trial#</th> 

348 <th>#completed trials</th> 

349 <th>#correct</th> 

350 <th>#incorrect</th> 

351 <th>Passed?</th> 

352 <th>Failed?</th> 

353 </tr> 

354 """ 

355 

356 def get_html_table_row(self) -> str: 

357 return tr( 

358 a(self.stage), 

359 a(self.stage_name), 

360 a(self.relevant_dimension), 

361 a(self.correct_exemplar), 

362 a(self.incorrect_exemplar), 

363 a(self.correct_stimulus_shapes), 

364 a(self.correct_stimulus_colours), 

365 a(self.correct_stimulus_numbers), 

366 a(self.incorrect_stimulus_shapes), 

367 a(self.incorrect_stimulus_colours), 

368 a(self.incorrect_stimulus_numbers), 

369 a(self.first_trial_num), 

370 a(self.n_completed_trials), 

371 a(self.n_correct), 

372 a(self.n_incorrect), 

373 a(self.stage_passed), 

374 a(self.stage_failed), 

375 ) 

376 

377 # ------------------------------------------------------------------------- 

378 # TaskDescendant overrides 

379 # ------------------------------------------------------------------------- 

380 

381 @classmethod 

382 def task_ancestor_class(cls) -> Optional[Type["Task"]]: 

383 return IDED3D 

384 

385 def task_ancestor(self) -> Optional["IDED3D"]: 

386 return IDED3D.get_linked(self.ided3d_id, self) 

387 

388 

389class IDED3D(TaskHasPatientMixin, Task): 

390 """ 

391 Server implementation of the ID/ED-3D task. 

392 """ 

393 

394 __tablename__ = "ided3d" 

395 shortname = "ID/ED-3D" 

396 

397 # Config 

398 last_stage = Column( 

399 "last_stage", Integer, comment="Last stage to offer (1 [SD] - 8 [EDR])" 

400 ) 

401 max_trials_per_stage = Column( 

402 "max_trials_per_stage", 

403 Integer, 

404 comment="Maximum number of trials allowed per stage before " 

405 "the task aborts", 

406 ) 

407 progress_criterion_x = Column( 

408 "progress_criterion_x", 

409 Integer, 

410 comment="Criterion to proceed to next stage: X correct out of" 

411 " the last Y trials, where this is X", 

412 ) 

413 progress_criterion_y = Column( 

414 "progress_criterion_y", 

415 Integer, 

416 comment="Criterion to proceed to next stage: X correct out of" 

417 " the last Y trials, where this is Y", 

418 ) 

419 min_number = Column( 

420 "min_number", 

421 Integer, 

422 comment="Minimum number of stimulus element to use", 

423 ) 

424 max_number = Column( 

425 "max_number", 

426 Integer, 

427 comment="Maximum number of stimulus element to use", 

428 ) 

429 pause_after_beep_ms = Column( 

430 "pause_after_beep_ms", 

431 Integer, 

432 comment="Time to continue visual feedback after auditory " 

433 "feedback finished (ms)", 

434 ) 

435 iti_ms = Column("iti_ms", Integer, comment="Intertrial interval (ms)") 

436 counterbalance_dimensions = Column( 

437 "counterbalance_dimensions", 

438 Integer, 

439 comment="Dimensional counterbalancing condition (0-5)", 

440 ) 

441 volume = Column("volume", Float, comment="Sound volume (0.0-1.0)") 

442 offer_abort = CamcopsColumn( 

443 "offer_abort", 

444 Boolean, 

445 permitted_value_checker=BIT_CHECKER, 

446 comment="Offer an abort button?", 

447 ) 

448 debug_display_stimuli_only = CamcopsColumn( 

449 "debug_display_stimuli_only", 

450 Boolean, 

451 permitted_value_checker=BIT_CHECKER, 

452 comment="DEBUG: show stimuli only, don't run task", 

453 ) 

454 

455 # Intrinsic config 

456 shape_definitions_svg = CamcopsColumn( 

457 "shape_definitions_svg", 

458 Text, 

459 exempt_from_anonymisation=True, 

460 comment="JSON-encoded version of shape definition" 

461 " array in SVG format (with arbitrary scale of -60 to" 

462 " +60 in both X and Y dimensions)", 

463 ) 

464 colour_definitions_rgb = CamcopsColumn( # v2.0.0 

465 "colour_definitions_rgb", 

466 Text, 

467 exempt_from_anonymisation=True, 

468 comment="JSON-encoded version of colour RGB definitions", 

469 ) 

470 

471 # Results 

472 aborted = Column( 

473 "aborted", Integer, comment="Was the task aborted? (0 no, 1 yes)" 

474 ) 

475 finished = Column( 

476 "finished", Integer, comment="Was the task finished? (0 no, 1 yes)" 

477 ) 

478 last_trial_completed = Column( 

479 "last_trial_completed", 

480 Integer, 

481 comment="Number of last trial completed", 

482 ) 

483 

484 # Relationships 

485 trials = ancillary_relationship( 

486 parent_class_name="IDED3D", 

487 ancillary_class_name="IDED3DTrial", 

488 ancillary_fk_to_parent_attr_name="ided3d_id", 

489 ancillary_order_by_attr_name="trial", 

490 ) # type: List[IDED3DTrial] 

491 stages = ancillary_relationship( 

492 parent_class_name="IDED3D", 

493 ancillary_class_name="IDED3DStage", 

494 ancillary_fk_to_parent_attr_name="ided3d_id", 

495 ancillary_order_by_attr_name="stage", 

496 ) # type: List[IDED3DStage] 

497 

498 @staticmethod 

499 def longname(req: "CamcopsRequest") -> str: 

500 _ = req.gettext 

501 return _("Three-dimensional ID/ED task") 

502 

503 def is_complete(self) -> bool: 

504 return bool(self.debug_display_stimuli_only) or bool(self.finished) 

505 

506 def get_stage_html(self) -> str: 

507 html = IDED3DStage.get_html_table_header() 

508 # noinspection PyTypeChecker 

509 for s in self.stages: 

510 html += s.get_html_table_row() 

511 html += """</table>""" 

512 return html 

513 

514 def get_trial_html(self) -> str: 

515 html = IDED3DTrial.get_html_table_header() 

516 # noinspection PyTypeChecker 

517 for t in self.trials: 

518 html += t.get_html_table_row() 

519 html += """</table>""" 

520 return html 

521 

522 def get_task_html(self, req: CamcopsRequest) -> str: 

523 h = f""" 

524 <div class="{CssClass.SUMMARY}"> 

525 <table class="{CssClass.SUMMARY}"> 

526 {self.get_is_complete_tr(req)} 

527 </table> 

528 </div> 

529 <div class="{CssClass.EXPLANATION}"> 

530 1. Simple discrimination (SD), and 2. reversal (SDr); 

531 3. compound discrimination (CD), and 4. reversal (CDr); 

532 5. intradimensional shift (ID), and 6. reversal (IDr); 

533 7. extradimensional shift (ED), and 8. reversal (EDr). 

534 </div> 

535 <table class="{CssClass.TASKCONFIG}"> 

536 <tr> 

537 <th width="50%">Configuration variable</th> 

538 <th width="50%">Value</th> 

539 </tr> 

540 """ 

541 h += tr_qa(self.wxstring(req, "last_stage"), self.last_stage) 

542 h += tr_qa( 

543 self.wxstring(req, "max_trials_per_stage"), 

544 self.max_trials_per_stage, 

545 ) 

546 h += tr_qa( 

547 self.wxstring(req, "progress_criterion_x"), 

548 self.progress_criterion_x, 

549 ) 

550 h += tr_qa( 

551 self.wxstring(req, "progress_criterion_y"), 

552 self.progress_criterion_y, 

553 ) 

554 h += tr_qa(self.wxstring(req, "min_number"), self.min_number) 

555 h += tr_qa(self.wxstring(req, "max_number"), self.max_number) 

556 h += tr_qa( 

557 self.wxstring(req, "pause_after_beep_ms"), self.pause_after_beep_ms 

558 ) 

559 h += tr_qa(self.wxstring(req, "iti_ms"), self.iti_ms) 

560 h += tr_qa( 

561 self.wxstring(req, "counterbalance_dimensions") + "<sup>[1]</sup>", 

562 self.counterbalance_dimensions, 

563 ) 

564 h += tr_qa(req.sstring(SS.VOLUME_0_TO_1), self.volume) 

565 h += tr_qa(self.wxstring(req, "offer_abort"), self.offer_abort) 

566 h += tr_qa( 

567 self.wxstring(req, "debug_display_stimuli_only"), 

568 self.debug_display_stimuli_only, 

569 ) 

570 h += tr_qa( 

571 "Shapes (as a JSON-encoded array of SVG " 

572 "definitions; X and Y range both –60 to +60)", 

573 ws.webify(self.shape_definitions_svg), 

574 ) 

575 h += f""" 

576 </table> 

577 <table class="{CssClass.TASKDETAIL}"> 

578 <tr><th width="50%">Measure</th><th width="50%">Value</th></tr> 

579 """ 

580 h += tr_qa("Aborted?", get_yes_no_none(req, self.aborted)) 

581 h += tr_qa("Finished?", get_yes_no_none(req, self.finished)) 

582 h += tr_qa("Last trial completed", self.last_trial_completed) 

583 h += ( 

584 """ 

585 </table> 

586 <div>Stage specifications and results:</div> 

587 """ 

588 + self.get_stage_html() 

589 + "<div>Trial-by-trial results:</div>" 

590 + self.get_trial_html() 

591 + f""" 

592 <div class="{CssClass.FOOTNOTES}"> 

593 [1] Counterbalancing of dimensions is as follows, with 

594 notation X/Y indicating that X is the first relevant 

595 dimension (for stages SD–IDr) and Y is the second relevant 

596 dimension (for stages ED–EDr). 

597 0: shape/colour. 

598 1: colour/number. 

599 2: number/shape. 

600 3: shape/number. 

601 4: colour/shape. 

602 5: number/colour. 

603 </div> 

604 """ 

605 ) 

606 return h