Coverage for tasks/ace3.py: 53%

1"""

2 camcops_server/tasks/ace3.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 ACE-III and Mini-ACE.

27

28"""

29

30 from typing import Any, cast, Iterable, List, Optional, Type

31

32 from cardinal_pythonlib.stringfunc import strseq

33 import cardinal_pythonlib.rnc_web as ws

34 import numpy

35 from sqlalchemy.orm import Mapped, mapped_column

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

37

38 from camcops_server.cc_modules.cc_blob import (

39 Blob,

40 blob_relationship,

41 get_blob_img_html,

42)

43 from camcops_server.cc_modules.cc_constants import CssClass, PlotDefaults, PV

44 from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo

45 from camcops_server.cc_modules.cc_db import add_multiple_columns

46 from camcops_server.cc_modules.cc_html import (

47 answer,

48 get_yes_no_none,

49 italic,

50 pmid,

51 subheading_spanning_two_columns,

52 tr,

53 tr_qa,

54 tr_span_col,

55)

56 from camcops_server.cc_modules.cc_request import CamcopsRequest

57 from camcops_server.cc_modules.cc_snomed import SnomedExpression, SnomedLookup

58 from camcops_server.cc_modules.cc_sqla_coltypes import (

59 BIT_CHECKER,

60 mapped_camcops_column,

61 PermittedValueChecker,

62)

63 from camcops_server.cc_modules.cc_summaryelement import SummaryElement

64 from camcops_server.cc_modules.cc_task import (

65 Task,

66 TaskHasClinicianMixin,

67 TaskHasPatientMixin,

68)

69 from camcops_server.cc_modules.cc_trackerhelpers import TrackerInfo

70

71

72# =============================================================================

73# Constants

74# =============================================================================

75

76 ADDRESS_PARTS = [

77 "forename",

78 "surname",

79 "number",

80 "street_1",

81 "street_2",

82 "town",

83 "county",

84]

85 RECALL_WORDS = ["lemon", "key", "ball"]

86 PERCENT_DP = 1

87

88 TOTAL_MAX = 100

89 ATTN_MAX = 18

90 MEMORY_MAX = 26

91 FLUENCY_MAX = 14

92 LANG_MAX = 26

93 VSP_MAX = 16

94

95 ATTN_MINIACE_MAX = 4

96 MEM_MINIACE_MAX = 14

97 FLUENCY_MINIACE_MAX = 7

98 VSP_MINIACE_MAX = 5

99 MINI_ACE_MAX = 30

100

101 AGE_FTE = "Age on leaving full-time education"

102 OCCUPATION = "Occupation"

103 HANDEDNESS = "Handedness"

104 N_ATTN_TIME_ACE = 5

105 N_ATTN_TIME_MINIACE = 4

106 N_MEM_REPEAT_RECALL_ADDR = 7

107 ANIMAL_FLUENCY_SCORING_HTML = (

108 "Score for animals <i>(≥22 scores 7, 17–21 scores 6, 14–16 scores 5, "

109 "11–13 scores 4, 9–10 scores 3, 7–8 scores 2, 5–6 scores 1, "

110 "&lt;5 scores 0)</i>"

111)

112 ACE3_COPYRIGHT = """

113 ACE-III: Copyright © 2012, John Hodges. “The ACE-III is available for free. The

114 copyright is held by Professor John Hodges who is happy for the test to be used

115 in clinical practice and research projects. There is no need to contact us if

116 you wish to use the ACE-III in clinical practice.” (ACE-III FAQ, 7 July 2013,

117 www.neura.edu.au).

118"""

119 MINI_ACE_THRESHOLDS = f"""

120 In the mini-ACE, scores ≤21 had sensitivity 0.61 and specificity 1.0 for

121 dementia, and scores ≤25 had sensitivity 0.85 and specificity 0.87 for

122 dementia, in a context of patients with Alzheimer’s disease, behavioural

123 variant frontotemporal dementia, corticobasal syndrome, primary progressive

124 aphasia, and controls (Hsieh et al. 2015, {pmid(25227877)}.

125"""

126

127

128# =============================================================================

129# Ancillary functions

130# =============================================================================

131

132

133 def score_zero_for_absent(x: Optional[int]) -> int:

134 """0 if x is None else x"""

135 return 0 if x is None else x

136

137

138 def percent(score: int, maximum: int) -> str:

139 return ws.number_to_dp(100 * score / maximum, PERCENT_DP)

140

141

142 def tr_score_with_pct(title: str, score: int, maximum: int) -> str:

143 return tr(

144 title,

145 answer(score) + f" / {maximum} ({percent(score, maximum)}%)",

146 )

147

148

149 def qsequence(target_addr_parts: Iterable[str]) -> str:

150 """

151 For e.g. "Harry? Barnes? ..."

152 """

153 return " ".join(f"{x}?" for x in target_addr_parts)

154

155

156 def tr_heading(left: str, right: str) -> str:

157 """

158 HTML for header row of most tables.

159 """

160 return f"""

161 <tr>

162 <th width="67%">{left}</th>

163 <th width="33%">{right}</th>

164 </tr>

165 """

166

167

168# =============================================================================

169# ACE-III

170# =============================================================================

171

172

173 class Ace3(TaskHasPatientMixin, TaskHasClinicianMixin, Task): # type: ignore[misc] # noqa: E501

174 """

175 Server implementation of the ACE-III task.

176 """

177

178 __tablename__ = "ace3"

179 shortname = "ACE-III"

180 provides_trackers = True

181

182 prohibits_commercial = True

183

184 @classmethod

185 def extend_columns(cls: Type["Ace3"], **kwargs: Any) -> None:

186 add_multiple_columns(

187 cls,

188 "attn_time",

189 1,

190 5,

191 pv=PV.BIT,

192 comment_fmt="Attention, time, {n}/5, {s} (0 or 1)",

193 comment_strings=["day", "date", "month", "year", "season"],

194 )

195 add_multiple_columns(

196 cls,

197 "attn_place",

198 1,

199 5,

200 pv=PV.BIT,

201 comment_fmt="Attention, place, {n}/5, {s} (0 or 1)",

202 comment_strings=[

203 "house number/floor",

204 "street/hospital",

205 "town",

206 "county",

207 "country",

208 ],

209 )

210 add_multiple_columns(

211 cls,

212 "attn_repeat_word",

213 1,

214 3,

215 pv=PV.BIT,

216 comment_fmt="Attention, repeat word, {n}/3, {s} (0 or 1)",

217 comment_strings=RECALL_WORDS,

218 )

219 add_multiple_columns(

220 cls,

221 "attn_serial7_subtraction",

222 1,

223 5,

224 pv=PV.BIT,

225 comment_fmt="Attention, serial sevens, {n}/5 (0 or 1)",

226 )

227

228 add_multiple_columns(

229 cls,

230 "mem_recall_word",

231 1,

232 3,

233 pv=PV.BIT,

234 comment_fmt="Memory, recall word, {n}/3, {s} (0 or 1)",

235 comment_strings=RECALL_WORDS,

236 )

237 add_multiple_columns(

238 cls,

239 "mem_repeat_address_trial1_",

240 1,

241 N_MEM_REPEAT_RECALL_ADDR,

242 pv=PV.BIT,

243 comment_fmt="Memory, address registration trial 1/3 "

244 "(not scored), {s} (0 or 1)",

245 comment_strings=ADDRESS_PARTS,

246 )

247 add_multiple_columns(

248 cls,

249 "mem_repeat_address_trial2_",

250 1,

251 N_MEM_REPEAT_RECALL_ADDR,

252 pv=PV.BIT,

253 comment_fmt="Memory, address registration trial 2/3 "

254 "(not scored), {s} (0 or 1)",

255 comment_strings=ADDRESS_PARTS,

256 )

257 add_multiple_columns(

258 cls,

259 "mem_repeat_address_trial3_",

260 1,

261 N_MEM_REPEAT_RECALL_ADDR,

262 pv=PV.BIT,

263 comment_fmt="Memory, address registration trial 3/3 "

264 "(scored), {s} (0 or 1)",

265 comment_strings=ADDRESS_PARTS,

266 )

