Coverage for tasks/ace3.py: 54%

193 statements  

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

1#!/usr/bin/env python 

2 

3""" 

4camcops_server/tasks/ace3.py 

5 

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

7 

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

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

10 

11 This file is part of CamCOPS. 

12 

13 CamCOPS is free software: you can redistribute it and/or modify 

14 it under the terms of the GNU General Public License as published by 

15 the Free Software Foundation, either version 3 of the License, or 

16 (at your option) any later version. 

17 

18 CamCOPS is distributed in the hope that it will be useful, 

19 but WITHOUT ANY WARRANTY; without even the implied warranty of 

20 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

21 GNU General Public License for more details. 

22 

23 You should have received a copy of the GNU General Public License 

24 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>. 

25 

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

27 

28""" 

29 

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

31 

32from cardinal_pythonlib.stringfunc import strseq 

33import cardinal_pythonlib.rnc_web as ws 

34import numpy 

35from sqlalchemy.ext.declarative import DeclarativeMeta 

36from sqlalchemy.sql.schema import Column 

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

38 

39from camcops_server.cc_modules.cc_blob import ( 

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 italic, 

49 subheading_spanning_two_columns, 

50 tr, 

51 tr_qa, 

52 tr_span_col, 

53) 

54from camcops_server.cc_modules.cc_request import CamcopsRequest 

55from camcops_server.cc_modules.cc_snomed import SnomedExpression, SnomedLookup 

56from camcops_server.cc_modules.cc_sqla_coltypes import ( 

57 BIT_CHECKER, 

58 CamcopsColumn, 

59 PermittedValueChecker, 

60) 

61from camcops_server.cc_modules.cc_summaryelement import SummaryElement 

62from camcops_server.cc_modules.cc_task import ( 

63 Task, 

64 TaskHasClinicianMixin, 

65 TaskHasPatientMixin, 

66) 

67from camcops_server.cc_modules.cc_trackerhelpers import TrackerInfo 

68 

69if TYPE_CHECKING: 

70 from camcops_server.cc_modules.cc_blob import Blob 

71 

72 

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

74# Constants 

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

76 

77ADDRESS_PARTS = [ 

78 "forename", 

79 "surname", 

80 "number", 

81 "street_1", 

82 "street_2", 

83 "town", 

84 "county", 

85] 

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

87PERCENT_DP = 1 

88TOTAL_MAX = 100 

89ATTN_MAX = 18 

90MEMORY_MAX = 26 

91FLUENCY_MAX = 14 

92LANG_MAX = 26 

93VSP_MAX = 16 

94 

95 

96# ============================================================================= 

97# Ancillary functions 

98# ============================================================================= 

99 

100 

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

102 """0 if x is None else x""" 

103 return 0 if x is None else x 

104 

105 

106# ============================================================================= 

107# ACE-III 

108# ============================================================================= 

109 

110 

111class Ace3Metaclass(DeclarativeMeta): 

112 # noinspection PyInitNewSignature 

113 def __init__( 

114 cls: Type["Ace3"], 

115 name: str, 

116 bases: Tuple[Type, ...], 

117 classdict: Dict[str, Any], 

118 ) -> None: 

119 add_multiple_columns( 

120 cls, 

121 "attn_time", 

122 1, 

123 5, 

124 pv=PV.BIT, 

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

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

127 ) 

128 add_multiple_columns( 

129 cls, 

130 "attn_place", 

131 1, 

132 5, 

133 pv=PV.BIT, 

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

135 comment_strings=[ 

136 "house number/floor", 

137 "street/hospital", 

138 "town", 

139 "county", 

140 "country", 

141 ], 

142 ) 

143 add_multiple_columns( 

144 cls, 

145 "attn_repeat_word", 

146 1, 

147 3, 

148 pv=PV.BIT, 

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

150 comment_strings=RECALL_WORDS, 

151 ) 

152 add_multiple_columns( 

153 cls, 

154 "attn_serial7_subtraction", 

155 1, 

156 5, 

157 pv=PV.BIT, 

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

159 ) 

160 

161 add_multiple_columns( 

162 cls, 

163 "mem_recall_word", 

164 1, 

165 3, 

166 pv=PV.BIT, 

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

168 comment_strings=RECALL_WORDS, 

169 ) 

