Coverage for tasks/ace3.py: 54%
193 statements
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-08 23:14 +0000
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-08 23:14 +0000
1#!/usr/bin/env python
3"""
4camcops_server/tasks/ace3.py
6===============================================================================
8 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
9 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
11 This file is part of CamCOPS.
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.
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.
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/>.
26===============================================================================
28"""
30from typing import Any, Dict, List, Optional, Tuple, Type, TYPE_CHECKING
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
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
69if TYPE_CHECKING:
70 from camcops_server.cc_modules.cc_blob import Blob
73# =============================================================================
74# Constants
75# =============================================================================
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
96# =============================================================================
97# Ancillary functions
98# =============================================================================
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
106# =============================================================================
107# ACE-III
108# =============================================================================
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 )
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 )
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 )
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 )
333 super().__init__(name, bases, classdict)
336class Ace3(
337 TaskHasPatientMixin, TaskHasClinicianMixin, Task, metaclass=Ace3Metaclass
338):
339 """
340 Server implementation of the ACE-III task.
341 """
343 __tablename__ = "ace3"
344 shortname = "ACE-III"
345 provides_trackers = True
347 prohibits_commercial = True
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")
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
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 )
487 @staticmethod
488 def longname(req: "CamcopsRequest") -> str:
489 _ = req.gettext
490 return _("Addenbrooke’s Cognitive Examination III")
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 ]
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)]
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 ]
561 def attn_score(self) -> int:
562 return self.sum_fields(self.ATTN_SCORE_FIELDS)
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)
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)
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
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 )
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)
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)
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)
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 )
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 )
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 )
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 )
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()
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)
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, <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 )
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