267 add_multiple_columns(

268 cls,

269 "mem_famous",

270 1,

271 4,

272 pv=PV.BIT,

273 comment_fmt="Memory, famous people, {n}/4, {s} (0 or 1)",

274 comment_strings=["current PM", "woman PM", "USA president", "JFK"],

275 )

276

277 add_multiple_columns(

278 cls,

279 "lang_follow_command",

280 1,

281 3,

282 pv=PV.BIT,

283 comment_fmt="Language, command {n}/3 (0 or 1)",

284 )

285 add_multiple_columns(

286 cls,

287 "lang_write_sentences_point",

288 1,

289 2,

290 pv=PV.BIT,

291 comment_fmt="Language, write sentences, {n}/2, {s} (0 or 1)",

292 comment_strings=[

293 "two sentences on same topic",

294 "grammar/spelling",

295 ],

296 )

297 add_multiple_columns(

298 cls,

299 "lang_repeat_word",

300 1,

301 4,

302 pv=PV.BIT,

303 comment_fmt="Language, repeat word, {n}/4, {s} (0 or 1)",

304 comment_strings=[

305 "caterpillar",

306 "eccentricity",

307 "unintelligible",

308 "statistician",

309 ],

310 )

311 add_multiple_columns(

312 cls,

313 "lang_repeat_sentence",

314 1,

315 2,

316 pv=PV.BIT,

317 comment_fmt="Language, repeat sentence, {n}/2, {s} (0 or 1)",

318 comment_strings=["glitters_gold", "stitch_time"],

319 )

320 add_multiple_columns(

321 cls,

322 "lang_name_picture",

323 1,

324 12,

325 pv=PV.BIT,

326 comment_fmt="Language, name picture, {n}/12, {s} (0 or 1)",

327 comment_strings=[

328 "spoon",

329 "book",

330 "kangaroo/wallaby",

331 "penguin",

332 "anchor",

333 "camel/dromedary",

334 "harp",

335 "rhinoceros",

336 "barrel/keg/tub",

337 "crown",

338 "alligator/crocodile",

339 "accordion/piano accordion/squeeze box",

340 ],

341 )

342 add_multiple_columns(

343 cls,

344 "lang_identify_concept",

345 1,

346 4,

347 pv=PV.BIT,

348 comment_fmt="Language, identify concept, {n}/4, {s} (0 or 1)",

349 comment_strings=["monarchy", "marsupial", "Antarctic", "nautical"],

350 )

351

352 add_multiple_columns(

353 cls,

354 "vsp_count_dots",

355 1,

356 4,

357 pv=PV.BIT,

358 comment_fmt="Visuospatial, count dots {n}/4, {s} dots (0-1)",

359 comment_strings=["8", "10", "7", "9"],

360 )

361 add_multiple_columns(

362 cls,

363 "vsp_identify_letter",

364 1,

365 4,

366 pv=PV.BIT,

367 comment_fmt="Visuospatial, identify letter {n}/4, {s} (0-1)",

368 comment_strings=["K", "M", "A", "T"],

369 )

370 add_multiple_columns(

371 cls,

372 "mem_recall_address",

373 1,

374 N_MEM_REPEAT_RECALL_ADDR,

375 pv=PV.BIT,

376 comment_fmt="Memory, recall address {n}/7, {s} (0-1)",

377 comment_strings=ADDRESS_PARTS,

378 )

379 add_multiple_columns(

380 cls,

381 "mem_recognize_address",

382 1,

383 5,

384 pv=PV.BIT,

385 comment_fmt="Memory, recognize address {n}/5 (if "

386 "applicable) ({s}) (0-1)",

387 comment_strings=["name", "number", "street", "town", "county"],

388 )

389 add_multiple_columns( # tablet version 2.0.0 onwards

390 cls,

391 "mem_recognize_address_choice",

392 1,

393 5,

394 coltype=String(length=1), # was Text

395 comment_fmt="Memory, recognize address {n}/5, CHOICE (if "

396 "applicable) ({s}) (A/B/C)",

397 comment_strings=["name", "number", "street", "town", "county"],

398 )

399

400 task_edition: Mapped[Optional[str]] = mapped_camcops_column(

401 String(length=255),

402 comment="Task edition. Older task instances will have NULL and that "

403 "indicates UK English, 2012 version.",

404 )

405 task_address_version: Mapped[Optional[str]] = mapped_camcops_column(

406 String(length=1),

407 comment="Task version, determining the address for recall (A/B/C). "

408 "Older task instances will have NULL and that indicates version A.",

409 permitted_value_checker=PermittedValueChecker(

410 permitted_values=["A", "B", "C"]

411 ),

412 )

413 remote_administration: Mapped[Optional[bool]] = mapped_camcops_column(

414 permitted_value_checker=BIT_CHECKER,

415 comment="Task performed using remote (videoconferencing) "

416 "administration?",

417 )

418 age_at_leaving_full_time_education: Mapped[Optional[int]] = mapped_column(

419 comment="Age at leaving full time education",

420 )

421 occupation: Mapped[Optional[str]] = mapped_column(

422 UnicodeText, comment="Occupation"

423 )

424 handedness: Mapped[Optional[str]] = mapped_camcops_column(

425 String(length=1), # was Text

426 comment="Handedness (L or R)",

427 permitted_value_checker=PermittedValueChecker(

428 permitted_values=["L", "R"]

429 ),

430 )

431 attn_num_registration_trials: Mapped[Optional[int]] = mapped_column(

432 comment="Attention, repetition, number of trials (not scored)",

433 )

434 fluency_letters_score: Mapped[Optional[int]] = mapped_camcops_column(

435 comment="Fluency, words beginning with P, score 0-7",

436 permitted_value_checker=PermittedValueChecker(minimum=0, maximum=7),

437 )

438 fluency_animals_score: Mapped[Optional[int]] = mapped_camcops_column(

439 comment="Fluency, animals, score 0-7",

440 permitted_value_checker=PermittedValueChecker(minimum=0, maximum=7),

441 )

442 lang_follow_command_practice: Mapped[Optional[int]] = (

443 mapped_camcops_column(

444 comment="Language, command, practice trial (not scored)",

445 permitted_value_checker=BIT_CHECKER,

446 )

447 )

448 lang_read_words_aloud: Mapped[Optional[int]] = mapped_camcops_column(

449 comment="Language, read five irregular words (0 or 1)",

450 permitted_value_checker=BIT_CHECKER,

451 )

452 vsp_copy_infinity: Mapped[Optional[int]] = mapped_camcops_column(

453 comment="Visuospatial, copy infinity (0-1)",

454 permitted_value_checker=BIT_CHECKER,

455 )

456 vsp_copy_cube: Mapped[Optional[int]] = mapped_camcops_column(

457 comment="Visuospatial, copy cube (0-2)",

458 permitted_value_checker=PermittedValueChecker(minimum=0, maximum=2),

459 )

460 vsp_draw_clock: Mapped[Optional[int]] = mapped_camcops_column(

461 comment="Visuospatial, draw clock (0-5)",

462 permitted_value_checker=PermittedValueChecker(minimum=0, maximum=5),

463 )

464 picture1_blobid: Mapped[Optional[int]] = mapped_camcops_column(

465 comment="Photo 1/2 PNG BLOB ID",

466 is_blob_id_field=True,

467 blob_relationship_attr_name="picture1",

468 )

469 picture2_blobid: Mapped[Optional[int]] = mapped_camcops_column(

470 comment="Photo 2/2 PNG BLOB ID",

471 is_blob_id_field=True,

472 blob_relationship_attr_name="picture2",

473 )

474 comments: Mapped[Optional[str]] = mapped_column(

475 UnicodeText, comment="Clinician's comments"

476 )

477

478 picture1: Mapped[Optional[Blob]] = blob_relationship( # type: ignore[assignment] # noqa: E501

479 "Ace3", "picture1_blobid"

480 )