170 add_multiple_columns( 

171 cls, 

172 "mem_repeat_address_trial1_", 

173 1, 

174 7, 

175 pv=PV.BIT, 

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

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

178 comment_strings=ADDRESS_PARTS, 

179 ) 

180 add_multiple_columns( 

181 cls, 

182 "mem_repeat_address_trial2_", 

183 1, 

184 7, 

185 pv=PV.BIT, 

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

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

188 comment_strings=ADDRESS_PARTS, 

189 ) 

190 add_multiple_columns( 

191 cls, 

192 "mem_repeat_address_trial3_", 

193 1, 

194 7, 

195 pv=PV.BIT, 

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

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

198 comment_strings=ADDRESS_PARTS, 

199 ) 

200 add_multiple_columns( 

201 cls, 

202 "mem_famous", 

203 1, 

204 4, 

205 pv=PV.BIT, 

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

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

208 ) 

209 

210 add_multiple_columns( 

211 cls, 

212 "lang_follow_command", 

213 1, 

214 3, 

215 pv=PV.BIT, 

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

217 ) 

218 add_multiple_columns( 

219 cls, 

220 "lang_write_sentences_point", 

221 1, 

222 2, 

223 pv=PV.BIT, 

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

225 comment_strings=[ 

226 "two sentences on same topic", 

227 "grammar/spelling", 

228 ], 

229 ) 

230 add_multiple_columns( 

231 cls, 

232 "lang_repeat_word", 

233 1, 

234 4, 

235 pv=PV.BIT, 

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

237 comment_strings=[ 

238 "caterpillar", 

239 "eccentricity", 

240 "unintelligible", 

241 "statistician", 

242 ], 

243 ) 

244 add_multiple_columns( 

245 cls, 

246 "lang_repeat_sentence", 

247 1, 

248 2, 

249 pv=PV.BIT, 

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

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

252 ) 

253 add_multiple_columns( 

254 cls, 

255 "lang_name_picture", 

256 1, 

257 12, 

258 pv=PV.BIT, 

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

260 comment_strings=[ 

261 "spoon", 

262 "book", 

263 "kangaroo/wallaby", 

264 "penguin", 

265 "anchor", 

266 "camel/dromedary", 

267 "harp", 

268 "rhinoceros", 

269 "barrel/keg/tub", 

270 "crown", 

271 "alligator/crocodile", 

272 "accordion/piano accordion/squeeze box", 

273 ], 

274 ) 

275 add_multiple_columns( 

276 cls, 

277 "lang_identify_concept", 

278 1, 

279 4, 

280 pv=PV.BIT, 

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

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

283 ) 

284 

285 add_multiple_columns( 

286 cls, 

287 "vsp_count_dots", 

288 1, 

289 4, 

290 pv=PV.BIT, 

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

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

293 ) 

294 add_multiple_columns( 

295 cls, 

296 "vsp_identify_letter", 

297 1, 

298 4, 

299 pv=PV.BIT, 

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

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

302 ) 

303 add_multiple_columns( 

304 cls, 

305 "mem_recall_address", 

306 1, 

307 7, 

308 pv=PV.BIT, 

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

310 comment_strings=ADDRESS_PARTS, 

311 ) 

312 add_multiple_columns( 

313 cls, 

314 "mem_recognize_address", 

315 1, 

316 5, 

317 pv=PV.BIT, 

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

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

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

321 ) 

322 add_multiple_columns( # tablet version 2.0.0 onwards 

323 cls, 

324 "mem_recognize_address_choice", 

325 1, 

326 5, 

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

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

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

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

331 ) 

332 

333 super().__init__(name, bases, classdict) 

334 

335 

336class Ace3( 

337 TaskHasPatientMixin, TaskHasClinicianMixin, Task, metaclass=Ace3Metaclass 

338): 

339 """ 

340 Server implementation of the ACE-III task. 

341 """ 

342 

343 __tablename__ = "ace3" 

344 shortname = "ACE-III" 

345 provides_trackers = True 

346 

347 prohibits_commercial = True 

348 

349 age_at_leaving_full_time_education = Column( 

350 "age_at_leaving_full_time_education", 

351 Integer, 

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

353 ) 

