Coverage for tasks/ided3d.py: 69%

135 statements  

« prev     ^ index     » next       coverage.py v7.9.2, created at 2025-07-15 14:23 +0100

1""" 

2camcops_server/tasks/ided3d.py 

3 

4=============================================================================== 

5 

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

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

8 

9 This file is part of CamCOPS. 

10 

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

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

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

14 (at your option) any later version. 

15 

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

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

18 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

19 GNU General Public License for more details. 

20 

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

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

23 

24=============================================================================== 

25 

26""" 

27 

28from typing import Any, List, Optional, Type 

29 

30import cardinal_pythonlib.rnc_web as ws 

31from pendulum import DateTime as Pendulum 

32from sqlalchemy.orm import Mapped, mapped_column 

33from sqlalchemy.sql.sqltypes import Boolean, Text 

34 

35from camcops_server.cc_modules.cc_constants import CssClass 

36from camcops_server.cc_modules.cc_db import ( 

37 ancillary_relationship, 

38 GenericTabletRecordMixin, 

39 TaskDescendant, 

40) 

41from camcops_server.cc_modules.cc_html import ( 

42 answer, 

43 get_yes_no_none, 

44 identity, 

45 tr, 

46 tr_qa, 

47) 

48from camcops_server.cc_modules.cc_request import CamcopsRequest 

49from camcops_server.cc_modules.cc_sqla_coltypes import ( 

50 BIT_CHECKER, 

51 mapped_camcops_column, 

52 PendulumDateTimeAsIsoTextColType, 

53) 

54from camcops_server.cc_modules.cc_sqlalchemy import Base 

55from camcops_server.cc_modules.cc_task import Task, TaskHasPatientMixin 

56from camcops_server.cc_modules.cc_text import SS 

57 

58 

59# ============================================================================= 

60# Helper functions 

61# ============================================================================= 

62 

63 

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

65 """ 

66 Answer formatting for this task. 

67 """ 

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

69 

70 

71# ============================================================================= 

72# IDED3D 

73# ============================================================================= 

74 

75 

76class IDED3DTrial(GenericTabletRecordMixin, TaskDescendant, Base): 

77 __tablename__ = "ided3d_trials" 

78 

79 ided3d_id: Mapped[int] = mapped_column(comment="FK to ided3d") 

80 trial: Mapped[int] = mapped_column(comment="Trial number (1-based)") 

81 stage: Mapped[Optional[int]] = mapped_column( 

82 comment="Stage number (1-based)" 

83 ) 

84 

85 # Locations 

86 correct_location: Mapped[Optional[int]] = mapped_column( 

87 comment="Location of correct stimulus " 

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

89 ) 

90 incorrect_location: Mapped[Optional[int]] = mapped_column( 

91 comment="Location of incorrect stimulus " 

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

93 ) 

94 

95 # Stimuli 

96 correct_shape: Mapped[Optional[int]] = mapped_column( 

97 comment="Shape# of correct stimulus" 

98 ) 

99 correct_colour: Mapped[Optional[str]] = mapped_camcops_column( 

100 Text, 

101 exempt_from_anonymisation=True, 

102 comment="HTML colour of correct stimulus", 

103 ) 

104 correct_number: Mapped[Optional[int]] = mapped_column( 

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

106 ) 

107 incorrect_shape: Mapped[Optional[int]] = mapped_column( 

108 comment="Shape# of incorrect stimulus" 

109 ) 

110 incorrect_colour: Mapped[Optional[str]] = mapped_camcops_column( 

111 Text, 

112 exempt_from_anonymisation=True, 

113 comment="HTML colour of incorrect stimulus", 

114 ) 

115 incorrect_number: Mapped[Optional[int]] = mapped_column( 

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

117 ) 

118 

119 # Trial 

120 trial_start_time: Mapped[Optional[Pendulum]] = mapped_column( 

121 PendulumDateTimeAsIsoTextColType, 

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

123 ) 

124 

125 # Response 

126 responded: Mapped[Optional[bool]] = mapped_camcops_column( 

127 permitted_value_checker=BIT_CHECKER, 

128 comment="Did the subject respond?", 

129 ) 

130 response_time: Mapped[Optional[Pendulum]] = mapped_column( 

131 PendulumDateTimeAsIsoTextColType, 

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

133 ) 

134 response_latency_ms: Mapped[Optional[int]] = mapped_column( 

135 comment="Response latency (ms)" 

136 ) 