481 picture2: Mapped[Optional[Blob]] = blob_relationship( # type: ignore[assignment] # noqa: E501

482 "Ace3", "picture2_blobid"

483 )

484

485 ATTN_SCORE_FIELDS = (

486 strseq("attn_time", 1, N_ATTN_TIME_ACE)

487 + strseq("attn_place", 1, 5)

488 + strseq("attn_repeat_word", 1, 3)

489 + strseq("attn_serial7_subtraction", 1, 5)

490 )

491 MEM_NON_RECOG_SCORE_FIELDS = (

492 strseq("mem_recall_word", 1, 3)

493 + strseq("mem_repeat_address_trial3_", 1, N_MEM_REPEAT_RECALL_ADDR)

494 + strseq("mem_famous", 1, 4)

495 + strseq("mem_recall_address", 1, N_MEM_REPEAT_RECALL_ADDR)

496 )

497 LANG_SIMPLE_SCORE_FIELDS = (

498 strseq("lang_write_sentences_point", 1, 2)

499 + strseq("lang_repeat_sentence", 1, 2)

500 + strseq("lang_name_picture", 1, 12)

501 + strseq("lang_identify_concept", 1, 4)

502 )

503 LANG_FOLLOW_CMD_FIELDS = strseq("lang_follow_command", 1, 3)

504 LANG_REPEAT_WORD_FIELDS = strseq("lang_repeat_word", 1, 4)

505 VSP_SIMPLE_SCORE_FIELDS = strseq("vsp_count_dots", 1, 4) + strseq(

506 "vsp_identify_letter", 1, 4

507 )

508 BASIC_COMPLETENESS_FIELDS = (

509 ATTN_SCORE_FIELDS

510 + MEM_NON_RECOG_SCORE_FIELDS

511 + ["fluency_letters_score", "fluency_animals_score"]

512 + ["lang_follow_command_practice"]

513 + LANG_SIMPLE_SCORE_FIELDS

514 + LANG_REPEAT_WORD_FIELDS

515 + [

516 "lang_read_words_aloud",

517 "vsp_copy_infinity",

518 "vsp_copy_cube",

519 "vsp_draw_clock",

520 ]

521 + VSP_SIMPLE_SCORE_FIELDS

522 + strseq("mem_recall_address", 1, N_MEM_REPEAT_RECALL_ADDR)

523 )

524 MINI_ACE_FIELDS = (

525 strseq("attn_time", 1, N_ATTN_TIME_MINIACE) # 4 points; not season

526 + ["fluency_animals_score"] # 7 points

527 + strseq("mem_repeat_address_trial3_", 1, N_MEM_REPEAT_RECALL_ADDR)

528 # ... 7 points

529 + ["vsp_draw_clock"] # 5 points

530 + strseq("mem_recall_address", 1, N_MEM_REPEAT_RECALL_ADDR) # 7 points

531 )

532

533 @staticmethod

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

535 _ = req.gettext

536 return _("Addenbrooke’s Cognitive Examination III")

537

538 def get_trackers(self, req: CamcopsRequest) -> List[TrackerInfo]:

539 return [

540 TrackerInfo(

541 value=self.total_score(),

542 plot_label="ACE-III total score",

543 axis_label=f"Total score (out of {TOTAL_MAX})",

544 axis_min=-0.5,

545 axis_max=TOTAL_MAX + 0.5,

546 # Traditional cutoffs: ≤82, ≤88

547 horizontal_lines=[82.5, 88.5],

548 ),

549 TrackerInfo(

550 value=self.mini_ace_score(),

551 plot_label="Mini-ACE score",

552 axis_label=f"Mini-ACE score (out of {MINI_ACE_MAX})",

553 axis_min=-0.5,

554 axis_max=MINI_ACE_MAX + 0.5,

555 # Traditional cutoffs: ≤21, ≤25

556 horizontal_lines=[21.5, 25.5],

557 ),

558 ]

559

560 def get_clinical_text(self, req: CamcopsRequest) -> List[CtvInfo]:

561 if not self.is_complete():

562 return CTV_INCOMPLETE

563 a = self.attn_score()

564 m = self.mem_score()

565 f = self.fluency_score()

566 lang = self.lang_score()

567 v = self.vsp_score()

568 t = a + m + f + lang + v

569 mini = self.mini_ace_score()

570 text = (

571 f"ACE-III total: {t}/{TOTAL_MAX} "

572 f"(attention {a}/{ATTN_MAX}, memory {m}/{MEMORY_MAX}, "

573 f"fluency {f}/{FLUENCY_MAX}, language {lang}/{LANG_MAX}, "

574 f"visuospatial {v}/{VSP_MAX}, "

575 f"mini-ACE score {mini}/{MINI_ACE_MAX})"

576 )

577 return [CtvInfo(content=text)]

578

579 def get_summaries(self, req: CamcopsRequest) -> List[SummaryElement]:

580 return self.standard_task_summary_fields() + [

581 SummaryElement(

582 name="total",

583 coltype=Integer(),

584 value=self.total_score(),

585 comment=f"Total score (/{TOTAL_MAX})",

586 ),

587 SummaryElement(

588 name="attn",

589 coltype=Integer(),

590 value=self.attn_score(),

591 comment=f"Attention (/{ATTN_MAX})",

592 ),

593 SummaryElement(

594 name="mem",

595 coltype=Integer(),

596 value=self.mem_score(),

597 comment=f"Memory (/{MEMORY_MAX})",

598 ),

599 SummaryElement(

600 name="fluency",

601 coltype=Integer(),

602 value=self.fluency_score(),

603 comment=f"Fluency (/{FLUENCY_MAX})",

604 ),

605 SummaryElement(

606 name="lang",

607 coltype=Integer(),

608 value=self.lang_score(),

609 comment=f"Language (/{LANG_MAX})",

610 ),

611 SummaryElement(

612 name="vsp",

613 coltype=Integer(),

614 value=self.vsp_score(),

615 comment=f"Visuospatial (/{VSP_MAX})",

616 ),

617 SummaryElement(

618 name="mini_ace",

619 coltype=Integer(),

620 value=self.mini_ace_score(),

621 comment=f"Mini-ACE (/{MINI_ACE_MAX})",

622 ),

623 ]

624

625 def attn_score(self) -> int:

626 return cast(int, self.sum_fields(self.ATTN_SCORE_FIELDS))

627

628 @staticmethod

629 def get_recog_score(

630 recalled: Optional[int], recognized: Optional[int]

631 ) -> int:

632 if recalled == 1:

633 return 1

634 return score_zero_for_absent(recognized)

635

636 @staticmethod

637 def get_recog_text(

638 recalled: Optional[int], recognized: Optional[int]

639 ) -> str:

640 if recalled:

641 return "<i>1 (already recalled)</i>"

642 return answer(recognized)

643

644 # noinspection PyUnresolvedReferences

645 def get_mem_recognition_score(self) -> int:

646 score = 0

647 score += self.get_recog_score(

648 (self.mem_recall_address1 == 1 and self.mem_recall_address2 == 1), # type: ignore[attr-defined] # noqa: E501

649 self.mem_recognize_address1, # type: ignore[attr-defined]

650 )

651 score += self.get_recog_score(

652 (self.mem_recall_address3 == 1), self.mem_recognize_address2 # type: ignore[attr-defined] # noqa: E501

653 )

654 score += self.get_recog_score(

655 (self.mem_recall_address4 == 1 and self.mem_recall_address5 == 1), # type: ignore[attr-defined] # noqa: E501

656 self.mem_recognize_address3, # type: ignore[attr-defined]

657 )

658 score += self.get_recog_score(

659 (self.mem_recall_address6 == 1), self.mem_recognize_address4 # type: ignore[attr-defined] # noqa: E501

660 )

661 score += self.get_recog_score(

662 (self.mem_recall_address7 == 1), self.mem_recognize_address5 # type: ignore[attr-defined] # noqa: E501

663 )

664 return score

665

666 def mem_score(self) -> int:

667 return cast(

668 int,

669 (

670 self.sum_fields(self.MEM_NON_RECOG_SCORE_FIELDS)

671 + self.get_mem_recognition_score()

672 ),

673 )