354 occupation = Column("occupation", UnicodeText, comment="Occupation") 

355 handedness = CamcopsColumn( 

356 "handedness", 

357 String(length=1), # was Text 

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

359 permitted_value_checker=PermittedValueChecker( 

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

361 ), 

362 ) 

363 attn_num_registration_trials = Column( 

364 "attn_num_registration_trials", 

365 Integer, 

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

367 ) 

368 fluency_letters_score = CamcopsColumn( 

369 "fluency_letters_score", 

370 Integer, 

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

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

373 ) 

374 fluency_animals_score = CamcopsColumn( 

375 "fluency_animals_score", 

376 Integer, 

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

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

379 ) 

380 lang_follow_command_practice = CamcopsColumn( 

381 "lang_follow_command_practice", 

382 Integer, 

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

384 permitted_value_checker=BIT_CHECKER, 

385 ) 

386 lang_read_words_aloud = CamcopsColumn( 

387 "lang_read_words_aloud", 

388 Integer, 

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

390 permitted_value_checker=BIT_CHECKER, 

391 ) 

392 vsp_copy_infinity = CamcopsColumn( 

393 "vsp_copy_infinity", 

394 Integer, 

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

396 permitted_value_checker=BIT_CHECKER, 

397 ) 

398 vsp_copy_cube = CamcopsColumn( 

399 "vsp_copy_cube", 

400 Integer, 

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

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

403 ) 

404 vsp_draw_clock = CamcopsColumn( 

405 "vsp_draw_clock", 

406 Integer, 

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

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

409 ) 

410 picture1_blobid = CamcopsColumn( 

411 "picture1_blobid", 

412 Integer, 

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

414 is_blob_id_field=True, 

415 blob_relationship_attr_name="picture1", 

416 ) 

417 picture1_rotation = Column( 

418 # DEFUNCT as of v2.0.0 

419 # IGNORED. REMOVE WHEN ALL PRE-2.0.0 TABLETS GONE 

420 "picture1_rotation", 

421 Integer, 

422 comment="Photo 1/2 rotation (degrees clockwise)", 

423 ) 

424 picture2_blobid = CamcopsColumn( 

425 "picture2_blobid", 

426 Integer, 

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

428 is_blob_id_field=True, 

429 blob_relationship_attr_name="picture2", 

430 ) 

431 picture2_rotation = Column( 

432 # DEFUNCT as of v2.0.0 

433 # IGNORED. REMOVE WHEN ALL PRE-2.0.0 TABLETS GONE 

434 "picture2_rotation", 

435 Integer, 

436 comment="Photo 2/2 rotation (degrees clockwise)", 

437 ) 

438 comments = Column("comments", UnicodeText, comment="Clinician's comments") 

439 

440 picture1 = blob_relationship( 

441 "Ace3", "picture1_blobid" 

442 ) # type: Optional[Blob] # noqa 

443 picture2 = blob_relationship( 

444 "Ace3", "picture2_blobid" 

445 ) # type: Optional[Blob] # noqa 

446 

447 ATTN_SCORE_FIELDS = ( 

448 strseq("attn_time", 1, 5) 

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

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

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

452 ) 

453 MEM_NON_RECOG_SCORE_FIELDS = ( 

454 strseq("mem_recall_word", 1, 3) 

455 + strseq("mem_repeat_address_trial3_", 1, 7) 

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

457 + strseq("mem_recall_address", 1, 7) 

458 ) 

459 LANG_SIMPLE_SCORE_FIELDS = ( 

460 strseq("lang_write_sentences_point", 1, 2) 

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

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

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

464 ) 

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

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

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

468 "vsp_identify_letter", 1, 4 

469 ) 

470 BASIC_COMPLETENESS_FIELDS = ( 

471 ATTN_SCORE_FIELDS 

472 + MEM_NON_RECOG_SCORE_FIELDS 

473 + ["fluency_letters_score", "fluency_animals_score"] 

474 + ["lang_follow_command_practice"] 

475 + LANG_SIMPLE_SCORE_FIELDS 

476 + LANG_REPEAT_WORD_FIELDS 

477 + [ 

478 "lang_read_words_aloud", 

479 "vsp_copy_infinity", 

480 "vsp_copy_cube", 

481 "vsp_draw_clock", 

482 ] 

483 + VSP_SIMPLE_SCORE_FIELDS 

484 + strseq("mem_recall_address", 1, 7) 

485 ) 

