Coverage for tasks/moca.py: 54%

124 statements  

« prev     ^ index     » next       coverage.py v7.9.2, created at 2025-07-15 14:23 +0100

1""" 

2camcops_server/tasks/moca.py 

3 

4=============================================================================== 

5 

6 Copyright (C) 2012, University of Cambridge, Department of Psychiatry. 

7 Created by Rudolf Cardinal (rnc1001@cam.ac.uk). 

8 

9 This file is part of CamCOPS. 

10 

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. 

15 

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. 

20 

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/>. 

23 

24=============================================================================== 

25 

26""" 

27 

28from typing import Any, cast, List, Optional, Type 

29 

30from cardinal_pythonlib.stringfunc import strseq 

31from sqlalchemy.orm import Mapped, mapped_column 

32from sqlalchemy.sql.sqltypes import Integer, String, UnicodeText 

33 

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) 

68 

69 

70WORDLIST = ["FACE", "VELVET", "CHURCH", "DAISY", "RED"] 

71 

72 

73# ============================================================================= 

74# MoCA 

75# ============================================================================= 

76 

77 

78class Moca( # type: ignore[misc] 

79 TaskHasPatientMixin, 

80 TaskHasClinicianMixin, 

81 Task, 

82): 

83 """ 

84 Server implementation of the MoCA task. 

85 """ 

86 

87 __tablename__ = "moca" 

88 shortname = "MoCA" 

89 provides_trackers = True 

90 

91 prohibits_commercial = True 

92 prohibits_research = True 

93 

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 ) 

157 

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 ) 

198 

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 ) 

221 

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 ) 

231 

232 NQUESTIONS = 28 

233 MAX_SCORE = 30 

234 

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) 

243 

244 @staticmethod 

245 def longname(req: "CamcopsRequest") -> str: 

246 _ = req.gettext 

247 return _("Montreal Cognitive Assessment") 

248 

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 ] 

268 

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 ] 

278 

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 ] 

294 

295 def is_complete(self) -> bool: 

296 return ( 

297 self.all_fields_not_none(self.QFIELDS) 

298 and self.field_contents_valid() 

299 ) 

300 

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) 

310 

311 def score_vsp(self) -> int: 

312 return cast(int, self.sum_fields(self.VSP_FIELDS)) 

313 

314 def score_naming(self) -> int: 

315 return cast(int, self.sum_fields(self.NAMING_FIELDS)) 

316 

317 def score_attention(self) -> int: 

318 return cast(int, self.sum_fields(self.ATTN_FIELDS)) 

319 

320 def score_language(self) -> int: 

321 return cast(int, self.sum_fields(self.LANG_FIELDS)) 

322 

323 def score_abstraction(self) -> int: 

324 return cast(int, self.sum_fields(self.ABSTRACTION_FIELDS)) 

325 

326 def score_memory(self) -> int: 

327 return cast(int, self.sum_fields(self.MEM_FIELDS)) 

328 

329 def score_orientation(self) -> int: 

330 return cast(int, self.sum_fields(self.ORIENTATION_FIELDS)) 

331 

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 ) 

339 

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) 

351 

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 ) 

380 

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 ) 

393 

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 ) 

403 

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 ) 

416 

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 ) 

426 

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 ) 

436 

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 ) 

501 

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 ) 

521 

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 

574 

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