674

675 def fluency_score(self) -> int:

676 return cast(

677 int,

678 (

679 score_zero_for_absent(self.fluency_letters_score)

680 + score_zero_for_absent(self.fluency_animals_score)

681 ),

682 )

683

684 def get_follow_command_score(self) -> int:

685 if self.lang_follow_command_practice != 1:

686 return 0

687 return cast(int, self.sum_fields(self.LANG_FOLLOW_CMD_FIELDS))

688

689 def get_repeat_word_score(self) -> int:

690 n = cast(int, self.sum_fields(self.LANG_REPEAT_WORD_FIELDS))

691 return 2 if n >= 4 else (1 if n == 3 else 0)

692

693 def lang_score(self) -> int:

694 return (

695 cast(int, self.sum_fields(self.LANG_SIMPLE_SCORE_FIELDS))

696 + self.get_follow_command_score()

697 + self.get_repeat_word_score()

698 + score_zero_for_absent(self.lang_read_words_aloud)

699 )

700

701 def vsp_score(self) -> int:

702 return (

703 cast(int, self.sum_fields(self.VSP_SIMPLE_SCORE_FIELDS))

704 + score_zero_for_absent(self.vsp_copy_infinity)

705 + score_zero_for_absent(self.vsp_copy_cube)

706 + score_zero_for_absent(self.vsp_draw_clock)

707 )

708

709 def total_score(self) -> int:

710 return (

711 self.attn_score()

712 + self.mem_score()

713 + self.fluency_score()

714 + self.lang_score()

715 + self.vsp_score()

716 )

717

718 def mini_ace_score(self) -> int:

719 return cast(int, self.sum_fields(self.MINI_ACE_FIELDS))

720

721 # noinspection PyUnresolvedReferences

722 def is_recognition_complete(self) -> bool:

723 return (

724 (

725 (

726 self.mem_recall_address1 == 1 # type: ignore[attr-defined]

727 and self.mem_recall_address2 == 1 # type: ignore[attr-defined] # noqa: E501

728 )

729 or self.mem_recognize_address1 is not None # type: ignore[attr-defined] # noqa: E501

730 )

731 and (

732 self.mem_recall_address3 == 1 # type: ignore[attr-defined]

733 or self.mem_recognize_address2 is not None # type: ignore[attr-defined] # noqa: E501

734 )

735 and (

736 (

737 self.mem_recall_address4 == 1 # type: ignore[attr-defined]

738 and self.mem_recall_address5 == 1 # type: ignore[attr-defined] # noqa: E501

739 )

740 or self.mem_recognize_address3 is not None # type: ignore[attr-defined] # noqa: E501

741 )

742 and (

743 self.mem_recall_address6 == 1 # type: ignore[attr-defined]

744 or self.mem_recognize_address4 is not None # type: ignore[attr-defined] # noqa: E501

745 )

746 and (

747 self.mem_recall_address7 == 1 # type: ignore[attr-defined]

748 or self.mem_recognize_address5 is not None # type: ignore[attr-defined] # noqa: E501

749 )

750 )

751

752 def is_complete(self) -> bool:

753 if self.any_fields_none(self.BASIC_COMPLETENESS_FIELDS):

754 return False

755 if not self.field_contents_valid():

756 return False

757 if self.lang_follow_command_practice == 1 and self.any_fields_none(

758 self.LANG_FOLLOW_CMD_FIELDS

759 ):

760 return False

761 return self.is_recognition_complete()

762

763 @classmethod

764 def get_target_address_parts(

765 cls, req: CamcopsRequest, task_address_version: str

766 ) -> List[str]:

767 """

768 Returns the target address components (7 of them). This requires an

769 xstring (via a request also embodying the currently selected locale)

770 and the version selected for the task.

771

772 We do this as a classmethod so it (a) saves duplication and (b) knows

773 about the xstrings for ACE-III (which are shared with the Mini-ACE). A

774 superclass/mixin would be an alternative.

775 """

776 parts = [] # type: List[str]

777 for i in range(1, N_MEM_REPEAT_RECALL_ADDR + 1):

778 xstringname = f"task_{task_address_version}_target_address_{i}"

779 part = cls.xstring(req, xstringname)

780 parts.append(part)

781 return parts

782

783 # noinspection PyUnresolvedReferences

784 def get_task_html(self, req: CamcopsRequest) -> str:

785 a = self.attn_score()

786 m = self.mem_score()

787 f = self.fluency_score()

788 lang = self.lang_score()

789 v = self.vsp_score()

790 t = a + m + f + lang + v

791 mini = self.mini_ace_score()

792 target_addr = qsequence(

793 self.get_target_address_parts(req, self.task_address_version)

794 )

795 lkb = qsequence(RECALL_WORDS) # lemon, key, ball

796

797 if self.is_complete():

798 figsize = (

799 PlotDefaults.FULLWIDTH_PLOT_WIDTH / 3,

800 PlotDefaults.FULLWIDTH_PLOT_WIDTH / 4,

801 )

802 width = 0.9

803 fig = req.create_figure(figsize=figsize)

804 ax = fig.add_subplot(1, 1, 1)

805 scores = numpy.array([a, m, f, lang, v])

806 maxima = numpy.array(

807 [ATTN_MAX, MEMORY_MAX, FLUENCY_MAX, LANG_MAX, VSP_MAX]

808 )

809 y = 100 * scores / maxima

810 x_labels = ["Attn", "Mem", "Flu", "Lang", "VSp"]

811 # noinspection PyTypeChecker

812 n = len(y)

813 xvar = numpy.arange(n)

814 ax.bar(xvar, y, width, color="b")

815 ax.set_ylabel("%", fontdict=req.fontdict)

816 ax.set_xticks(xvar)

817 x_offset = -0.5

818 ax.set_xlim(0 + x_offset, len(scores) + x_offset)

819 ax.set_xticklabels(x_labels, fontdict=req.fontdict)

820 fig.tight_layout() # or the ylabel drops off the figure

821 # fig.autofmt_xdate()

822 req.set_figure_font_sizes(ax)

823 figurehtml = req.get_html_from_pyplot_figure(fig)

824 else:

825 figurehtml = "<i>Incomplete; not plotted</i>"

826

