Coverage for tasks/ided3d.py : 69%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1#!/usr/bin/env python
3"""
4camcops_server/tasks/ided3d.py
6===============================================================================
8 Copyright (C) 2012-2020 Rudolf Cardinal (rudolf@pobox.com).
10 This file is part of CamCOPS.
12 CamCOPS is free software: you can redistribute it and/or modify
13 it under the terms of the GNU General Public License as published by
14 the Free Software Foundation, either version 3 of the License, or
15 (at your option) any later version.
17 CamCOPS is distributed in the hope that it will be useful,
18 but WITHOUT ANY WARRANTY; without even the implied warranty of
19 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
20 GNU General Public License for more details.
22 You should have received a copy of the GNU General Public License
23 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>.
25===============================================================================
27"""
29from typing import Any, List, Optional, Type
31import cardinal_pythonlib.rnc_web as ws
32from sqlalchemy.sql.schema import Column
33from sqlalchemy.sql.sqltypes import Boolean, Float, Integer, Text
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 CamcopsColumn,
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
59def a(x: Any) -> str:
60 """Answer formatting for this task."""
61 return answer(x, formatter_answer=identity, default="")
64# =============================================================================
65# IDED3D
66# =============================================================================
68class IDED3DTrial(GenericTabletRecordMixin, TaskDescendant, Base):
69 __tablename__ = "ided3d_trials"
71 ided3d_id = Column(
72 "ided3d_id", Integer,
73 nullable=False,
74 comment="FK to ided3d"
75 )
76 trial = Column(
77 "trial", Integer,
78 nullable=False,
79 comment="Trial number (1-based)"
80 )
81 stage = Column(
82 "stage", Integer,
83 comment="Stage number (1-based)"
84 )
86 # Locations
87 correct_location = Column(
88 "correct_location", Integer,
89 comment="Location of correct stimulus "
90 "(0 top, 1 right, 2 bottom, 3 left)"
91 )
92 incorrect_location = Column(
93 "incorrect_location", Integer,
94 comment="Location of incorrect stimulus "
95 "(0 top, 1 right, 2 bottom, 3 left)"
96 )
98 # Stimuli
99 correct_shape = Column(
100 "correct_shape", Integer,
101 comment="Shape# of correct stimulus"
102 )
103 correct_colour = CamcopsColumn(
104 "correct_colour", Text,
105 exempt_from_anonymisation=True,
106 comment="HTML colour of correct stimulus"
107 )
108 correct_number = Column(
109 "correct_number", Integer,
110 comment="Number of copies of correct stimulus"
111 )
112 incorrect_shape = Column(
113 "incorrect_shape", Integer,
114 comment="Shape# of incorrect stimulus"
115 )
116 incorrect_colour = CamcopsColumn(
117 "incorrect_colour", Text,
118 exempt_from_anonymisation=True,
119 comment="HTML colour of incorrect stimulus"
120 )
121 incorrect_number = Column(
122 "incorrect_number", Integer,
123 comment="Number of copies of incorrect stimulus"
124 )
126 # Trial
127 trial_start_time = Column(
128 "trial_start_time", PendulumDateTimeAsIsoTextColType,
129 comment="Trial start time / stimuli presented at (ISO-8601)"
130 )
132 # Response
133 responded = CamcopsColumn(
134 "responded", Boolean,
135 permitted_value_checker=BIT_CHECKER,
136 comment="Did the subject respond?"
137 )
138 response_time = Column(
139 "response_time", PendulumDateTimeAsIsoTextColType,
140 comment="Time of response (ISO-8601)"
141 )
142 response_latency_ms = Column(
143 "response_latency_ms", Integer,
144 comment="Response latency (ms)"
145 )
146 correct = CamcopsColumn(
147 "correct", Boolean,
148 permitted_value_checker=BIT_CHECKER,
149 comment="Response was correct"
150 )
151 incorrect = CamcopsColumn(
152 "incorrect", Boolean,
153 permitted_value_checker=BIT_CHECKER,
154 comment="Response was incorrect"
155 )
157 @classmethod
158 def get_html_table_header(cls) -> str:
159 return f"""
160 <table class="{CssClass.EXTRADETAIL}">
161 <tr>
162 <th>Trial#</th>
163 <th>Stage#</th>
164 <th>Correct location</th>
165 <th>Incorrect location</th>
166 <th>Correct shape</th>
167 <th>Correct colour</th>
168 <th>Correct number</th>
169 <th>Incorrect shape</th>
170 <th>Incorrect colour</th>
171 <th>Incorrect number</th>
172 <th>Trial start time</th>
173 <th>Responded?</th>
174 <th>Response time</th>
175 <th>Response latency (ms)</th>
176 <th>Correct?</th>
177 <th>Incorrect?</th>
178 </tr>
179 """
181 def get_html_table_row(self) -> str:
182 return tr(
183 a(self.trial),
184 a(self.stage),
185 a(self.correct_location),
186 a(self.incorrect_location),
187 a(self.correct_shape),
188 a(self.correct_colour),
189 a(self.correct_number),
190 a(self.incorrect_shape),
191 a(self.incorrect_colour),
192 a(self.incorrect_number),
193 a(self.trial_start_time),
194 a(self.responded),
195 a(self.response_time),
196 a(self.response_latency_ms),
197 a(self.correct),
198 a(self.incorrect),
199 )
201 # -------------------------------------------------------------------------
202 # TaskDescendant overrides
203 # -------------------------------------------------------------------------
205 @classmethod
206 def task_ancestor_class(cls) -> Optional[Type["Task"]]:
207 return IDED3D
209 def task_ancestor(self) -> Optional["IDED3D"]:
210 return IDED3D.get_linked(self.ided3d_id, self)
213class IDED3DStage(GenericTabletRecordMixin, TaskDescendant, Base):
214 __tablename__ = "ided3d_stages"
216 ided3d_id = Column(
217 "ided3d_id", Integer,
218 nullable=False,
219 comment="FK to ided3d"
220 )
221 stage = Column(
222 "stage", Integer,
223 nullable=False,
224 comment="Stage number (1-based)"
225 )
227 # Config
228 stage_name = CamcopsColumn(
229 "stage_name", Text,
230 exempt_from_anonymisation=True,
231 comment="Name of the stage (e.g. SD, EDr)"
232 )
233 relevant_dimension = CamcopsColumn(
234 "relevant_dimension", Text,
235 exempt_from_anonymisation=True,
236 comment="Relevant dimension (e.g. shape, colour, number)"
237 )
238 correct_exemplar = CamcopsColumn(
239 "correct_exemplar", Text,
240 exempt_from_anonymisation=True,
241 comment="Correct exemplar (from relevant dimension)"
242 )
243 incorrect_exemplar = CamcopsColumn(
244 "incorrect_exemplar", Text,
245 exempt_from_anonymisation=True,
246 comment="Incorrect exemplar (from relevant dimension)"
247 )
248 correct_stimulus_shapes = CamcopsColumn(
249 "correct_stimulus_shapes", Text,
250 exempt_from_anonymisation=True,
251 comment="Possible shapes for correct stimulus "
252 "(CSV list of shape numbers)"
253 )
254 correct_stimulus_colours = CamcopsColumn(
255 "correct_stimulus_colours", Text,
256 exempt_from_anonymisation=True,
257 comment="Possible colours for correct stimulus "
258 "(CSV list of HTML colours)"
259 )
260 correct_stimulus_numbers = CamcopsColumn(
261 "correct_stimulus_numbers", Text,
262 exempt_from_anonymisation=True,
263 comment="Possible numbers for correct stimulus "
264 "(CSV list of numbers)"
265 )
266 incorrect_stimulus_shapes = CamcopsColumn(
267 "incorrect_stimulus_shapes", Text,
268 exempt_from_anonymisation=True,
269 comment="Possible shapes for incorrect stimulus "
270 "(CSV list of shape numbers)"
271 )
272 incorrect_stimulus_colours = CamcopsColumn(
273 "incorrect_stimulus_colours", Text,
274 exempt_from_anonymisation=True,
275 comment="Possible colours for incorrect stimulus "
276 "(CSV list of HTML colours)"
277 )
278 incorrect_stimulus_numbers = CamcopsColumn(
279 "incorrect_stimulus_numbers", Text,
280 exempt_from_anonymisation=True,
281 comment="Possible numbers for incorrect stimulus "
282 "(CSV list of numbers)"
283 )
285 # Results
286 first_trial_num = Column(
287 "first_trial_num", Integer,
288 comment="Number of the first trial of the stage (1-based)"
289 )
290 n_completed_trials = Column(
291 "n_completed_trials", Integer,
292 comment="Number of trials completed"
293 )
294 n_correct = Column(
295 "n_correct", Integer,
296 comment="Number of trials performed correctly"
297 )
298 n_incorrect = Column(
299 "n_incorrect", Integer,
300 comment="Number of trials performed incorrectly"
301 )
302 stage_passed = CamcopsColumn(
303 "stage_passed", Boolean,
304 permitted_value_checker=BIT_CHECKER,
305 comment="Subject met criterion and passed stage"
306 )
307 stage_failed = CamcopsColumn(
308 "stage_failed", Boolean,
309 permitted_value_checker=BIT_CHECKER,
310 comment="Subject took too many trials and failed stage"
311 )
313 @classmethod
314 def get_html_table_header(cls) -> str:
315 return f"""
316 <table class="{CssClass.EXTRADETAIL}">
317 <tr>
318 <th>Stage#</th>
319 <th>Stage name</th>
320 <th>Relevant dimension</th>
321 <th>Correct exemplar</th>
322 <th>Incorrect exemplar</th>
323 <th>Shapes for correct</th>
324 <th>Colours for correct</th>
325 <th>Numbers for correct</th>
326 <th>Shapes for incorrect</th>
327 <th>Colours for incorrect</th>
328 <th>Numbers for incorrect</th>
329 <th>First trial#</th>
330 <th>#completed trials</th>
331 <th>#correct</th>
332 <th>#incorrect</th>
333 <th>Passed?</th>
334 <th>Failed?</th>
335 </tr>
336 """
338 def get_html_table_row(self) -> str:
339 return tr(
340 a(self.stage),
341 a(self.stage_name),
342 a(self.relevant_dimension),
343 a(self.correct_exemplar),
344 a(self.incorrect_exemplar),
345 a(self.correct_stimulus_shapes),
346 a(self.correct_stimulus_colours),
347 a(self.correct_stimulus_numbers),
348 a(self.incorrect_stimulus_shapes),
349 a(self.incorrect_stimulus_colours),
350 a(self.incorrect_stimulus_numbers),
351 a(self.first_trial_num),
352 a(self.n_completed_trials),
353 a(self.n_correct),
354 a(self.n_incorrect),
355 a(self.stage_passed),
356 a(self.stage_failed),
357 )
359 # -------------------------------------------------------------------------
360 # TaskDescendant overrides
361 # -------------------------------------------------------------------------
363 @classmethod
364 def task_ancestor_class(cls) -> Optional[Type["Task"]]:
365 return IDED3D
367 def task_ancestor(self) -> Optional["IDED3D"]:
368 return IDED3D.get_linked(self.ided3d_id, self)
371class IDED3D(TaskHasPatientMixin, Task):
372 """
373 Server implementation of the ID/ED-3D task.
374 """
375 __tablename__ = "ided3d"
376 shortname = "ID/ED-3D"
378 # Config
379 last_stage = Column(
380 "last_stage", Integer,
381 comment="Last stage to offer (1 [SD] - 8 [EDR])"
382 )
383 max_trials_per_stage = Column(
384 "max_trials_per_stage", Integer,
385 comment="Maximum number of trials allowed per stage before "
386 "the task aborts"
387 )
388 progress_criterion_x = Column(
389 "progress_criterion_x", Integer,
390 comment='Criterion to proceed to next stage: X correct out of'
391 ' the last Y trials, where this is X'
392 )
393 progress_criterion_y = Column(
394 "progress_criterion_y", Integer,
395 comment='Criterion to proceed to next stage: X correct out of'
396 ' the last Y trials, where this is Y'
397 )
398 min_number = Column(
399 "min_number", Integer,
400 comment="Minimum number of stimulus element to use"
401 )
402 max_number = Column(
403 "max_number", Integer,
404 comment="Maximum number of stimulus element to use"
405 )
406 pause_after_beep_ms = Column(
407 "pause_after_beep_ms", Integer,
408 comment="Time to continue visual feedback after auditory "
409 "feedback finished (ms)"
410 )
411 iti_ms = Column(
412 "iti_ms", Integer,
413 comment="Intertrial interval (ms)"
414 )
415 counterbalance_dimensions = Column(
416 "counterbalance_dimensions", Integer,
417 comment="Dimensional counterbalancing condition (0-5)"
418 )
419 volume = Column(
420 "volume", Float,
421 comment="Sound volume (0.0-1.0)"
422 )
423 offer_abort = CamcopsColumn(
424 "offer_abort", Boolean,
425 permitted_value_checker=BIT_CHECKER,
426 comment="Offer an abort button?"
427 )
428 debug_display_stimuli_only = CamcopsColumn(
429 "debug_display_stimuli_only", Boolean,
430 permitted_value_checker=BIT_CHECKER,
431 comment="DEBUG: show stimuli only, don't run task"
432 )
434 # Intrinsic config
435 shape_definitions_svg = CamcopsColumn(
436 "shape_definitions_svg", Text,
437 exempt_from_anonymisation=True,
438 comment="JSON-encoded version of shape definition"
439 " array in SVG format (with arbitrary scale of -60 to"
440 " +60 in both X and Y dimensions)"
441 )
442 colour_definitions_rgb = CamcopsColumn( # v2.0.0
443 "colour_definitions_rgb", Text,
444 exempt_from_anonymisation=True,
445 comment="JSON-encoded version of colour RGB definitions"
446 )
448 # Results
449 aborted = Column(
450 "aborted", Integer,
451 comment="Was the task aborted? (0 no, 1 yes)"
452 )
453 finished = Column(
454 "finished", Integer,
455 comment="Was the task finished? (0 no, 1 yes)"
456 )
457 last_trial_completed = Column(
458 "last_trial_completed", Integer,
459 comment="Number of last trial completed"
460 )
462 # Relationships
463 trials = ancillary_relationship(
464 parent_class_name="IDED3D",
465 ancillary_class_name="IDED3DTrial",
466 ancillary_fk_to_parent_attr_name="ided3d_id",
467 ancillary_order_by_attr_name="trial"
468 ) # type: List[IDED3DTrial]
469 stages = ancillary_relationship(
470 parent_class_name="IDED3D",
471 ancillary_class_name="IDED3DStage",
472 ancillary_fk_to_parent_attr_name="ided3d_id",
473 ancillary_order_by_attr_name="stage"
474 ) # type: List[IDED3DStage]
476 @staticmethod
477 def longname(req: "CamcopsRequest") -> str:
478 _ = req.gettext
479 return _("Three-dimensional ID/ED task")
481 def is_complete(self) -> bool:
482 return bool(self.debug_display_stimuli_only) or bool(self.finished)
484 def get_stage_html(self) -> str:
485 html = IDED3DStage.get_html_table_header()
486 # noinspection PyTypeChecker
487 for s in self.stages:
488 html += s.get_html_table_row()
489 html += """</table>"""
490 return html
492 def get_trial_html(self) -> str:
493 html = IDED3DTrial.get_html_table_header()
494 # noinspection PyTypeChecker
495 for t in self.trials:
496 html += t.get_html_table_row()
497 html += """</table>"""
498 return html
500 def get_task_html(self, req: CamcopsRequest) -> str:
501 h = f"""
502 <div class="{CssClass.SUMMARY}">
503 <table class="{CssClass.SUMMARY}">
504 {self.get_is_complete_tr(req)}
505 </table>
506 </div>
507 <div class="{CssClass.EXPLANATION}">
508 1. Simple discrimination (SD), and 2. reversal (SDr);
509 3. compound discrimination (CD), and 4. reversal (CDr);
510 5. intradimensional shift (ID), and 6. reversal (IDr);
511 7. extradimensional shift (ED), and 8. reversal (EDr).
512 </div>
513 <table class="{CssClass.TASKCONFIG}">
514 <tr>
515 <th width="50%">Configuration variable</th>
516 <th width="50%">Value</th>
517 </tr>
518 """
519 h += tr_qa(self.wxstring(req, "last_stage"), self.last_stage)
520 h += tr_qa(self.wxstring(req, "max_trials_per_stage"),
521 self.max_trials_per_stage)
522 h += tr_qa(self.wxstring(req, "progress_criterion_x"),
523 self.progress_criterion_x)
524 h += tr_qa(self.wxstring(req, "progress_criterion_y"),
525 self.progress_criterion_y)
526 h += tr_qa(self.wxstring(req, "min_number"), self.min_number)
527 h += tr_qa(self.wxstring(req, "max_number"), self.max_number)
528 h += tr_qa(self.wxstring(req, "pause_after_beep_ms"),
529 self.pause_after_beep_ms)
530 h += tr_qa(self.wxstring(req, "iti_ms"), self.iti_ms)
531 h += tr_qa(
532 self.wxstring(req, "counterbalance_dimensions") + "<sup>[1]</sup>",
533 self.counterbalance_dimensions)
534 h += tr_qa(req.sstring(SS.VOLUME_0_TO_1), self.volume)
535 h += tr_qa(self.wxstring(req, "offer_abort"), self.offer_abort)
536 h += tr_qa(self.wxstring(req, "debug_display_stimuli_only"),
537 self.debug_display_stimuli_only)
538 h += tr_qa("Shapes (as a JSON-encoded array of SVG "
539 "definitions; X and Y range both –60 to +60)",
540 ws.webify(self.shape_definitions_svg))
541 h += f"""
542 </table>
543 <table class="{CssClass.TASKDETAIL}">
544 <tr><th width="50%">Measure</th><th width="50%">Value</th></tr>
545 """
546 h += tr_qa("Aborted?", get_yes_no_none(req, self.aborted))
547 h += tr_qa("Finished?", get_yes_no_none(req, self.finished))
548 h += tr_qa("Last trial completed", self.last_trial_completed)
549 h += (
550 """
551 </table>
552 <div>Stage specifications and results:</div>
553 """ +
554 self.get_stage_html() +
555 "<div>Trial-by-trial results:</div>" +
556 self.get_trial_html() +
557 f"""
558 <div class="{CssClass.FOOTNOTES}">
559 [1] Counterbalancing of dimensions is as follows, with
560 notation X/Y indicating that X is the first relevant
561 dimension (for stages SD–IDr) and Y is the second relevant
562 dimension (for stages ED–EDr).
563 0: shape/colour.
564 1: colour/number.
565 2: number/shape.
566 3: shape/number.
567 4: colour/shape.
568 5: number/colour.
569 </div>
570 """
571 )
572 return h