486 

487 @staticmethod 

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

489 _ = req.gettext 

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

491 

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

493 return [ 

494 TrackerInfo( 

495 value=self.total_score(), 

496 plot_label="ACE-III total score", 

497 axis_label="Total score (out of 100)", 

498 axis_min=-0.5, 

499 axis_max=100.5, 

500 horizontal_lines=[82.5, 88.5], 

501 ) 

502 ] 

503 

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

505 if not self.is_complete(): 

506 return CTV_INCOMPLETE 

507 a = self.attn_score() 

508 m = self.mem_score() 

509 f = self.fluency_score() 

510 lang = self.lang_score() 

511 v = self.vsp_score() 

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

513 text = ( 

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

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

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

517 f"visuospatial {v}/{VSP_MAX})" 

518 ) 

519 return [CtvInfo(content=text)] 

520 

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

522 return self.standard_task_summary_fields() + [ 

523 SummaryElement( 

524 name="total", 

525 coltype=Integer(), 

526 value=self.total_score(), 

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

528 ), 

529 SummaryElement( 

530 name="attn", 

531 coltype=Integer(), 

532 value=self.attn_score(), 

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

534 ), 

535 SummaryElement( 

536 name="mem", 

537 coltype=Integer(), 

538 value=self.mem_score(), 

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

540 ), 

541 SummaryElement( 

542 name="fluency", 

543 coltype=Integer(), 

544 value=self.fluency_score(), 

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

546 ), 

547 SummaryElement( 

548 name="lang", 

549 coltype=Integer(), 

550 value=self.lang_score(), 

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

552 ), 

553 SummaryElement( 

554 name="vsp", 

555 coltype=Integer(), 

556 value=self.vsp_score(), 

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

558 ), 

559 ] 

560 

561 def attn_score(self) -> int: 

562 return self.sum_fields(self.ATTN_SCORE_FIELDS) 

563 

564 @staticmethod 

565 def get_recog_score( 

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

567 ) -> int: 

568 if recalled == 1: 

569 return 1 

570 return score_zero_for_absent(recognized) 

571 

572 @staticmethod 

573 def get_recog_text( 

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

575 ) -> str: 

576 if recalled: 

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

578 return answer(recognized) 

579 

580 # noinspection PyUnresolvedReferences 

581 def get_mem_recognition_score(self) -> int: 

582 score = 0 

583 score += self.get_recog_score( 

584 (self.mem_recall_address1 == 1 and self.mem_recall_address2 == 1), 

585 self.mem_recognize_address1, 

586 ) 

587 score += self.get_recog_score( 

588 (self.mem_recall_address3 == 1), self.mem_recognize_address2 

589 ) 

590 score += self.get_recog_score( 

591 (self.mem_recall_address4 == 1 and self.mem_recall_address5 == 1), 

592 self.mem_recognize_address3, 

593 ) 

594 score += self.get_recog_score( 

595 (self.mem_recall_address6 == 1), self.mem_recognize_address4 

596 ) 

597 score += self.get_recog_score( 

598 (self.mem_recall_address7 == 1), self.mem_recognize_address5 

599 ) 

600 return score 

601 

602 def mem_score(self) -> int: 

603 return ( 

604 self.sum_fields(self.MEM_NON_RECOG_SCORE_FIELDS) 

605 + self.get_mem_recognition_score() 

606 ) 

607 

608 def fluency_score(self) -> int: 

609 return score_zero_for_absent( 

610 self.fluency_letters_score 

611 ) + score_zero_for_absent(self.fluency_animals_score) 

612 

613 def get_follow_command_score(self) -> int: 

614 if self.lang_follow_command_practice != 1: 

615 return 0 

616 return self.sum_fields(self.LANG_FOLLOW_CMD_FIELDS) 

617 

618 def get_repeat_word_score(self) -> int: 

619 n = self.sum_fields(self.LANG_REPEAT_WORD_FIELDS) 

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

621 

622 def lang_score(self) -> int: 

