Coverage for tasks/ace3.py: 53%

314 statements  

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

1""" 

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

26ACE-III and Mini-ACE. 

27 

28""" 

29 

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

31 

32from cardinal_pythonlib.stringfunc import strseq 

33import cardinal_pythonlib.rnc_web as ws 

34import numpy 

35from sqlalchemy.orm import Mapped, mapped_column 

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

37 

38from camcops_server.cc_modules.cc_blob import ( 

39 Blob, 

40 blob_relationship, 

41 get_blob_img_html, 

42) 

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

44from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo 

45from camcops_server.cc_modules.cc_db import add_multiple_columns 

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

56from camcops_server.cc_modules.cc_request import CamcopsRequest 

57from camcops_server.cc_modules.cc_snomed import SnomedExpression, SnomedLookup 

58from camcops_server.cc_modules.cc_sqla_coltypes import ( 

59 BIT_CHECKER, 

60 mapped_camcops_column, 

61 PermittedValueChecker, 

62) 

63from camcops_server.cc_modules.cc_summaryelement import SummaryElement 

64from camcops_server.cc_modules.cc_task import ( 

65 Task, 

66 TaskHasClinicianMixin, 

67 TaskHasPatientMixin, 

68) 

69from camcops_server.cc_modules.cc_trackerhelpers import TrackerInfo 

70 

71 

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

73# Constants 

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

75 

76ADDRESS_PARTS = [ 

77 "forename", 

78 "surname", 

79 "number", 

80 "street_1", 

81 "street_2", 

82 "town", 

83 "county", 

84] 

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

86PERCENT_DP = 1 

87 

88TOTAL_MAX = 100 

89ATTN_MAX = 18 

90MEMORY_MAX = 26 

91FLUENCY_MAX = 14 

92LANG_MAX = 26 

93VSP_MAX = 16 

94 

95ATTN_MINIACE_MAX = 4 

96MEM_MINIACE_MAX = 14 

97FLUENCY_MINIACE_MAX = 7 

98VSP_MINIACE_MAX = 5 

99MINI_ACE_MAX = 30 

100 

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

102OCCUPATION = "Occupation" 

103HANDEDNESS = "Handedness" 

104N_ATTN_TIME_ACE = 5 

105N_ATTN_TIME_MINIACE = 4 

106N_MEM_REPEAT_RECALL_ADDR = 7 

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

112ACE3_COPYRIGHT = """ 

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

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

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

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

117www.neura.edu.au). 

118""" 

119MINI_ACE_THRESHOLDS = f""" 

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

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

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

123variant frontotemporal dementia, corticobasal syndrome, primary progressive 

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

125""" 

126 

127 

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

129# Ancillary functions 

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

131 

132 

133def 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 

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

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

140 

141 

142def 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 

149def 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 

156def 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 

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

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