827 return (

828 self.get_standard_clinician_comments_block(req, self.comments)

829 + f"""

830 <div class="{CssClass.SUMMARY}">

831 <table class="{CssClass.SUMMARY}">

832 <tr>

833 {self.get_is_complete_td_pair(req)}

834 <td class="{CssClass.FIGURE}"

835 rowspan="8">{figurehtml}</td>

836 </tr>

837 """

838 + tr(

839 "Total ACE-III score <sup>[1]</sup>",

840 answer(t) + f" / {TOTAL_MAX}",

841 )

842 + tr_score_with_pct("Attention", a, ATTN_MAX)

843 + tr_score_with_pct("Memory", m, MEMORY_MAX)

844 + tr_score_with_pct("Fluency", f, FLUENCY_MAX)

845 + tr_score_with_pct("Language", lang, LANG_MAX)

846 + tr_score_with_pct("Visuospatial", v, VSP_MAX)

847 + tr_score_with_pct(

848 "Mini-ACE score <sup>[2]</sup>", mini, MINI_ACE_MAX

849 )

850 + f"""

851 </table>

852 </div>

853 <table class="{CssClass.TASKCONFIG}">

854 """

855 + tr_heading("Task aspect", "Setting")

856 + tr_qa("Edition", self.task_edition)

857 + tr_qa("Version", self.task_address_version)

858 + tr_qa(

859 "Remote administration?",

860 get_yes_no_none(req, self.remote_administration),

861 )

862 + f"""

863 <table class="{CssClass.TASKDETAIL}">

864 """

865 + tr_heading("Question", "Answer/score")

866 + tr_qa(

867 AGE_FTE,

868 self.age_at_leaving_full_time_education,

869 )

870 + tr_qa(OCCUPATION, ws.webify(self.occupation))

871 + tr_qa(HANDEDNESS, ws.webify(self.handedness))

872 + subheading_spanning_two_columns("Attention")

873 + tr(

874 "Day? Date? Month? Year? Season?",

875 ", ".join(

876 answer(x)

877 for x in (

878 self.attn_time1, # type: ignore[attr-defined]

879 self.attn_time2, # type: ignore[attr-defined]

880 self.attn_time3, # type: ignore[attr-defined]

881 self.attn_time4, # type: ignore[attr-defined]

882 self.attn_time5, # type: ignore[attr-defined]

883 )

884 ),

885 )

886 + tr(

887 "House number/floor? Street/hospital? Town? County? Country?",

888 ", ".join(

889 answer(x)

890 for x in (

891 self.attn_place1, # type: ignore[attr-defined]

892 self.attn_place2, # type: ignore[attr-defined]

893 self.attn_place3, # type: ignore[attr-defined]

894 self.attn_place4, # type: ignore[attr-defined]

895 self.attn_place5, # type: ignore[attr-defined]

896 )

897 ),

898 )

899 + tr(

900 "Repeat: " + lkb,

901 ", ".join(

902 answer(x)

903 for x in (

904 self.attn_repeat_word1, # type: ignore[attr-defined]

905 self.attn_repeat_word2, # type: ignore[attr-defined]

906 self.attn_repeat_word3, # type: ignore[attr-defined]

907 )

908 ),

909 )

910 + tr(

911 "Repetition: number of trials <i>(not scored)</i>",

912 answer(

913 self.attn_num_registration_trials, formatter_answer=italic

914 ),

915 )

916 + tr(

917 "Serial subtractions: First correct? Second? Third? Fourth? "

918 "Fifth?",

919 ", ".join(

920 answer(x)

921 for x in (

922 self.attn_serial7_subtraction1, # type: ignore[attr-defined] # noqa: E501

923 self.attn_serial7_subtraction2, # type: ignore[attr-defined] # noqa: E501

924 self.attn_serial7_subtraction3, # type: ignore[attr-defined] # noqa: E501

925 self.attn_serial7_subtraction4, # type: ignore[attr-defined] # noqa: E501

926 self.attn_serial7_subtraction5, # type: ignore[attr-defined] # noqa: E501

927 )

928 ),

929 )

930 + subheading_spanning_two_columns("Memory (1)")

931 + tr(

932 "Recall: " + lkb,

933 ", ".join(

934 answer(x)

935 for x in (

936 self.mem_recall_word1, # type: ignore[attr-defined]

937 self.mem_recall_word2, # type: ignore[attr-defined]

938 self.mem_recall_word3, # type: ignore[attr-defined]

939 )

940 ),

941 )

942 + subheading_spanning_two_columns("Fluency")

943 + tr(

944 "Score for words beginning with ‘P’ <i>(≥18 scores 7, 14–17 "

945 "scores 6, 11–13 scores 5, 8–10 scores 4, 6–7 scores 3, "

946 "4–5 scores 2, 2–3 scores 1, 0–1 scores 0)</i>",

947 answer(self.fluency_letters_score) + " / 7",

948 )

949 + tr(

950 ANIMAL_FLUENCY_SCORING_HTML,

951 answer(self.fluency_animals_score) + " / 7",

952 )

953 + subheading_spanning_two_columns("Memory (2)")

954 + tr(

955 "Third trial of address registration: " + target_addr,

956 ", ".join(

957 answer(x)

958 for x in (

959 self.mem_repeat_address_trial3_1, # type: ignore[attr-defined] # noqa: E501

960 self.mem_repeat_address_trial3_2, # type: ignore[attr-defined] # noqa: E501

961 self.mem_repeat_address_trial3_3, # type: ignore[attr-defined] # noqa: E501

962 self.mem_repeat_address_trial3_4, # type: ignore[attr-defined] # noqa: E501

963 self.mem_repeat_address_trial3_5, # type: ignore[attr-defined] # noqa: E501

964 self.mem_repeat_address_trial3_6, # type: ignore[attr-defined] # noqa: E501

965 self.mem_repeat_address_trial3_7, # type: ignore[attr-defined] # noqa: E501

966 )

967 ),

968 )

969 + tr(

970 "Current PM? First female PM? USA president? USA president "

971 "assassinated in 1960s?",

972 ", ".join(

973 answer(x)

974 for x in (

975 self.mem_famous1, # type: ignore[attr-defined]

976 self.mem_famous2, # type: ignore[attr-defined]

977 self.mem_famous3, # type: ignore[attr-defined]

978 self.mem_famous4, # type: ignore[attr-defined]

979 )

980 ),

981 )

982 + subheading_spanning_two_columns("Language")

983 + tr(

984 "<i>Practice trial (“Pick up the pencil and then the "

985 "paper”)</i>",

986 answer(

987 self.lang_follow_command_practice, formatter_answer=italic

988 ),

989 )

990 + tr_qa(

991 "“Place the paper on top of the pencil”",

992 self.lang_follow_command1, # type: ignore[attr-defined]

993 )

994 + tr_qa(

995 "“Pick up the pencil but not the paper”",

996 self.lang_follow_command2, # type: ignore[attr-defined]

997 )

998 + tr_qa(

999 "“Pass me the pencil after touching the paper”",

1000 self.lang_follow_command3, # type: ignore[attr-defined]

1001 )

1002 + tr(

1003 "Sentence-writing: point for 2 complete sentences? "

1004 "Point for correct grammar and spelling?",

1005 ", ".join(

1006 answer(x)

1007 for x in (

1008 self.lang_write_sentences_point1, # type: ignore[attr-defined] # noqa: E501

1009 self.lang_write_sentences_point2, # type: ignore[attr-defined] # noqa: E501

1010 )

1011 ),

1012 )

1013 + tr(

1014 "Repeat: caterpillar? eccentricity? unintelligible? "

1015 "statistician? <i>(score 2 if all correct, 1 if 3 correct, "

1016 "0 if ≤2 correct)</i>",

1017 "<i>{}, {}, {}, {}</i> (score <b>{}</b> / 2)".format(

1018 answer(self.lang_repeat_word1, formatter_answer=italic), # type: ignore[attr-defined] # noqa: E501

1019 answer(self.lang_repeat_word2, formatter_answer=italic), # type: ignore[attr-defined] # noqa: E501

1020 answer(self.lang_repeat_word3, formatter_answer=italic), # type: ignore[attr-defined] # noqa: E501

1021 answer(self.lang_repeat_word4, formatter_answer=italic), # type: ignore[attr-defined] # noqa: E501

1022 self.get_repeat_word_score(),

1023 ),

1024 )

1025 + tr_qa(

1026 "Repeat: “All that glitters is not gold”?",

1027 self.lang_repeat_sentence1, # type: ignore[attr-defined]

1028 )

1029 + tr_qa(

1030 "Repeat: “A stitch in time saves nine”?",

1031 self.lang_repeat_sentence2, # type: ignore[attr-defined]

1032 )

1033 + tr(

1034 "Name pictures: spoon, book, kangaroo/wallaby",

1035 ", ".join(

1036 answer(x)

1037 for x in (

1038 self.lang_name_picture1, # type: ignore[attr-defined]

1039 self.lang_name_picture2, # type: ignore[attr-defined]

1040 self.lang_name_picture3, # type: ignore[attr-defined]

1041 )

1042 ),

1043 )

1044 + tr(

1045 "Name pictures: penguin, anchor, camel/dromedary",

1046 ", ".join(

1047 answer(x)

1048 for x in (

1049 self.lang_name_picture4, # type: ignore[attr-defined]

1050 self.lang_name_picture5, # type: ignore[attr-defined]

1051 self.lang_name_picture6, # type: ignore[attr-defined]

1052 )

1053 ),

1054 )

1055 + tr(

1056 "Name pictures: harp, rhinoceros/rhino, barrel/keg/tub",

1057 ", ".join(

1058 answer(x)

1059 for x in (

1060 self.lang_name_picture7, # type: ignore[attr-defined]

1061 self.lang_name_picture8, # type: ignore[attr-defined]

1062 self.lang_name_picture9, # type: ignore[attr-defined]

1063 )

1064 ),

1065 )

1066 + tr(

1067 "Name pictures: crown, alligator/crocodile, "

1068 "accordion/piano accordion/squeeze box",

1069 ", ".join(

1070 answer(x)

1071 for x in (

1072 self.lang_name_picture10, # type: ignore[attr-defined]

1073 self.lang_name_picture11, # type: ignore[attr-defined]

1074 self.lang_name_picture12, # type: ignore[attr-defined]

1075 )

1076 ),

1077 )

1078 + tr(

1079 "Identify pictures: monarchy? marsupial? Antarctic? nautical?",

1080 ", ".join(

1081 answer(x)

1082 for x in (

1083 self.lang_identify_concept1, # type: ignore[attr-defined] # noqa: E501

1084 self.lang_identify_concept2, # type: ignore[attr-defined] # noqa: E501

1085 self.lang_identify_concept3, # type: ignore[attr-defined] # noqa: E501

1086 self.lang_identify_concept4, # type: ignore[attr-defined] # noqa: E501

1087 )

1088 ),

1089 )

1090 + tr_qa(

1091 "Read all successfully: sew, pint, soot, dough, height",

1092 self.lang_read_words_aloud,

1093 )

1094 + subheading_spanning_two_columns("Visuospatial")

1095 + tr("Copy infinity", answer(self.vsp_copy_infinity) + " / 1")

1096 + tr("Copy cube", answer(self.vsp_copy_cube) + " / 2")

1097 + tr(

1098 "Draw clock with numbers and hands at 5:10",

1099 answer(self.vsp_draw_clock) + " / 5",

1100 )

1101 + tr(

1102 "Count dots: 8, 10, 7, 9",

1103 ", ".join(

1104 answer(x)

1105 for x in (

1106 self.vsp_count_dots1, # type: ignore[attr-defined]

1107 self.vsp_count_dots2, # type: ignore[attr-defined]

1108 self.vsp_count_dots3, # type: ignore[attr-defined]

1109 self.vsp_count_dots4, # type: ignore[attr-defined]

1110 )

1111 ),

1112 )

1113 + tr(

1114 "Identify letters: K, M, A, T",

1115 ", ".join(

1116 answer(x)

1117 for x in (

1118 self.vsp_identify_letter1, # type: ignore[attr-defined] # noqa: E501

1119 self.vsp_identify_letter2, # type: ignore[attr-defined] # noqa: E501

1120 self.vsp_identify_letter3, # type: ignore[attr-defined] # noqa: E501

1121 self.vsp_identify_letter4, # type: ignore[attr-defined] # noqa: E501

1122 )

1123 ),

1124 )

1125 + subheading_spanning_two_columns("Memory (3)")

1126 + tr(

1127 "Recall address: " + target_addr,

1128 ", ".join(

1129 answer(x)

1130 for x in (

1131 self.mem_recall_address1, # type: ignore[attr-defined]

1132 self.mem_recall_address2, # type: ignore[attr-defined]

1133 self.mem_recall_address3, # type: ignore[attr-defined]

1134 self.mem_recall_address4, # type: ignore[attr-defined]

1135 self.mem_recall_address5, # type: ignore[attr-defined]

1136 self.mem_recall_address6, # type: ignore[attr-defined]

1137 self.mem_recall_address7, # type: ignore[attr-defined]

1138 )

1139 ),

1140 )

1141 + tr(

1142 "Recognize address: forename and surname?",

1143 self.get_recog_text(

1144 (

1145 self.mem_recall_address1 == 1 # type: ignore[attr-defined] # noqa: E501

1146 and self.mem_recall_address2 == 1 # type: ignore[attr-defined] # noqa: E501

1147 ),

1148 self.mem_recognize_address1, # type: ignore[attr-defined]

1149 ),

1150 )

1151 + tr(

1152 "Recognize address: house number?",

1153 self.get_recog_text(

1154 (self.mem_recall_address3 == 1), # type: ignore[attr-defined] # noqa: E501

1155 self.mem_recognize_address2, # type: ignore[attr-defined]

1156 ),

1157 )

1158 + tr(

1159 "Recognize address: street?",

1160 self.get_recog_text(

1161 (

1162 self.mem_recall_address4 == 1 # type: ignore[attr-defined] # noqa: E501

1163 and self.mem_recall_address5 == 1 # type: ignore[attr-defined] # noqa: E501

1164 ),

1165 self.mem_recognize_address3, # type: ignore[attr-defined]

1166 ),

1167 )

1168 + tr(

1169 "Recognize address: town?",

1170 self.get_recog_text(

1171 (self.mem_recall_address6 == 1), # type: ignore[attr-defined] # noqa: E501

1172 self.mem_recognize_address4, # type: ignore[attr-defined]

1173 ),

1174 )

1175 + tr(

1176 "Recognize address: county?",

1177 self.get_recog_text(

1178 (self.mem_recall_address7 == 1), # type: ignore[attr-defined] # noqa: E501

1179 self.mem_recognize_address5, # type: ignore[attr-defined]

1180 ),

1181 )

1182 + subheading_spanning_two_columns("Photos of test sheet")

1183 + tr_span_col(

1184 get_blob_img_html(self.picture1), td_class=CssClass.PHOTO

1185 )

1186 + tr_span_col(

1187 get_blob_img_html(self.picture2), td_class=CssClass.PHOTO

1188 )

1189 + f"""

1190 </table>

1191 <div class="{CssClass.FOOTNOTES}">

1192 [1] In the ACE-III, scores ≤82 had sensitivity 0.93 and

1193 specificity 1.0 for dementia, and scores ≤88 had

1194 sensitivity 1.0 and specificity 0.98 for dementia, in a

1195 context of patients with Alzheimer’s disease,

1196 frontotemporal dementia, and controls (Hsieh et al. 2013,

1197 {pmid(23949210)}).

1198

1199 [2] {MINI_ACE_THRESHOLDS}

1200 </div>

1201 <div class="{CssClass.COPYRIGHT}">

1202 {ACE3_COPYRIGHT}

1203 </div>

1204 """

1205 )