623 return ( 

624 self.sum_fields(self.LANG_SIMPLE_SCORE_FIELDS) 

625 + self.get_follow_command_score() 

626 + self.get_repeat_word_score() 

627 + score_zero_for_absent(self.lang_read_words_aloud) 

628 ) 

629 

630 def vsp_score(self) -> int: 

631 return ( 

632 self.sum_fields(self.VSP_SIMPLE_SCORE_FIELDS) 

633 + score_zero_for_absent(self.vsp_copy_infinity) 

634 + score_zero_for_absent(self.vsp_copy_cube) 

635 + score_zero_for_absent(self.vsp_draw_clock) 

636 ) 

637 

638 def total_score(self) -> int: 

639 return ( 

640 self.attn_score() 

641 + self.mem_score() 

642 + self.fluency_score() 

643 + self.lang_score() 

644 + self.vsp_score() 

645 ) 

646 

647 # noinspection PyUnresolvedReferences 

648 def is_recognition_complete(self) -> bool: 

649 return ( 

650 ( 

651 ( 

652 self.mem_recall_address1 == 1 

653 and self.mem_recall_address2 == 1 

654 ) 

655 or self.mem_recognize_address1 is not None 

656 ) 

657 and ( 

658 self.mem_recall_address3 == 1 

659 or self.mem_recognize_address2 is not None 

660 ) 

661 and ( 

662 ( 

663 self.mem_recall_address4 == 1 

664 and self.mem_recall_address5 == 1 

665 ) 

666 or self.mem_recognize_address3 is not None 

667 ) 

668 and ( 

669 self.mem_recall_address6 == 1 

670 or self.mem_recognize_address4 is not None 

671 ) 

672 and ( 

673 self.mem_recall_address7 == 1 

674 or self.mem_recognize_address5 is not None 

675 ) 

676 ) 

677 

678 def is_complete(self) -> bool: 

679 if self.any_fields_none(self.BASIC_COMPLETENESS_FIELDS): 

680 return False 

681 if not self.field_contents_valid(): 

682 return False 

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

684 self.LANG_FOLLOW_CMD_FIELDS 

685 ): 

686 return False 

687 return self.is_recognition_complete() 

688 

689 # noinspection PyUnresolvedReferences 

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

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

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

693 

694 a = self.attn_score() 

695 m = self.mem_score() 

696 f = self.fluency_score() 

697 lang = self.lang_score() 

698 v = self.vsp_score() 

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

700 if self.is_complete(): 

701 figsize = ( 

702 PlotDefaults.FULLWIDTH_PLOT_WIDTH / 3, 

703 PlotDefaults.FULLWIDTH_PLOT_WIDTH / 4, 

704 ) 

705 width = 0.9 

706 fig = req.create_figure(figsize=figsize) 

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

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

709 maxima = numpy.array( 

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

711 ) 

712 y = 100 * scores / maxima 

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

714 # noinspection PyTypeChecker 

715 n = len(y) 

716 xvar = numpy.arange(n) 

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

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

719 ax.set_xticks(xvar) 

720 x_offset = -0.5 

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

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

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

724 # fig.autofmt_xdate() 

725 req.set_figure_font_sizes(ax) 

726 figurehtml = req.get_html_from_pyplot_figure(fig) 

727 else: 

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

