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