Coverage for tasks/moca.py: 55%
126 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/moca.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, Dict, List, Optional, Tuple, Type
32from cardinal_pythonlib.stringfunc import strseq
33from sqlalchemy.ext.declarative import DeclarativeMeta
34from sqlalchemy.sql.schema import Column
35from sqlalchemy.sql.sqltypes import Integer, String, UnicodeText
37from camcops_server.cc_modules.cc_blob import (
38 Blob,
39 blob_relationship,
40 get_blob_img_html,
41)
42from camcops_server.cc_modules.cc_constants import CssClass, PV
43from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo
44from camcops_server.cc_modules.cc_db import add_multiple_columns
45from camcops_server.cc_modules.cc_html import (
46 answer,
47 italic,
48 subheading_spanning_two_columns,
49 td,
50 tr,
51 tr_qa,
52)
53from camcops_server.cc_modules.cc_request import CamcopsRequest
54from camcops_server.cc_modules.cc_snomed import SnomedExpression, SnomedLookup
55from camcops_server.cc_modules.cc_sqla_coltypes import (
56 BIT_CHECKER,
57 CamcopsColumn,
58 ZERO_TO_THREE_CHECKER,
59)
60from camcops_server.cc_modules.cc_summaryelement import SummaryElement
61from camcops_server.cc_modules.cc_task import (
62 Task,
63 TaskHasClinicianMixin,
64 TaskHasPatientMixin,
65)
66from camcops_server.cc_modules.cc_text import SS
67from camcops_server.cc_modules.cc_trackerhelpers import (
68 LabelAlignment,
69 TrackerInfo,
70 TrackerLabel,
71)
74WORDLIST = ["FACE", "VELVET", "CHURCH", "DAISY", "RED"]
77# =============================================================================
78# MoCA
79# =============================================================================
82class MocaMetaclass(DeclarativeMeta):
83 # noinspection PyInitNewSignature
84 def __init__(
85 cls: Type["Moca"],
86 name: str,
87 bases: Tuple[Type, ...],
88 classdict: Dict[str, Any],
89 ) -> None:
90 add_multiple_columns(
91 cls,
92 "q",
93 1,
94 cls.NQUESTIONS,
95 minimum=0,
96 maximum=1, # see below
97 comment_fmt="{s}",
98 comment_strings=[
99 "Q1 (VSE/path) (0-1)",
100 "Q2 (VSE/cube) (0-1)",
101 "Q3 (VSE/clock/contour) (0-1)",
102 "Q4 (VSE/clock/numbers) (0-1)",
103 "Q5 (VSE/clock/hands) (0-1)",
104 "Q6 (naming/lion) (0-1)",
105 "Q7 (naming/rhino) (0-1)",
106 "Q8 (naming/camel) (0-1)",
107 "Q9 (attention/5 digits) (0-1)",
108 "Q10 (attention/3 digits) (0-1)",
109 "Q11 (attention/tapping) (0-1)",
110 "Q12 (attention/serial 7s) (0-3)", # different max
111 "Q13 (language/sentence 1) (0-1)",
112 "Q14 (language/sentence 2) (0-1)",
113 "Q15 (language/fluency) (0-1)",
114 "Q16 (abstraction 1) (0-1)",
115 "Q17 (abstraction 2) (0-1)",
116 "Q18 (recall word/face) (0-1)",
117 "Q19 (recall word/velvet) (0-1)",
118 "Q20 (recall word/church) (0-1)",
119 "Q21 (recall word/daisy) (0-1)",
120 "Q22 (recall word/red) (0-1)",
121 "Q23 (orientation/date) (0-1)",
122 "Q24 (orientation/month) (0-1)",
123 "Q25 (orientation/year) (0-1)",
124 "Q26 (orientation/day) (0-1)",
125 "Q27 (orientation/place) (0-1)",
126 "Q28 (orientation/city) (0-1)",
127 ],
128 )
129 # Fix maximum for Q12:
130 # noinspection PyUnresolvedReferences
131 cls.q12.set_permitted_value_checker(ZERO_TO_THREE_CHECKER)
133 add_multiple_columns(
134 cls,
135 "register_trial1_",
136 1,
137 5,
138 pv=PV.BIT,
139 comment_fmt="Registration, trial 1 (not scored), {n}: {s} "
140 "(0 or 1)",
141 comment_strings=WORDLIST,
142 )
143 add_multiple_columns(
144 cls,
145 "register_trial2_",
146 1,
147 5,
148 pv=PV.BIT,
149 comment_fmt="Registration, trial 2 (not scored), {n}: {s} "
150 "(0 or 1)",
151 comment_strings=WORDLIST,
152 )
153 add_multiple_columns(
154 cls,
155 "recall_category_cue_",
156 1,
157 5,
158 pv=PV.BIT,
159 comment_fmt="Recall with category cue (not scored), {n}: {s} "
160 "(0 or 1)",
161 comment_strings=WORDLIST,
162 )
163 add_multiple_columns(
164 cls,
165 "recall_mc_cue_",
166 1,
167 5,
168 pv=PV.BIT,
169 comment_fmt="Recall with multiple-choice cue (not scored), "
170 "{n}: {s} (0 or 1)",
171 comment_strings=WORDLIST,
172 )
173 super().__init__(name, bases, classdict)
176class Moca(
177 TaskHasPatientMixin, TaskHasClinicianMixin, Task, metaclass=MocaMetaclass
178):
179 """
180 Server implementation of the MoCA task.
181 """
183 __tablename__ = "moca"
184 shortname = "MoCA"
185 provides_trackers = True
187 prohibits_commercial = True
188 prohibits_research = True
190 education12y_or_less = CamcopsColumn(
191 "education12y_or_less",
192 Integer,
193 permitted_value_checker=BIT_CHECKER,
194 comment="<=12 years of education (0 no, 1 yes)",
195 )
196 trailpicture_blobid = CamcopsColumn(
197 "trailpicture_blobid",
198 Integer,
199 is_blob_id_field=True,
200 blob_relationship_attr_name="trailpicture",
201 comment="BLOB ID of trail picture",
202 )
203 cubepicture_blobid = CamcopsColumn(
204 "cubepicture_blobid",
205 Integer,
206 is_blob_id_field=True,
207 blob_relationship_attr_name="cubepicture",
208 comment="BLOB ID of cube picture",
209 )
210 clockpicture_blobid = CamcopsColumn(
211 "clockpicture_blobid",
212 Integer,
213 is_blob_id_field=True,
214 blob_relationship_attr_name="clockpicture",
215 comment="BLOB ID of clock picture",
216 )
217 comments = Column("comments", UnicodeText, comment="Clinician's comments")
219 trailpicture = blob_relationship(
220 "Moca", "trailpicture_blobid"
221 ) # type: Optional[Blob] # noqa
222 cubepicture = blob_relationship(
223 "Moca", "cubepicture_blobid"
224 ) # type: Optional[Blob] # noqa
225 clockpicture = blob_relationship(
226 "Moca", "clockpicture_blobid"
227 ) # type: Optional[Blob] # noqa
229 NQUESTIONS = 28
230 MAX_SCORE = 30
232 QFIELDS = strseq("q", 1, NQUESTIONS)
233 VSP_FIELDS = strseq("q", 1, 5)
234 NAMING_FIELDS = strseq("q", 6, 8)
235 ATTN_FIELDS = strseq("q", 9, 12)
236 LANG_FIELDS = strseq("q", 13, 15)
237 ABSTRACTION_FIELDS = strseq("q", 16, 17)
238 MEM_FIELDS = strseq("q", 18, 22)
239 ORIENTATION_FIELDS = strseq("q", 23, 28)
241 @staticmethod
242 def longname(req: "CamcopsRequest") -> str:
243 _ = req.gettext
244 return _("Montreal Cognitive Assessment")
246 def get_trackers(self, req: CamcopsRequest) -> List[TrackerInfo]:
247 return [
248 TrackerInfo(
249 value=self.total_score(),
250 plot_label="MOCA total score",
251 axis_label=f"Total score (out of {self.MAX_SCORE})",
252 axis_min=-0.5,
253 axis_max=(self.MAX_SCORE + 0.5),
254 horizontal_lines=[25.5],
255 horizontal_labels=[
256 TrackerLabel(
257 26, req.sstring(SS.NORMAL), LabelAlignment.bottom
258 ),
259 TrackerLabel(
260 25, req.sstring(SS.ABNORMAL), LabelAlignment.top
261 ),
262 ],
263 )
264 ]
266 def get_clinical_text(self, req: CamcopsRequest) -> List[CtvInfo]:
267 if not self.is_complete():
268 return CTV_INCOMPLETE
269 return [
270 CtvInfo(
271 content=f"MOCA total score "
272 f"{self.total_score()}/{self.MAX_SCORE}"
273 )
274 ]
276 def get_summaries(self, req: CamcopsRequest) -> List[SummaryElement]:
277 return self.standard_task_summary_fields() + [
278 SummaryElement(
279 name="total",
280 coltype=Integer(),
281 value=self.total_score(),
282 comment=f"Total score (/{self.MAX_SCORE})",
283 ),
284 SummaryElement(
285 name="category",
286 coltype=String(50),
287 value=self.category(req),
288 comment="Categorization",
289 ),
290 ]
292 def is_complete(self) -> bool:
293 return (
294 self.all_fields_not_none(self.QFIELDS)
295 and self.field_contents_valid()
296 )
298 def total_score(self) -> int:
299 score = self.sum_fields(self.QFIELDS)
300 # Interpretation of the educational extra point: see moca.cpp; we have
301 # a choice of allowing 31/30 or capping at 30. I think the instructions
302 # imply a cap of 30.
303 if score < self.MAX_SCORE:
304 score += self.sum_fields(["education12y_or_less"])
305 # extra point for this
306 return score
308 def score_vsp(self) -> int:
309 return self.sum_fields(self.VSP_FIELDS)
311 def score_naming(self) -> int:
312 return self.sum_fields(self.NAMING_FIELDS)
314 def score_attention(self) -> int:
315 return self.sum_fields(self.ATTN_FIELDS)
317 def score_language(self) -> int:
318 return self.sum_fields(self.LANG_FIELDS)
320 def score_abstraction(self) -> int:
321 return self.sum_fields(self.ABSTRACTION_FIELDS)
323 def score_memory(self) -> int:
324 return self.sum_fields(self.MEM_FIELDS)
326 def score_orientation(self) -> int:
327 return self.sum_fields(self.ORIENTATION_FIELDS)
329 def category(self, req: CamcopsRequest) -> str:
330 totalscore = self.total_score()
331 return (
332 req.sstring(SS.NORMAL)
333 if totalscore >= 26
334 else req.sstring(SS.ABNORMAL)
335 )
337 # noinspection PyUnresolvedReferences
338 def get_task_html(self, req: CamcopsRequest) -> str:
339 vsp = self.score_vsp()
340 naming = self.score_naming()
341 attention = self.score_attention()
342 language = self.score_language()
343 abstraction = self.score_abstraction()
344 memory = self.score_memory()
345 orientation = self.score_orientation()
346 totalscore = self.total_score()
347 category = self.category(req)
349 h = """
350 {clinician_comments}
351 <div class="{CssClass.SUMMARY}">
352 <table class="{CssClass.SUMMARY}">
353 {tr_is_complete}
354 {total_score}
355 {category}
356 </table>
357 </div>
358 <table class="{CssClass.TASKDETAIL}">
359 <tr>
360 <th width="69%">Question</th>
361 <th width="31%">Score</th>
362 </tr>
363 """.format(
364 clinician_comments=self.get_standard_clinician_comments_block(
365 req, self.comments
366 ),
367 CssClass=CssClass,
368 tr_is_complete=self.get_is_complete_tr(req),
369 total_score=tr(
370 req.sstring(SS.TOTAL_SCORE),
371 answer(totalscore) + f" / {self.MAX_SCORE}",
372 ),
373 category=tr_qa(
374 self.wxstring(req, "category") + " <sup>[1]</sup>", category
375 ),
376 )
378 h += tr(
379 self.wxstring(req, "subscore_visuospatial"),
380 answer(vsp) + " / 5",
381 tr_class=CssClass.SUBHEADING,
382 )
383 h += tr(
384 "Path, cube, clock/contour, clock/numbers, clock/hands",
385 ", ".join(
386 answer(x)
387 for x in (self.q1, self.q2, self.q3, self.q4, self.q5)
388 ),
389 )
391 h += tr(
392 self.wxstring(req, "subscore_naming"),
393 answer(naming) + " / 3",
394 tr_class=CssClass.SUBHEADING,
395 )
396 h += tr(
397 "Lion, rhino, camel",
398 ", ".join(answer(x) for x in (self.q6, self.q7, self.q8)),
399 )
401 h += tr(
402 self.wxstring(req, "subscore_attention"),
403 answer(attention) + " / 6",
404 tr_class=CssClass.SUBHEADING,
405 )
406 h += tr(
407 "5 digits forwards, 3 digits backwards, tapping, serial 7s "
408 "[<i>scores 3</i>]",
409 ", ".join(
410 answer(x) for x in (self.q9, self.q10, self.q11, self.q12)
411 ),
412 )
414 h += tr(
415 self.wxstring(req, "subscore_language"),
416 answer(language) + " / 3",
417 tr_class=CssClass.SUBHEADING,
418 )
419 h += tr(
420 "Repeat sentence 1, repeat sentence 2, fluency to letter ‘F’",
421 ", ".join(answer(x) for x in (self.q13, self.q14, self.q15)),
422 )
424 h += tr(
425 self.wxstring(req, "subscore_abstraction"),
426 answer(abstraction) + " / 2",
427 tr_class=CssClass.SUBHEADING,
428 )
429 h += tr(
430 "Means of transportation, measuring instruments",
431 ", ".join(answer(x) for x in (self.q16, self.q17)),
432 )
434 h += tr(
435 self.wxstring(req, "subscore_memory"),
436 answer(memory) + " / 5",
437 tr_class=CssClass.SUBHEADING,
438 )
439 h += tr(
440 "Registered on first trial [<i>not scored</i>]",
441 ", ".join(
442 answer(x, formatter_answer=italic)
443 for x in (
444 self.register_trial1_1,
445 self.register_trial1_2,
446 self.register_trial1_3,
447 self.register_trial1_4,
448 self.register_trial1_5,
449 )
450 ),
451 )
452 h += tr(
453 "Registered on second trial [<i>not scored</i>]",
454 ", ".join(
455 answer(x, formatter_answer=italic)
456 for x in (
457 self.register_trial2_1,
458 self.register_trial2_2,
459 self.register_trial2_3,
460 self.register_trial2_4,
461 self.register_trial2_5,
462 )
463 ),
464 )
465 h += tr(
466 "Recall FACE, VELVET, CHURCH, DAISY, RED with no cue",
467 ", ".join(
468 answer(x)
469 for x in (self.q18, self.q19, self.q20, self.q21, self.q22)
470 ),
471 )
472 h += tr(
473 "Recall with category cue [<i>not scored</i>]",
474 ", ".join(
475 answer(x, formatter_answer=italic)
476 for x in (
477 self.recall_category_cue_1,
478 self.recall_category_cue_2,
479 self.recall_category_cue_3,
480 self.recall_category_cue_4,
481 self.recall_category_cue_5,
482 )
483 ),
484 )
485 h += tr(
486 "Recall with multiple-choice cue [<i>not scored</i>]",
487 ", ".join(
488 answer(x, formatter_answer=italic)
489 for x in (
490 self.recall_mc_cue_1,
491 self.recall_mc_cue_2,
492 self.recall_mc_cue_3,
493 self.recall_mc_cue_4,
494 self.recall_mc_cue_5,
495 )
496 ),
497 )
499 h += tr(
500 self.wxstring(req, "subscore_orientation"),
501 answer(orientation) + " / 6",
502 tr_class=CssClass.SUBHEADING,
503 )
504 h += tr(
505 "Date, month, year, day, place, city",
506 ", ".join(
507 answer(x)
508 for x in (
509 self.q23,
510 self.q24,
511 self.q25,
512 self.q26,
513 self.q27,
514 self.q28,
515 )
516 ),
517 )
519 h += subheading_spanning_two_columns(self.wxstring(req, "education_s"))
520 h += tr_qa("≤12 years’ education?", self.education12y_or_less)
521 # noinspection PyTypeChecker
522 h += """
523 </table>
524 <table class="{CssClass.TASKDETAIL}">
525 {tr_subhead_images}
526 {tr_images_1}
527 {tr_images_2}
528 </table>
529 <div class="{CssClass.FOOTNOTES}">
530 [1] Normal is ≥26 (Nasreddine et al. 2005, PubMed ID 15817019).
531 </div>
532 <div class="{CssClass.COPYRIGHT}">
533 MoCA: Copyright © Ziad Nasreddine. In 2012, could be reproduced
534 without permission for CLINICAL and EDUCATIONAL use (with
535 permission from the copyright holder required for any other
536 use), with no special restrictions on electronic versions.
537 However, as of 2021, electronic versions are prohibited without
538 specific authorization from the copyright holder; see <a
539 href="https://camcops.readthedocs.io/en/latest/tasks/moca.html">
540 https://camcops.readthedocs.io/en/latest/tasks/moca.html</a>.
541 </div>
542 """.format(
543 CssClass=CssClass,
544 tr_subhead_images=subheading_spanning_two_columns(
545 "Images of tests: trail, cube, clock", th_not_td=True
546 ),
547 tr_images_1=tr(
548 td(
549 get_blob_img_html(self.trailpicture),
550 td_class=CssClass.PHOTO,
551 td_width="50%",
552 ),
553 td(
554 get_blob_img_html(self.cubepicture),
555 td_class=CssClass.PHOTO,
556 td_width="50%",
557 ),
558 literal=True,
559 ),
560 tr_images_2=tr(
561 td(
562 get_blob_img_html(self.clockpicture),
563 td_class=CssClass.PHOTO,
564 td_width="50%",
565 ),
566 td("", td_class=CssClass.SUBHEADING),
567 literal=True,
568 ),
569 )
570 return h
572 def get_snomed_codes(self, req: CamcopsRequest) -> List[SnomedExpression]:
573 codes = [
574 SnomedExpression(
575 req.snomed(SnomedLookup.MOCA_PROCEDURE_ASSESSMENT)
576 )
577 ]
578 if self.is_complete():
579 codes.append(
580 SnomedExpression(
581 req.snomed(SnomedLookup.MOCA_SCALE),
582 {req.snomed(SnomedLookup.MOCA_SCORE): self.total_score()},
583 )
584 )
585 return codes