729 return ( 

730 self.get_standard_clinician_comments_block(req, self.comments) 

731 + f""" 

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

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

734 <tr> 

735 {self.get_is_complete_td_pair(req)} 

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

737 rowspan="7">{figurehtml}</td> 

738 </tr> 

739 """ 

740 + tr("Total ACE-III score <sup>[1]</sup>", answer(t) + " / 100") 

741 + tr( 

742 "Attention", 

743 answer(a) + f" / {ATTN_MAX} ({percent(a, ATTN_MAX)}%)", 

744 ) 

745 + tr( 

746 "Memory", 

747 answer(m) + f" / {MEMORY_MAX} ({percent(m, MEMORY_MAX)}%)", 

748 ) 

749 + tr( 

750 "Fluency", 

751 answer(f) + f" / {FLUENCY_MAX} ({percent(f, FLUENCY_MAX)}%)", 

752 ) 

753 + tr( 

754 "Language", 

755 answer(lang) + f" / {LANG_MAX} ({percent(lang, LANG_MAX)}%)", 

756 ) 

757 + tr( 

758 "Visuospatial", 

759 answer(v) + f" / {VSP_MAX} ({percent(v, VSP_MAX)}%)", 

760 ) 

761 + f""" 

762 </table> 

763 </div> 

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

765 <tr> 

766 <th width="75%">Question</th> 

767 <th width="25%">Answer/score</td> 

768 </tr> 

769 """ 

770 + tr_qa( 

771 "Age on leaving full-time education", 

772 self.age_at_leaving_full_time_education, 

773 ) 

774 + tr_qa("Occupation", ws.webify(self.occupation)) 

775 + tr_qa("Handedness", ws.webify(self.handedness)) 

776 + subheading_spanning_two_columns("Attention") 

777 + tr( 

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

779 ", ".join( 

780 answer(x) 

781 for x in ( 

782 self.attn_time1, 

783 self.attn_time2, 

784 self.attn_time3, 

785 self.attn_time4, 

786 self.attn_time5, 

787 ) 

788 ), 

789 ) 

790 + tr( 

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

792 ", ".join( 

793 answer(x) 

794 for x in ( 

795 self.attn_place1, 

796 self.attn_place2, 

797 self.attn_place3, 

798 self.attn_place4, 

799 self.attn_place5, 

800 ) 

801 ), 

802 ) 

803 + tr( 

804 "Repeat: Lemon? Key? Ball?", 

805 ", ".join( 

806 answer(x) 

807 for x in ( 

808 self.attn_repeat_word1, 

809 self.attn_repeat_word2, 

810 self.attn_repeat_word3, 

811 ) 

812 ), 

813 ) 

814 + tr( 

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

816 answer( 

817 self.attn_num_registration_trials, formatter_answer=italic 

818 ), 

819 ) 

820 + tr( 

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

822 "Fifth?", 

823 ", ".join( 

824 answer(x) 

825 for x in ( 

826 self.attn_serial7_subtraction1, 

827 self.attn_serial7_subtraction2, 

828 self.attn_serial7_subtraction3, 

829 self.attn_serial7_subtraction4, 

830 self.attn_serial7_subtraction5, 

831 ) 

832 ), 

833 ) 

834 + subheading_spanning_two_columns("Memory (1)") 

835 + tr( 

836 "Recall: Lemon? Key? Ball?", 

837 ", ".join( 

838 answer(x) 

839 for x in ( 

840 self.mem_recall_word1, 

841 self.mem_recall_word2, 

842 self.mem_recall_word3, 

843 ) 

844 ), 

845 ) 

846 + subheading_spanning_two_columns("Fluency") 

847 + tr( 

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

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

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

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

852 ) 

853 + tr( 

854 "Score for animals <i>(≥22 scores 7, 17–21 scores 6, " 

855 "14–16 scores 5, 11–13 scores 4, 9–10 scores 3, " 

856 "7–8 scores 2, 5–6 scores 1, &lt;5 scores 0)</i>", 

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

858 ) 

859 + subheading_spanning_two_columns("Memory (2)") 

860 + tr( 

861 "Third trial of address registration: Harry? Barnes? 73? " 

862 "Orchard? Close? Kingsbridge? Devon?", 

863 ", ".join( 

864 answer(x) 

865 for x in ( 

866 self.mem_repeat_address_trial3_1, 

867 self.mem_repeat_address_trial3_2, 

868 self.mem_repeat_address_trial3_3, 

869 self.mem_repeat_address_trial3_4, 

870 self.mem_repeat_address_trial3_5, 

871 self.mem_repeat_address_trial3_6, 

872 self.mem_repeat_address_trial3_7, 

873 ) 

874 ), 

875 ) 

876 + tr( 

877 "Current PM? Woman who was PM? USA president? USA president " 

878 "assassinated in 1960s?", 

879 ", ".join( 

880 answer(x) 

881 for x in ( 

882 self.mem_famous1, 

883 self.mem_famous2, 

884 self.mem_famous3, 

885 self.mem_famous4, 

886 ) 

887 ), 

888 ) 

889 + subheading_spanning_two_columns("Language") 

890 + tr( 

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

892 "paper”)</i>", 

893 answer( 

894 self.lang_follow_command_practice, formatter_answer=italic 

895 ), 

896 ) 

897 + tr_qa( 

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

899 self.lang_follow_command1, 

900 ) 

901 + tr_qa( 

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

903 self.lang_follow_command2, 

904 ) 

905 + tr_qa( 

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

907 self.lang_follow_command3, 

908 ) 

909 + tr( 

910 "Sentence-writing: point for ≥2 complete sentences about " 

911 "the one topic? Point for correct grammar and spelling?", 

912 ", ".join( 

913 answer(x) 

914 for x in ( 

915 self.lang_write_sentences_point1, 

916 self.lang_write_sentences_point2, 

917 ) 

918 ), 

919 ) 

920 + tr( 

921 "Repeat: caterpillar? eccentricity? unintelligible? " 

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

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

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

925 answer(self.lang_repeat_word1, formatter_answer=italic), 

926 answer(self.lang_repeat_word2, formatter_answer=italic), 

927 answer(self.lang_repeat_word3, formatter_answer=italic), 

928 answer(self.lang_repeat_word4, formatter_answer=italic), 

929 self.get_repeat_word_score(), 

930 ), 

931 ) 