137 correct: Mapped[Optional[bool]] = mapped_camcops_column( 

138 permitted_value_checker=BIT_CHECKER, 

139 comment="Response was correct", 

140 ) 

141 incorrect: Mapped[Optional[bool]] = mapped_camcops_column( 

142 permitted_value_checker=BIT_CHECKER, 

143 comment="Response was incorrect", 

144 ) 

145 

146 @classmethod 

147 def get_html_table_header(cls) -> str: 

148 return f""" 

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

150 <tr> 

151 <th>Trial#</th> 

152 <th>Stage#</th> 

153 <th>Correct location</th> 

154 <th>Incorrect location</th> 

155 <th>Correct shape</th> 

156 <th>Correct colour</th> 

157 <th>Correct number</th> 

158 <th>Incorrect shape</th> 

159 <th>Incorrect colour</th> 

160 <th>Incorrect number</th> 

161 <th>Trial start time</th> 

162 <th>Responded?</th> 

163 <th>Response time</th> 

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

165 <th>Correct?</th> 

166 <th>Incorrect?</th> 

167 </tr> 

168 """ 

169 

170 def get_html_table_row(self) -> str: 

171 return tr( 

172 a(self.trial), 

173 a(self.stage), 

174 a(self.correct_location), 

175 a(self.incorrect_location), 

176 a(self.correct_shape), 

177 a(self.correct_colour), 

178 a(self.correct_number), 

179 a(self.incorrect_shape), 

180 a(self.incorrect_colour), 

181 a(self.incorrect_number), 

182 a(self.trial_start_time), 

183 a(self.responded), 

184 a(self.response_time), 

185 a(self.response_latency_ms), 

186 a(self.correct), 

187 a(self.incorrect), 

188 ) 

189 

190 # ------------------------------------------------------------------------- 

191 # TaskDescendant overrides 

192 # ------------------------------------------------------------------------- 

193 

194 @classmethod 

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

196 return IDED3D 

197 

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

199 return IDED3D.get_linked(self.ided3d_id, self) # type: ignore[return-value] # noqa: E501 

200 

201 

202class IDED3DStage(GenericTabletRecordMixin, TaskDescendant, Base): 

203 __tablename__ = "ided3d_stages" 

204 

205 ided3d_id: Mapped[int] = mapped_column(comment="FK to ided3d") 

206 stage: Mapped[int] = mapped_column(comment="Stage number (1-based)") 

207 

208 # Config 

209 stage_name: Mapped[Optional[str]] = mapped_camcops_column( 

210 Text, 

211 exempt_from_anonymisation=True, 

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

213 ) 

214 relevant_dimension: Mapped[Optional[str]] = mapped_camcops_column( 

215 Text, 

216 exempt_from_anonymisation=True, 

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

218 ) 

219 correct_exemplar: Mapped[Optional[str]] = mapped_camcops_column( 

220 Text, 

221 exempt_from_anonymisation=True, 

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

223 ) 

224 incorrect_exemplar: Mapped[Optional[str]] = mapped_camcops_column( 

225 Text, 

226 exempt_from_anonymisation=True, 

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

228 ) 

229 correct_stimulus_shapes: Mapped[Optional[str]] = mapped_camcops_column( 

230 Text, 

231 exempt_from_anonymisation=True, 

232 comment="Possible shapes for correct stimulus " 

233 "(CSV list of shape numbers)", 

234 ) 

235 correct_stimulus_colours: Mapped[Optional[str]] = mapped_camcops_column( 

236 Text, 

237 exempt_from_anonymisation=True, 

238 comment="Possible colours for correct stimulus " 

239 "(CSV list of HTML colours)", 

240 ) 

241 correct_stimulus_numbers: Mapped[Optional[str]] = mapped_camcops_column( 

242 Text, 

243 exempt_from_anonymisation=True, 

244 comment="Possible numbers for correct stimulus " 

245 "(CSV list of numbers)", 

246 ) 

247 incorrect_stimulus_shapes: Mapped[Optional[str]] = mapped_camcops_column( 

248 Text, 

249 exempt_from_anonymisation=True, 

250 comment="Possible shapes for incorrect stimulus " 

251 "(CSV list of shape numbers)", 

252 ) 

