Coverage for tasks/moca.py : 55%

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