Hide keyboard shortcuts

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 

2 

3""" 

4camcops_server/tasks/moca.py 

5 

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

7 

8 Copyright (C) 2012-2020 Rudolf Cardinal (rudolf@pobox.com). 

9 

10 This file is part of CamCOPS. 

11 

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. 

16 

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. 

21 

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

24 

25=============================================================================== 

26 

27""" 

28 

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

30 

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 

35 

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) 

71 

72 

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

74 

75 

76# ============================================================================= 

77# MoCA 

78# ============================================================================= 

79 

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) 

124 

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) 

154 

155 

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 

164 

165 prohibits_commercial = True 

166 prohibits_research = True 

167 

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 ) 

192 

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 

196 

197 NQUESTIONS = 28 

198 MAX_SCORE = 30 

199 

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) 

208 

209 @staticmethod 

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

211 _ = req.gettext 

212 return _("Montreal Cognitive Assessment") 

213 

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

229 

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

236 

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 ] 

248 

249 def is_complete(self) -> bool: 

250 return ( 

251 self.all_fields_not_none(self.QFIELDS) and 

252 self.field_contents_valid() 

253 ) 

254 

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 

264 

265 def score_vsp(self) -> int: 

266 return self.sum_fields(self.VSP_FIELDS) 

267 

268 def score_naming(self) -> int: 

269 return self.sum_fields(self.NAMING_FIELDS) 

270 

271 def score_attention(self) -> int: 

272 return self.sum_fields(self.ATTN_FIELDS) 

273 

274 def score_language(self) -> int: 

275 return self.sum_fields(self.LANG_FIELDS) 

276 

277 def score_abstraction(self) -> int: 

278 return self.sum_fields(self.ABSTRACTION_FIELDS) 

279 

280 def score_memory(self) -> int: 

281 return self.sum_fields(self.MEM_FIELDS) 

282 

283 def score_orientation(self) -> int: 

284 return self.sum_fields(self.ORIENTATION_FIELDS) 

285 

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

290 

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) 

302 

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 ) 

329 

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

336 

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

342 

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

350 

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

356 

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

362 

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 ) 

426 

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 ) 

438 

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 

481 

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