Coverage for tasks/moca.py: 54%

1"""

2 camcops_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

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

29

30 from cardinal_pythonlib.stringfunc import strseq

31 from sqlalchemy.orm import Mapped, mapped_column

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

33

34 from camcops_server.cc_modules.cc_blob import (

35 Blob,

36 blob_relationship,

37 get_blob_img_html,

38)

39 from camcops_server.cc_modules.cc_constants import CssClass, PV

40 from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo

41 from camcops_server.cc_modules.cc_db import add_multiple_columns

42 from camcops_server.cc_modules.cc_html import (

43 answer,

44 italic,

45 subheading_spanning_two_columns,

46 td,

47 tr,

48 tr_qa,

49)

50 from camcops_server.cc_modules.cc_request import CamcopsRequest

51 from camcops_server.cc_modules.cc_snomed import SnomedExpression, SnomedLookup

52 from camcops_server.cc_modules.cc_sqla_coltypes import (

53 BIT_CHECKER,

54 mapped_camcops_column,

55)

56 from camcops_server.cc_modules.cc_summaryelement import SummaryElement

57 from camcops_server.cc_modules.cc_task import (

58 Task,

59 TaskHasClinicianMixin,

60 TaskHasPatientMixin,

61)

62 from camcops_server.cc_modules.cc_text import SS

63 from camcops_server.cc_modules.cc_trackerhelpers import (

64 LabelAlignment,

65 TrackerInfo,

66 TrackerLabel,

67)

68

69

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

71

72

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

74# MoCA

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

76

77

78 class 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