253 incorrect_stimulus_colours: Mapped[Optional[str]] = mapped_camcops_column( 

254 Text, 

255 exempt_from_anonymisation=True, 

256 comment="Possible colours for incorrect stimulus " 

257 "(CSV list of HTML colours)", 

258 ) 

259 incorrect_stimulus_numbers: Mapped[Optional[str]] = mapped_camcops_column( 

260 Text, 

261 exempt_from_anonymisation=True, 

262 comment="Possible numbers for incorrect stimulus " 

263 "(CSV list of numbers)", 

264 ) 

265 

266 # Results 

267 first_trial_num: Mapped[Optional[int]] = mapped_column( 

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

269 ) 

270 n_completed_trials: Mapped[Optional[int]] = mapped_column( 

271 comment="Number of trials completed" 

272 ) 

273 n_correct: Mapped[Optional[int]] = mapped_column( 

274 comment="Number of trials performed correctly" 

275 ) 

276 n_incorrect: Mapped[Optional[int]] = mapped_column( 

277 comment="Number of trials performed incorrectly", 

278 ) 

279 stage_passed: Mapped[Optional[bool]] = mapped_camcops_column( 

280 Boolean, 

281 permitted_value_checker=BIT_CHECKER, 

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

283 ) 

284 stage_failed: Mapped[Optional[bool]] = mapped_camcops_column( 

285 Boolean, 

286 permitted_value_checker=BIT_CHECKER, 

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

288 ) 

289 

290 @classmethod 

291 def get_html_table_header(cls) -> str: 

292 return f""" 

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

294 <tr> 

295 <th>Stage#</th> 

296 <th>Stage name</th> 

297 <th>Relevant dimension</th> 

298 <th>Correct exemplar</th> 

299 <th>Incorrect exemplar</th> 

300 <th>Shapes for correct</th> 

301 <th>Colours for correct</th> 

302 <th>Numbers for correct</th> 

303 <th>Shapes for incorrect</th> 

304 <th>Colours for incorrect</th> 

305 <th>Numbers for incorrect</th> 

306 <th>First trial#</th> 

307 <th>#completed trials</th> 

308 <th>#correct</th> 

309 <th>#incorrect</th> 

310 <th>Passed?</th> 

311 <th>Failed?</th> 

312 </tr> 

313 """ 

314 

315 def get_html_table_row(self) -> str: 

316 return tr( 

317 a(self.stage), 

318 a(self.stage_name), 

319 a(self.relevant_dimension), 

320 a(self.correct_exemplar), 

321 a(self.incorrect_exemplar), 

322 a(self.correct_stimulus_shapes), 

323 a(self.correct_stimulus_colours), 

324 a(self.correct_stimulus_numbers), 

325 a(self.incorrect_stimulus_shapes), 

326 a(self.incorrect_stimulus_colours), 

327 a(self.incorrect_stimulus_numbers), 

328 a(self.first_trial_num), 

329 a(self.n_completed_trials), 

330 a(self.n_correct), 

331 a(self.n_incorrect), 

332 a(self.stage_passed), 

333 a(self.stage_failed), 

334 ) 

335 

336 # ------------------------------------------------------------------------- 

337 # TaskDescendant overrides 

338 # ------------------------------------------------------------------------- 

339 

340 @classmethod 

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

342 return IDED3D 

343 

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

345 return IDED3D.get_linked(self.ided3d_id, self) # type: ignore[return-value] # noqa: E501 

346 

347 

348class IDED3D(TaskHasPatientMixin, Task): # type: ignore[misc] 

349 """ 

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

351 """ 

352 

353 __tablename__ = "ided3d" 

354 shortname = "ID/ED-3D" 

355 

356 # Config 

357 last_stage: Mapped[Optional[int]] = mapped_column( 

358 comment="Last stage to offer (1 [SD] - 8 [EDR])" 

359 ) 

360 max_trials_per_stage: Mapped[Optional[int]] = mapped_column( 

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

362 "the task aborts", 

363 ) 

364 progress_criterion_x: Mapped[Optional[int]] = mapped_column( 

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

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

367 ) 

368 progress_criterion_y: Mapped[Optional[int]] = mapped_column( 

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

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

371 ) 

372 min_number: Mapped[Optional[int]] = mapped_column( 

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

374 ) 

375 max_number: Mapped[Optional[int]] = mapped_column( 

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

377 ) 

378 pause_after_beep_ms: Mapped[Optional[int]] = mapped_column( 

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

380 "feedback finished (ms)", 

381 ) 

