Coverage for tasks/moca.py: 55%

126 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2022-11-08 23:14 +0000

1#!/usr/bin/env python 

2 

3""" 

4camcops_server/tasks/moca.py 

5 

6=============================================================================== 

7 

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

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

10 

11 This file is part of CamCOPS. 

12 

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. 

17 

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. 

22 

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

25 

26=============================================================================== 

27 

28""" 

29 

30from typing import Any, Dict, List, Optional, Tuple, Type 

31 

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 

36 

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) 

72 

73 

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

75 

76 

77# ============================================================================= 

78# MoCA 

79# ============================================================================= 

80 

81 

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) 

132 

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) 

174 

175 

176class Moca( 

177 TaskHasPatientMixin, TaskHasClinicianMixin, Task, metaclass=MocaMetaclass 

178): 

179 """ 

180 Server implementation of the MoCA task. 

181 """ 

182 

183 __tablename__ = "moca" 

184 shortname = "MoCA" 

185 provides_trackers = True 

186 

187 prohibits_commercial = True 

188 prohibits_research = True 

189 

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") 

218 

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 

228 

229 NQUESTIONS = 28 

230 MAX_SCORE = 30 

231 

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) 

240 

241 @staticmethod 

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

243 _ = req.gettext 

244 return _("Montreal Cognitive Assessment") 

245 

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 ] 

265 

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 ] 

275 

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 ] 

291 

292 def is_complete(self) -> bool: 

293 return ( 

294 self.all_fields_not_none(self.QFIELDS) 

295 and self.field_contents_valid() 

296 ) 

297 

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 

307 

308 def score_vsp(self) -> int: 

309 return self.sum_fields(self.VSP_FIELDS) 

310 

311 def score_naming(self) -> int: 

312 return self.sum_fields(self.NAMING_FIELDS) 

313 

314 def score_attention(self) -> int: 

315 return self.sum_fields(self.ATTN_FIELDS) 

316 

317 def score_language(self) -> int: 

318 return self.sum_fields(self.LANG_FIELDS) 

319 

320 def score_abstraction(self) -> int: 

321 return self.sum_fields(self.ABSTRACTION_FIELDS) 

322 

323 def score_memory(self) -> int: 

324 return self.sum_fields(self.MEM_FIELDS) 

325 

326 def score_orientation(self) -> int: 

327 return self.sum_fields(self.ORIENTATION_FIELDS) 

328 

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 ) 

336 

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) 

348 

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 ) 

377 

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 ) 

390 

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 ) 

400 

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 ) 

413 

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 ) 

423 

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 ) 

433 

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 ) 

498 

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 ) 

518 

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 

571 

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