932 + tr_qa( 

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

934 self.lang_repeat_sentence1, 

935 ) 

936 + tr_qa( 

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

938 self.lang_repeat_sentence2, 

939 ) 

940 + tr( 

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

942 ", ".join( 

943 answer(x) 

944 for x in ( 

945 self.lang_name_picture1, 

946 self.lang_name_picture2, 

947 self.lang_name_picture3, 

948 ) 

949 ), 

950 ) 

951 + tr( 

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

953 ", ".join( 

954 answer(x) 

955 for x in ( 

956 self.lang_name_picture4, 

957 self.lang_name_picture5, 

958 self.lang_name_picture6, 

959 ) 

960 ), 

961 ) 

962 + tr( 

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

964 ", ".join( 

965 answer(x) 

966 for x in ( 

967 self.lang_name_picture7, 

968 self.lang_name_picture8, 

969 self.lang_name_picture9, 

970 ) 

971 ), 

972 ) 

973 + tr( 

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

975 "accordion/piano accordion/squeeze box", 

976 ", ".join( 

977 answer(x) 

978 for x in ( 

979 self.lang_name_picture10, 

980 self.lang_name_picture11, 

981 self.lang_name_picture12, 

982 ) 

983 ), 

984 ) 

985 + tr( 

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

987 ", ".join( 

988 answer(x) 

989 for x in ( 

990 self.lang_identify_concept1, 

991 self.lang_identify_concept2, 

992 self.lang_identify_concept3, 

993 self.lang_identify_concept4, 

994 ) 

995 ), 

996 ) 

997 + tr_qa( 

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

999 self.lang_read_words_aloud, 

1000 ) 

1001 + subheading_spanning_two_columns("Visuospatial") 

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

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

1004 + tr( 

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

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

1007 ) 

1008 + tr( 

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

1010 ", ".join( 

1011 answer(x) 

1012 for x in ( 

1013 self.vsp_count_dots1, 

1014 self.vsp_count_dots2, 

1015 self.vsp_count_dots3, 

1016 self.vsp_count_dots4, 

1017 ) 

1018 ), 

1019 ) 

1020 + tr( 

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

1022 ", ".join( 

1023 answer(x) 

1024 for x in ( 

1025 self.vsp_identify_letter1, 

1026 self.vsp_identify_letter2, 

1027 self.vsp_identify_letter3, 

1028 self.vsp_identify_letter4, 

1029 ) 

1030 ), 

1031 ) 

1032 + subheading_spanning_two_columns("Memory (3)") 

1033 + tr( 

1034 "Recall address: Harry? Barnes? 73? Orchard? Close? " 

1035 "Kingsbridge? Devon?", 

1036 ", ".join( 

1037 answer(x) 

1038 for x in ( 

1039 self.mem_recall_address1, 

1040 self.mem_recall_address2, 

1041 self.mem_recall_address3, 

1042 self.mem_recall_address4, 

1043 self.mem_recall_address5, 

1044 self.mem_recall_address6, 

1045 self.mem_recall_address7, 

1046 ) 

1047 ), 

1048 ) 

