Coverage for tasks/ided3d.py: 69%

1"""

2 camcops_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

28 from typing import Any, List, Optional, Type

29

30 import cardinal_pythonlib.rnc_web as ws

31 from pendulum import DateTime as Pendulum

32 from sqlalchemy.orm import Mapped, mapped_column

33 from sqlalchemy.sql.sqltypes import Boolean, Text

34

35 from camcops_server.cc_modules.cc_constants import CssClass

36 from camcops_server.cc_modules.cc_db import (

37 ancillary_relationship,

38 GenericTabletRecordMixin,

39 TaskDescendant,

40)

41 from camcops_server.cc_modules.cc_html import (

42 answer,

43 get_yes_no_none,

44 identity,

45 tr,

46 tr_qa,

47)

48 from camcops_server.cc_modules.cc_request import CamcopsRequest

49 from camcops_server.cc_modules.cc_sqla_coltypes import (

50 BIT_CHECKER,

51 mapped_camcops_column,

52 PendulumDateTimeAsIsoTextColType,

53)

54 from camcops_server.cc_modules.cc_sqlalchemy import Base

55 from camcops_server.cc_modules.cc_task import Task, TaskHasPatientMixin

56 from camcops_server.cc_modules.cc_text import SS

57

58

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

60# Helper functions

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

62

63

64 def 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

76 class 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

202 class 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

348 class 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