382 iti_ms: Mapped[Optional[int]] = mapped_column( 

383 comment="Intertrial interval (ms)" 

384 ) 

385 counterbalance_dimensions: Mapped[Optional[int]] = mapped_column( 

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

387 ) 

388 volume: Mapped[Optional[float]] = mapped_column( 

389 comment="Sound volume (0.0-1.0)" 

390 ) 

391 offer_abort: Mapped[Optional[bool]] = mapped_camcops_column( 

392 permitted_value_checker=BIT_CHECKER, 

393 comment="Offer an abort button?", 

394 ) 

395 debug_display_stimuli_only: Mapped[Optional[bool]] = mapped_camcops_column( 

396 permitted_value_checker=BIT_CHECKER, 

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

398 ) 

399 

400 # Intrinsic config 

401 shape_definitions_svg: Mapped[Optional[str]] = mapped_camcops_column( 

402 Text, 

403 exempt_from_anonymisation=True, 

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

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

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

407 ) 

408 colour_definitions_rgb: Mapped[Optional[str]] = ( 

409 mapped_camcops_column( # v2.0.0 

410 Text, 

411 exempt_from_anonymisation=True, 

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

413 ) 

414 ) 

415 

416 # Results 

417 aborted: Mapped[Optional[int]] = mapped_column( 

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

419 ) 

420 finished: Mapped[Optional[int]] = mapped_column( 

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

422 ) 

423 last_trial_completed: Mapped[Optional[int]] = mapped_column( 

424 comment="Number of last trial completed", 

425 ) 

426 

427 # Relationships 

428 trials = ancillary_relationship( # type: ignore[assignment] 

429 parent_class_name="IDED3D", 

430 ancillary_class_name="IDED3DTrial", 

431 ancillary_fk_to_parent_attr_name="ided3d_id", 

432 ancillary_order_by_attr_name="trial", 

433 ) # type: List[IDED3DTrial] 

434 stages = ancillary_relationship( # type: ignore[assignment] 

435 parent_class_name="IDED3D", 

436 ancillary_class_name="IDED3DStage", 

437 ancillary_fk_to_parent_attr_name="ided3d_id", 

438 ancillary_order_by_attr_name="stage", 

439 ) # type: List[IDED3DStage] 

440 

441 @staticmethod 

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

443 _ = req.gettext 

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

445 

446 def is_complete(self) -> bool: 

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

448 

449 def get_stage_html(self) -> str: 

450 html = IDED3DStage.get_html_table_header() 

451 # noinspection PyTypeChecker 

452 for s in self.stages: 

453 html += s.get_html_table_row() 

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

455 return html 

456 

457 def get_trial_html(self) -> str: 

458 html = IDED3DTrial.get_html_table_header() 

459 # noinspection PyTypeChecker 

460 for t in self.trials: 

461 html += t.get_html_table_row() 

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

463 return html 

464 

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

466 h = f""" 

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

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

469 {self.get_is_complete_tr(req)} 

470 </table> 

471 </div> 

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

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

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

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

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

477 </div> 

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

479 <tr> 

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

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

482 </tr> 

483 """ 

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

485 h += tr_qa( 

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

487 self.max_trials_per_stage, 

488 ) 

489 h += tr_qa( 

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

491 self.progress_criterion_x, 

492 ) 

493 h += tr_qa( 

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

495 self.progress_criterion_y, 

496 ) 

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

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

499 h += tr_qa( 

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

501 ) 

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

503 h += tr_qa( 

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

505 self.counterbalance_dimensions, 

506 ) 

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

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

509 h += tr_qa( 

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

511 self.debug_display_stimuli_only, 

512 ) 

513 h += tr_qa( 

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

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

516 ws.webify(self.shape_definitions_svg), 

517 ) 

518 h += f""" 

519 </table> 

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

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

522 """ 

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

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

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

526 h += ( 

527 """ 

528 </table> 

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

530 """ 

531 + self.get_stage_html() 

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

533 + self.get_trial_html() 

534 + f""" 

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

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

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

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

539 dimension (for stages ED–EDr). 

540 0: shape/colour. 

541 1: colour/number. 

542 2: number/shape. 

543 3: shape/number. 

544 4: colour/shape. 

545 5: number/colour. 

546 </div> 

547 """ 

548 ) 

549 return h