Coverage for tasks/ided3d.py: 69%
135 statements
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-15 14:23 +0100
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-15 14:23 +0100
1"""
2camcops_server/tasks/ided3d.py
4===============================================================================
6 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
7 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
9 This file is part of CamCOPS.
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.
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.
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/>.
24===============================================================================
26"""
28from typing import Any, List, Optional, Type
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
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
59# =============================================================================
60# Helper functions
61# =============================================================================
64def a(x: Any) -> str:
65 """
66 Answer formatting for this task.
67 """
68 return answer(x, formatter_answer=identity, default="")
71# =============================================================================
72# IDED3D
73# =============================================================================
76class IDED3DTrial(GenericTabletRecordMixin, TaskDescendant, Base):
77 __tablename__ = "ided3d_trials"
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 )
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 )
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 )
119 # Trial
120 trial_start_time: Mapped[Optional[Pendulum]] = mapped_column(
121 PendulumDateTimeAsIsoTextColType,
122 comment="Trial start time / stimuli presented at (ISO-8601)",
123 )
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 )
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 """
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 )
190 # -------------------------------------------------------------------------
191 # TaskDescendant overrides
192 # -------------------------------------------------------------------------
194 @classmethod
195 def task_ancestor_class(cls) -> Optional[Type["Task"]]:
196 return IDED3D
198 def task_ancestor(self) -> Optional["IDED3D"]:
199 return IDED3D.get_linked(self.ided3d_id, self) # type: ignore[return-value] # noqa: E501
202class IDED3DStage(GenericTabletRecordMixin, TaskDescendant, Base):
203 __tablename__ = "ided3d_stages"
205 ided3d_id: Mapped[int] = mapped_column(comment="FK to ided3d")
206 stage: Mapped[int] = mapped_column(comment="Stage number (1-based)")
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 )
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 )
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 """
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 )
336 # -------------------------------------------------------------------------
337 # TaskDescendant overrides
338 # -------------------------------------------------------------------------
340 @classmethod
341 def task_ancestor_class(cls) -> Optional[Type["Task"]]:
342 return IDED3D
344 def task_ancestor(self) -> Optional["IDED3D"]:
345 return IDED3D.get_linked(self.ided3d_id, self) # type: ignore[return-value] # noqa: E501
348class IDED3D(TaskHasPatientMixin, Task): # type: ignore[misc]
349 """
350 Server implementation of the ID/ED-3D task.
351 """
353 __tablename__ = "ided3d"
354 shortname = "ID/ED-3D"
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 )
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 )
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 )
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]
441 @staticmethod
442 def longname(req: "CamcopsRequest") -> str:
443 _ = req.gettext
444 return _("Three-dimensional ID/ED task")
446 def is_complete(self) -> bool:
447 return bool(self.debug_display_stimuli_only) or bool(self.finished)
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
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
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