1206

1207 def get_snomed_codes(self, req: CamcopsRequest) -> List[SnomedExpression]:

1208 codes = [

1209 SnomedExpression(

1210 req.snomed(SnomedLookup.ACE_R_PROCEDURE_ASSESSMENT)

1211 )

1212 ]

1213 # add(SnomedLookup.ACE_R_PROCEDURE_ASSESSMENT_SUBSCALE_ATTENTION_ORIENTATION) # noqa

1214 # add(SnomedLookup.ACE_R_PROCEDURE_ASSESSMENT_SUBSCALE_MEMORY)

1215 # add(SnomedLookup.ACE_R_PROCEDURE_ASSESSMENT_SUBSCALE_FLUENCY)

1216 # add(SnomedLookup.ACE_R_PROCEDURE_ASSESSMENT_SUBSCALE_LANGUAGE)

1217 # add(SnomedLookup.ACE_R_PROCEDURE_ASSESSMENT_SUBSCALE_VISUOSPATIAL)

1218 if self.is_complete(): # could refine: is each subscale complete?

1219 a = self.attn_score()

1220 m = self.mem_score()

1221 f = self.fluency_score()

1222 lang = self.lang_score()

1223 v = self.vsp_score()

1224 t = a + m + f + lang + v

1225 codes.append(

1226 SnomedExpression(

1227 req.snomed(SnomedLookup.ACE_R_SCALE),

1228 {

1229 req.snomed(SnomedLookup.ACE_R_SCORE): t,

1230 req.snomed(

1231 SnomedLookup.ACE_R_SUBSCORE_ATTENTION_ORIENTATION

1232 ): a,

1233 req.snomed(SnomedLookup.ACE_R_SUBSCORE_MEMORY): m,

1234 req.snomed(SnomedLookup.ACE_R_SUBSCORE_FLUENCY): f,

1235 req.snomed(SnomedLookup.ACE_R_SUBSCORE_LANGUAGE): lang,

1236 req.snomed(

1237 SnomedLookup.ACE_R_SUBSCORE_VISUOSPATIAL

1238 ): v,

1239 },

1240 )

1241 )