1049 + tr( 

1050 "Recognize address: Jerry Barnes/Harry Barnes/Harry Bradford?", 

1051 self.get_recog_text( 

1052 ( 

1053 self.mem_recall_address1 == 1 

1054 and self.mem_recall_address2 == 1 

1055 ), 

1056 self.mem_recognize_address1, 

1057 ), 

1058 ) 

1059 + tr( 

1060 "Recognize address: 37/73/76?", 

1061 self.get_recog_text( 

1062 (self.mem_recall_address3 == 1), 

1063 self.mem_recognize_address2, 

1064 ), 

1065 ) 

1066 + tr( 

1067 "Recognize address: Orchard Place/Oak Close/Orchard " "Close?", 

1068 self.get_recog_text( 

1069 ( 

1070 self.mem_recall_address4 == 1 

1071 and self.mem_recall_address5 == 1 

1072 ), 

1073 self.mem_recognize_address3, 

1074 ), 

1075 ) 

1076 + tr( 

1077 "Recognize address: Oakhampton/Kingsbridge/Dartington?", 

1078 self.get_recog_text( 

1079 (self.mem_recall_address6 == 1), 

1080 self.mem_recognize_address4, 

1081 ), 

1082 ) 

1083 + tr( 

1084 "Recognize address: Devon/Dorset/Somerset?", 

1085 self.get_recog_text( 

1086 (self.mem_recall_address7 == 1), 

1087 self.mem_recognize_address5, 

1088 ), 

1089 ) 

1090 + subheading_spanning_two_columns("Photos of test sheet") 

1091 + tr_span_col( 

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

1093 ) 

1094 + tr_span_col( 

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

1096 ) 

1097 + f""" 

1098 </table> 

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

1100 [1] In the ACE-R (the predecessor of the ACE-III), 

1101 scores ≤82 had sensitivity 0.84 and specificity 1.0 for 

1102 dementia, and scores ≤88 had sensitivity 0.94 and 

1103 specificity 0.89 for dementia, in a context of patients 

1104 with AlzD, FTD, LBD, MCI, and controls 

1105 (Mioshi et al., 2006, PMID 16977673). 

1106 </div> 

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

1108 ACE-III: Copyright © 2012, John Hodges. 

1109 “The ACE-III is available for free. The copyright is held 

1110 by Professor John Hodges who is happy for the test to be 

1111 used in clinical practice and research projects. There is 

1112 no need to contact us if you wish to use the ACE-III in 

1113 clinical practice.” 

1114 (ACE-III FAQ, 7 July 2013, www.neura.edu.au). 

1115 </div> 

1116 """ 

1117 ) 

1118 

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

1120 codes = [ 

1121 SnomedExpression( 

1122 req.snomed(SnomedLookup.ACE_R_PROCEDURE_ASSESSMENT) 

1123 ) 

1124 ] 

1125 # add(SnomedLookup.ACE_R_PROCEDURE_ASSESSMENT_SUBSCALE_ATTENTION_ORIENTATION) # noqa 

1126 # add(SnomedLookup.ACE_R_PROCEDURE_ASSESSMENT_SUBSCALE_MEMORY) 

1127 # add(SnomedLookup.ACE_R_PROCEDURE_ASSESSMENT_SUBSCALE_FLUENCY) 

1128 # add(SnomedLookup.ACE_R_PROCEDURE_ASSESSMENT_SUBSCALE_LANGUAGE) 

1129 # add(SnomedLookup.ACE_R_PROCEDURE_ASSESSMENT_SUBSCALE_VISUOSPATIAL) 

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

1131 a = self.attn_score() 

1132 m = self.mem_score() 

1133 f = self.fluency_score() 

1134 lang = self.lang_score() 

1135 v = self.vsp_score() 

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

1137 codes.append( 

1138 SnomedExpression( 

1139 req.snomed(SnomedLookup.ACE_R_SCALE), 

1140 { 

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

1142 req.snomed( 

1143 SnomedLookup.ACE_R_SUBSCORE_ATTENTION_ORIENTATION 

1144 ): a, # noqa 

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

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

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

1148 req.snomed( 

1149 SnomedLookup.ACE_R_SUBSCORE_VISUOSPATIAL 

1150 ): v, 

1151 }, 

1152 ) 

1153 ) 

1154 return codes