Coverage for tasks/ided3d.py: 69%
135 statements
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-08 23:14 +0000
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-08 23:14 +0000
1#!/usr/bin/env python
3"""
4camcops_server/tasks/ided3d.py
6===============================================================================
8 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
9 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
11 This file is part of CamCOPS.
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.
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.
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/>.
26===============================================================================
28"""
30from typing import Any, List, Optional, Type
32import cardinal_pythonlib.rnc_web as ws
33from sqlalchemy.sql.schema import Column
34from sqlalchemy.sql.sqltypes import Boolean, Float, Integer, Text
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
60# =============================================================================
61# Helper functions
62# =============================================================================
65def a(x: Any) -> str:
66 """
67 Answer formatting for this task.
68 """
69 return answer(x, formatter_answer=identity, default="")
72# =============================================================================
73# IDED3D
74# =============================================================================
77class IDED3DTrial(GenericTabletRecordMixin, TaskDescendant, Base):
78 __tablename__ = "ided3d_trials"
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)")
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 )
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 )
132 # Trial
133 trial_start_time = Column(
134 "trial_start_time",
135 PendulumDateTimeAsIsoTextColType,
136 comment="Trial start time / stimuli presented at (ISO-8601)",
137 )
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 )
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 """
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 )
211 # -------------------------------------------------------------------------
212 # TaskDescendant overrides
213 # -------------------------------------------------------------------------
215 @classmethod
216 def task_ancestor_class(cls) -> Optional[Type["Task"]]:
217 return IDED3D
219 def task_ancestor(self) -> Optional["IDED3D"]:
220 return IDED3D.get_linked(self.ided3d_id, self)
223class IDED3DStage(GenericTabletRecordMixin, TaskDescendant, Base):
224 __tablename__ = "ided3d_stages"
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 )
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 )
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 )
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 """
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 )
377 # -------------------------------------------------------------------------
378 # TaskDescendant overrides
379 # -------------------------------------------------------------------------
381 @classmethod
382 def task_ancestor_class(cls) -> Optional[Type["Task"]]:
383 return IDED3D
385 def task_ancestor(self) -> Optional["IDED3D"]:
386 return IDED3D.get_linked(self.ided3d_id, self)
389class IDED3D(TaskHasPatientMixin, Task):
390 """
391 Server implementation of the ID/ED-3D task.
392 """
394 __tablename__ = "ided3d"
395 shortname = "ID/ED-3D"
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 )
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 )
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 )
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]
498 @staticmethod
499 def longname(req: "CamcopsRequest") -> str:
500 _ = req.gettext
501 return _("Three-dimensional ID/ED task")
503 def is_complete(self) -> bool:
504 return bool(self.debug_display_stimuli_only) or bool(self.finished)
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
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
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