1242 # There's no mini-ACE code in SNOMED-CT yet, as of 2022-12-01.

1243 return codes

1244

1245

1246# =============================================================================

1247# Mini-ACE

1248# =============================================================================

1249

1250

1251 class MiniAce( # type: ignore[misc]

1252 TaskHasPatientMixin,

1253 TaskHasClinicianMixin,

1254 Task,

1255):

1256 """

1257 Server implementation of the Mini-ACE task.

1258 """

1259

1260 __tablename__ = "miniace"

1261 shortname = "Mini-ACE"

1262 extrastring_taskname = "ace3" # shares strings with ACE-III

1263 provides_trackers = True

1264

1265 prohibits_commercial = True

1266

1267 @classmethod

1268 def extend_columns(cls: Type["MiniAce"], **kwargs: Any) -> None:

1269 add_multiple_columns(

1270 cls,

1271 "attn_time",

1272 1,

1273 N_ATTN_TIME_MINIACE, # 4, not 5

1274 pv=PV.BIT,

1275 comment_fmt="Attention, time, {n}/4, {s} (0 or 1)",

1276 comment_strings=["day", "date", "month", "year"], # not season

1277 )

1278 add_multiple_columns(

1279 cls,

1280 "mem_repeat_address_trial1_",

1281 1,

1282 N_MEM_REPEAT_RECALL_ADDR,

1283 pv=PV.BIT,

1284 comment_fmt="Memory, address registration trial 1/3 "

1285 "(not scored), {s} (0 or 1)",

1286 comment_strings=ADDRESS_PARTS,

1287 )

1288 add_multiple_columns(

1289 cls,

1290 "mem_repeat_address_trial2_",

1291 1,

1292 N_MEM_REPEAT_RECALL_ADDR,

1293 pv=PV.BIT,

1294 comment_fmt="Memory, address registration trial 2/3 "

1295 "(not scored), {s} (0 or 1)",

1296 comment_strings=ADDRESS_PARTS,

1297 )

1298 add_multiple_columns(

1299 cls,

1300 "mem_repeat_address_trial3_",

1301 1,

1302 N_MEM_REPEAT_RECALL_ADDR,

1303 pv=PV.BIT,

1304 comment_fmt="Memory, address registration trial 3/3 "

1305 "(scored), {s} (0 or 1)",

1306 comment_strings=ADDRESS_PARTS,

1307 )

1308 add_multiple_columns(

1309 cls,

1310 "mem_recall_address",

1311 1,

1312 N_MEM_REPEAT_RECALL_ADDR,

1313 pv=PV.BIT,

1314 comment_fmt="Memory, recall address {n}/7, {s} (0-1)",

1315 comment_strings=ADDRESS_PARTS,

1316 )

1317

1318 task_edition: Mapped[Optional[str]] = mapped_camcops_column(

1319 String(length=255),

1320 comment="Task edition.",

1321 )

1322 task_address_version: Mapped[Optional[str]] = mapped_camcops_column(

1323 String(length=1),

1324 comment="Task version, determining the address for recall (A/B/C).",

1325 permitted_value_checker=PermittedValueChecker(

1326 permitted_values=["A", "B", "C"]

1327 ),

1328 )

1329 remote_administration: Mapped[Optional[bool]] = mapped_camcops_column(

1330 permitted_value_checker=BIT_CHECKER,

1331 comment="Task performed using remote (videoconferencing) "

1332 "administration?",

1333 )

1334 age_at_leaving_full_time_education: Mapped[Optional[int]] = mapped_column(

1335 comment="Age at leaving full time education",

1336 )

1337 occupation: Mapped[Optional[str]] = mapped_column(

1338 "occupation", UnicodeText, comment=OCCUPATION

1339 )

1340 handedness: Mapped[Optional[str]] = mapped_camcops_column(

1341 String(length=1), # was Text

1342 comment="Handedness (L or R)",

1343 permitted_value_checker=PermittedValueChecker(

1344 permitted_values=["L", "R"]

1345 ),

1346 )

1347 fluency_animals_score: Mapped[Optional[int]] = mapped_camcops_column(

1348 comment="Fluency, animals, score 0-7",

1349 permitted_value_checker=PermittedValueChecker(minimum=0, maximum=7),

1350 )

1351 vsp_draw_clock: Mapped[Optional[int]] = mapped_camcops_column(

1352 comment="Visuospatial, draw clock (0-5)",

1353 permitted_value_checker=PermittedValueChecker(minimum=0, maximum=5),

1354 )

1355 picture1_blobid: Mapped[Optional[int]] = mapped_camcops_column(

1356 comment="Photo 1/2 PNG BLOB ID",

1357 is_blob_id_field=True,

1358 blob_relationship_attr_name="picture1",

1359 )

1360 picture2_blobid: Mapped[Optional[int]] = mapped_camcops_column(

1361 comment="Photo 2/2 PNG BLOB ID",

1362 is_blob_id_field=True,

1363 blob_relationship_attr_name="picture2",

1364 )

1365 comments: Mapped[Optional[str]] = mapped_column(

1366 "comments", UnicodeText, comment="Clinician's comments"

1367 )

1368

1369 picture1 = blob_relationship( # type: ignore[assignment]

1370 "MiniAce", "picture1_blobid"

1371 ) # type: Optional[Blob]

1372 picture2 = blob_relationship( # type: ignore[assignment]

1373 "MiniAce", "picture2_blobid"

1374 ) # type: Optional[Blob]

1375

1376 MACE_ATTN_FIELDS = strseq("attn_time", 1, N_ATTN_TIME_MINIACE) # 4 points

1377 MACE_MEMORY_FIELDS = strseq("mem_repeat_address_trial3_", 1, 7) + strseq(

1378 "mem_recall_address", 1, 7

1379 ) # 14 points

1380 MACE_FLUENCY_FIELDS = ["fluency_animals_score"] # 7 points

1381 MACE_VSP_FIELDS = ["vsp_draw_clock"] # 5 points

1382 MINI_ACE_FIELDS = (

1383 MACE_ATTN_FIELDS

1384 + MACE_MEMORY_FIELDS

1385 + MACE_FLUENCY_FIELDS

1386 + MACE_VSP_FIELDS

1387 )

1388

1389 @staticmethod

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

1391 _ = req.gettext

1392 return _("Mini-Addenbrooke’s Cognitive Examination")

1393

1394 def get_trackers(self, req: CamcopsRequest) -> List[TrackerInfo]:

1395 return [

1396 TrackerInfo(

1397 value=self.mini_ace_score(),

1398 plot_label="Mini-ACE score",

1399 axis_label=f"Mini-ACE score (out of {MINI_ACE_MAX})",

1400 axis_min=-0.5,

1401 axis_max=MINI_ACE_MAX + 0.5,

1402 # Traditional cutoffs: ≤21, ≤25

1403 horizontal_lines=[21.5, 25.5],

1404 ),

1405 ]

1406

1407 def get_clinical_text(self, req: CamcopsRequest) -> List[CtvInfo]:

1408 if not self.is_complete():

1409 return CTV_INCOMPLETE

1410 a = self.attn_score()

1411 m = self.mem_score()

1412 f = self.fluency_score()

1413 v = self.vsp_score()

1414 mini = a + m + f + v

1415 text = (

1416 f"Mini-ACE score: {mini}/{MINI_ACE_MAX} "

1417 f"(attention {a}/{ATTN_MINIACE_MAX}, "

1418 f"memory {m}/{MEM_MINIACE_MAX}, "

1419 f"fluency {f}/{FLUENCY_MINIACE_MAX}, "

1420 f"visuospatial {v}/{VSP_MINIACE_MAX})"

1421 )

1422 return [CtvInfo(content=text)]

1423

1424 def get_summaries(self, req: CamcopsRequest) -> List[SummaryElement]:

1425 return self.standard_task_summary_fields() + [

1426 SummaryElement(

1427 name="mini_ace",

1428 coltype=Integer(),

1429 value=self.mini_ace_score(),

1430 comment=f"Mini-ACE (/{MINI_ACE_MAX})",

1431 ),

1432 ]

1433

1434 def attn_score(self) -> int:

1435 return cast(int, self.sum_fields(self.MACE_ATTN_FIELDS))

1436

1437 def mem_score(self) -> int:

1438 return cast(int, self.sum_fields(self.MACE_MEMORY_FIELDS))

1439

1440 def fluency_score(self) -> int:

1441 return cast(int, self.sum_fields(self.MACE_FLUENCY_FIELDS))

1442

1443 def vsp_score(self) -> int:

1444 return cast(int, self.sum_fields(self.MACE_VSP_FIELDS))

1445

1446 def mini_ace_score(self) -> int:

1447 return cast(int, self.sum_fields(self.MINI_ACE_FIELDS))

1448

1449 def is_complete(self) -> bool:

1450 return (

1451 self.all_fields_not_none(self.MINI_ACE_FIELDS)

1452 and self.field_contents_valid()

1453 )

1454

1455 def get_task_html(self, req: CamcopsRequest) -> str:

1456 a = self.attn_score()

1457 m = self.mem_score()

1458 f = self.fluency_score()

1459 v = self.vsp_score()

1460 mini = a + m + f + v

1461 target_addr = qsequence(

1462 Ace3.get_target_address_parts(req, self.task_address_version)

1463 )

1464

1465 if self.is_complete():

1466 figsize = (

1467 PlotDefaults.FULLWIDTH_PLOT_WIDTH / 3,

1468 PlotDefaults.FULLWIDTH_PLOT_WIDTH / 4,

1469 )

1470 width = 0.9

1471 fig = req.create_figure(figsize=figsize)

1472 ax = fig.add_subplot(1, 1, 1)

1473 scores = numpy.array([a, m, f, v])

1474 maxima = numpy.array(

1475 [

1476 ATTN_MINIACE_MAX,

1477 MEM_MINIACE_MAX,

1478 FLUENCY_MINIACE_MAX,

1479 VSP_MINIACE_MAX,

1480 ]

1481 )

1482 y = 100 * scores / maxima

1483 x_labels = ["Attn", "Mem", "Flu", "VSp"]

1484 # noinspection PyTypeChecker

1485 n = len(y)

1486 xvar = numpy.arange(n)

1487 ax.bar(xvar, y, width, color="g")

1488 ax.set_ylabel("%", fontdict=req.fontdict)

1489 ax.set_xticks(xvar)

1490 x_offset = -0.5

1491 ax.set_xlim(0 + x_offset, len(scores) + x_offset)

1492 ax.set_xticklabels(x_labels, fontdict=req.fontdict)

1493 fig.tight_layout() # or the ylabel drops off the figure

1494 # fig.autofmt_xdate()

1495 req.set_figure_font_sizes(ax)

1496 figurehtml = req.get_html_from_pyplot_figure(fig)

1497 else:

1498 figurehtml = "<i>Incomplete; not plotted</i>"

1499

1500 return (

1501 self.get_standard_clinician_comments_block(req, self.comments)

1502 + f"""

1503 <div class="{CssClass.SUMMARY}">

1504 <table class="{CssClass.SUMMARY}">

1505 <tr>

1506 {self.get_is_complete_td_pair(req)}

1507 <td class="{CssClass.FIGURE}"

1508 rowspan="6">{figurehtml}</td>

1509 </tr>

1510 """

1511 + tr_score_with_pct(

1512 "Mini-ACE score <sup>[1]</sup>", mini, MINI_ACE_MAX

1513 )

1514 + tr_score_with_pct("Attention", a, ATTN_MINIACE_MAX)

1515 + tr_score_with_pct("Memory", m, MEM_MINIACE_MAX)

1516 + tr_score_with_pct("Fluency", f, FLUENCY_MINIACE_MAX)

1517 + tr_score_with_pct("Visuospatial", v, VSP_MINIACE_MAX)

1518 + f"""

1519 </table>

1520 </div>

1521 <table class="{CssClass.TASKCONFIG}">

1522 """

1523 + tr_heading("Task aspect", "Setting")

1524 + tr_qa("Edition", self.task_edition)

1525 + tr_qa("Version", self.task_address_version)

1526 + tr_qa(

1527 "Remote administration?",

1528 get_yes_no_none(req, self.remote_administration),

1529 )

1530 + f"""

1531 <table class="{CssClass.TASKDETAIL}">

1532 """

1533 + tr_heading("Question", "Answer/score")

1534 + tr_qa(

1535 AGE_FTE,

1536 self.age_at_leaving_full_time_education,

1537 )

1538 + tr_qa(OCCUPATION, ws.webify(self.occupation))

1539 + tr_qa(HANDEDNESS, ws.webify(self.handedness))

1540 + subheading_spanning_two_columns("Attention")

1541 + tr(

1542 "Day? Date? Month? Year?", # not season

1543 ", ".join(

1544 answer(x)

1545 for x in (

1546 self.attn_time1, # type: ignore[attr-defined]

1547 self.attn_time2, # type: ignore[attr-defined]

1548 self.attn_time3, # type: ignore[attr-defined]

1549 self.attn_time4, # type: ignore[attr-defined]

1550 )

1551 ),

1552 )

1553 + subheading_spanning_two_columns("Memory")

1554 + tr(

1555 "Third trial of address registration: " + target_addr,

1556 ", ".join(

1557 answer(x)

1558 for x in (

1559 self.mem_repeat_address_trial3_1, # type: ignore[attr-defined] # noqa: E501

1560 self.mem_repeat_address_trial3_2, # type: ignore[attr-defined] # noqa: E501

1561 self.mem_repeat_address_trial3_3, # type: ignore[attr-defined] # noqa: E501

1562 self.mem_repeat_address_trial3_4, # type: ignore[attr-defined] # noqa: E501

1563 self.mem_repeat_address_trial3_5, # type: ignore[attr-defined] # noqa: E501

1564 self.mem_repeat_address_trial3_6, # type: ignore[attr-defined] # noqa: E501

1565 self.mem_repeat_address_trial3_7, # type: ignore[attr-defined] # noqa: E501

1566 )

1567 ),

1568 )

1569 + subheading_spanning_two_columns("Fluency – animals")

1570 + tr(

1571 ANIMAL_FLUENCY_SCORING_HTML,

1572 answer(self.fluency_animals_score) + " / 7",

1573 )

1574 + subheading_spanning_two_columns("Clock drawing")

1575 + tr(

1576 "Draw clock with numbers and hands at 5:10",

1577 answer(self.vsp_draw_clock) + " / 5",

1578 )

1579 + subheading_spanning_two_columns("Memory recall")

1580 + tr(

1581 "Recall address: " + target_addr,

1582 ", ".join(

1583 answer(x)

1584 for x in (

1585 self.mem_recall_address1, # type: ignore[attr-defined]

1586 self.mem_recall_address2, # type: ignore[attr-defined]

1587 self.mem_recall_address3, # type: ignore[attr-defined]

1588 self.mem_recall_address4, # type: ignore[attr-defined]

1589 self.mem_recall_address5, # type: ignore[attr-defined]

1590 self.mem_recall_address6, # type: ignore[attr-defined]

1591 self.mem_recall_address7, # type: ignore[attr-defined]

1592 )

1593 ),

1594 )

1595 + subheading_spanning_two_columns("Photos of test sheet")

1596 + tr_span_col(

1597 get_blob_img_html(self.picture1), td_class=CssClass.PHOTO

1598 )

1599 + tr_span_col(

1600 get_blob_img_html(self.picture2), td_class=CssClass.PHOTO

1601 )

1602 + f"""

1603 </table>

1604 <div class="{CssClass.FOOTNOTES}">

1605 [1] {MINI_ACE_THRESHOLDS}

1606 </div>

1607 <div class="{CssClass.COPYRIGHT}">

1608 {ACE3_COPYRIGHT}

1609 </div>

1610 """

1611 )