Coverage for tasks/ace3.py: 53%
314 statements
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-15 14:23 +0100
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-15 14:23 +0100
1"""
2camcops_server/tasks/ace3.py
4===============================================================================
6 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
7 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
9 This file is part of CamCOPS.
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.
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.
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/>.
24===============================================================================
26ACE-III and Mini-ACE.
28"""
30from typing import Any, cast, Iterable, List, Optional, Type
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
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
72# =============================================================================
73# Constants
74# =============================================================================
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
88TOTAL_MAX = 100
89ATTN_MAX = 18
90MEMORY_MAX = 26
91FLUENCY_MAX = 14
92LANG_MAX = 26
93VSP_MAX = 16
95ATTN_MINIACE_MAX = 4
96MEM_MINIACE_MAX = 14
97FLUENCY_MINIACE_MAX = 7
98VSP_MINIACE_MAX = 5
99MINI_ACE_MAX = 30
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 "<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"""
128# =============================================================================
129# Ancillary functions
130# =============================================================================
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
138def percent(score: int, maximum: int) -> str:
139 return ws.number_to_dp(100 * score / maximum, PERCENT_DP)
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 )
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)
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 """
168# =============================================================================
169# ACE-III
170# =============================================================================
173class Ace3(TaskHasPatientMixin, TaskHasClinicianMixin, Task): # type: ignore[misc] # noqa: E501
174 """
175 Server implementation of the ACE-III task.
176 """
178 __tablename__ = "ace3"
179 shortname = "ACE-III"
180 provides_trackers = True
182 prohibits_commercial = True
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 )
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 )
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 )
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 )
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 )
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 )
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 )
533 @staticmethod
534 def longname(req: "CamcopsRequest") -> str:
535 _ = req.gettext
536 return _("Addenbrooke’s Cognitive Examination III")
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 ]
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)]
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 ]
625 def attn_score(self) -> int:
626 return cast(int, self.sum_fields(self.ATTN_SCORE_FIELDS))
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)
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)
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
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 )
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 )
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))
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)
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 )
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 )
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 )
718 def mini_ace_score(self) -> int:
719 return cast(int, self.sum_fields(self.MINI_ACE_FIELDS))
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 )
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()
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.
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
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
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>"
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)}).
1199 [2] {MINI_ACE_THRESHOLDS}
1200 </div>
1201 <div class="{CssClass.COPYRIGHT}">
1202 {ACE3_COPYRIGHT}
1203 </div>
1204 """
1205 )
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
1246# =============================================================================
1247# Mini-ACE
1248# =============================================================================
1251class MiniAce( # type: ignore[misc]
1252 TaskHasPatientMixin,
1253 TaskHasClinicianMixin,
1254 Task,
1255):
1256 """
1257 Server implementation of the Mini-ACE task.
1258 """
1260 __tablename__ = "miniace"
1261 shortname = "Mini-ACE"
1262 extrastring_taskname = "ace3" # shares strings with ACE-III
1263 provides_trackers = True
1265 prohibits_commercial = True
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 )
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 )
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]
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 )
1389 @staticmethod
1390 def longname(req: "CamcopsRequest") -> str:
1391 _ = req.gettext
1392 return _("Mini-Addenbrooke’s Cognitive Examination")
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 ]
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)]
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 ]
1434 def attn_score(self) -> int:
1435 return cast(int, self.sum_fields(self.MACE_ATTN_FIELDS))
1437 def mem_score(self) -> int:
1438 return cast(int, self.sum_fields(self.MACE_MEMORY_FIELDS))
1440 def fluency_score(self) -> int:
1441 return cast(int, self.sum_fields(self.MACE_FLUENCY_FIELDS))
1443 def vsp_score(self) -> int:
1444 return cast(int, self.sum_fields(self.MACE_VSP_FIELDS))
1446 def mini_ace_score(self) -> int:
1447 return cast(int, self.sum_fields(self.MINI_ACE_FIELDS))
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 )
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 )
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>"
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 )