Coverage for tasks/aq.py: 47%
122 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/aq.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===============================================================================
26**The Adult Autism Spectrum Quotient (AQ) Ages 16+ task.**
28"""
30from typing import Any, Dict, Iterable, List, Optional, Type
32from cardinal_pythonlib.stringfunc import strseq
33from sqlalchemy.sql.sqltypes import Integer
35from camcops_server.cc_modules.cc_constants import CssClass
36from camcops_server.cc_modules.cc_ctvinfo import CtvInfo, CTV_INCOMPLETE
37from camcops_server.cc_modules.cc_db import add_multiple_columns
38from camcops_server.cc_modules.cc_fhir import (
39 FHIRAnsweredQuestion,
40 FHIRAnswerType,
41 FHIRQuestionType,
42)
43from camcops_server.cc_modules.cc_html import answer, tr
44from camcops_server.cc_modules.cc_request import CamcopsRequest
45from camcops_server.cc_modules.cc_summaryelement import SummaryElement
46from camcops_server.cc_modules.cc_task import Task, TaskHasPatientMixin
47from camcops_server.cc_modules.cc_text import SS
50def to_csv(values: Iterable[Any]) -> str:
51 """
52 Create a comma-separated string from iterable.
53 """
54 return ", ".join(str(v) for v in values)
57class Aq( # type: ignore[misc]
58 TaskHasPatientMixin,
59 Task,
60):
61 __tablename__ = "aq"
62 shortname = "AQ"
64 prohibits_commercial = True
66 FIRST_Q = 1
67 LAST_Q = 50
68 PREFIX = "q"
69 MAX_AREA_SCORE = 10
70 MAX_SCORE = 50
72 # Questions where agreement indicates autistic-like traits.
74 @classmethod
75 def extend_columns(cls: Type["Aq"], **kwargs: Any) -> None:
76 add_multiple_columns(
77 cls,
78 cls.PREFIX,
79 cls.FIRST_Q,
80 cls.LAST_Q,
81 coltype=Integer,
82 minimum=0,
83 maximum=3,
84 comment_fmt=cls.PREFIX + "{n} - {s}",
85 comment_strings=[
86 # 1-5:
87 "prefer doing things with others",
88 "prefer doing things the same way",
89 "can create picture in mind",
90 "get strongly absorbed in one thing",
91 "notice small sounds",
92 # 6-10:
93 "notice car number plates",
94 "what I’ve said is impolite",
95 "can imagine what story characters look like",
96 "fascinated by dates",
97 "can keep track of conversations",
98 # 11-15:
99 "find social situations easy",
100 "notice details",
101 "prefer library to party",
102 "find making up stories easy",
103 "drawn more strongly to people",
104 # 16-20:
105 "upset if can't pursue strong interests",
106 "enjoy chit-chat",
107 "not easy for others to get a word in edgeways",
108 "fascinated by numbers",
109 "can't work out story characters’ intentions",
110 # 21-25:
111 "don’t enjoy fiction",
112 "hard to make new friends",
113 "notice patterns",
114 "prefer theatre to museum",
115 "not upset if daily routine disturbed",
116 # 26-30:
117 "don't know how to keep conversation going",
118 "easy to read between the lines",
119 "concentrate more on whole picture",
120 "can't remember phone numbers",
121 "don’t notice small changes",
122 # 31-35:
123 "can tell if person listening is bored",
124 "easy to do more than one thing",
125 "not sure when to speak on phone",
126 "enjoy doing things spontaneously",
127 "last to understand joke",
128 # 36-40:
129 "can work out thinking or feeling from face",
130 "can switch back after interruption",
131 "good at chit-chat",
132 "keep going on and on about the same thing",
133 "used to enjoy pretending games with other children",
134 # 41-45:
135 "like to collect information about categories of things",
136 "difficult to imagine being someone else",
137 "like to plan activities carefully",
138 "enjoy social occasions",
139 "difficult to work out people’s intentions",
140 # 46-50:
141 "new situations make me anxious",
142 "enjoy meeting new people",
143 "am a good diplomat",
144 "not very good at remembering people’s date of birth",
145 "easy to play pretending games with children",
146 ],
147 )
149 # As listed in Baron-Cohen et al. (2001) [see refs in aq.rst], p7:
150 # 'Scoring the AQ: “Definitely agree” or “slightly agree” responses
151 # scored 1 point, on the following items: 1, 2, 4, 5, 6, 7, 9, 12, 13,
152 # 16, 18, 19, 20, 21, 22, 23, 26, 33, 35, 39, 41, 42, 43, 45, 46.
153 # “Definitely disagree” or “slightly disagree” responses scored 1 point,
154 # on the following items: 3, 8, 10, 11, 14, 15, 17, 24, 25, 27, 28, 29,
155 # 30, 31, 32, 34, 36, 37, 38, 40, 44, 47, 48, 49, 50.'
156 # HOWEVER, there is likely an error here in the published paper:
157 # Baron-Cohen et al. (2001) list Q1 as an "agree" question, but
158 # agreement there is a preference for doing things with others versus on
159 # one's own, so disagreement would be the more autistic-like answer (e.g.
160 # per WHO ICD-10 criteria for F84.1). The ARC's scoring sheet lists Q1 as a
161 # "disagree" question.
162 AGREE_SCORING_QUESTIONS = [
163 2,
164 4,
165 5,
166 6,
167 7,
168 9,
169 12,
170 13,
171 16,
172 18,
173 19,
174 20,
175 21,
176 22,
177 23,
178 26,
179 33,
180 35,
181 39,
182 41,
183 42,
184 43,
185 45,
186 46,
187 ]
189 # Internal coding (not scoring) -- in the order on the questionnaire:
190 DEFINITELY_AGREE = 0
191 SLIGHTLY_AGREE = 1
192 SLIGHTLY_DISAGREE = 2
193 DEFINITELY_DISAGREE = 3
195 AGREE_OPTIONS = [DEFINITELY_AGREE, SLIGHTLY_AGREE]
196 DISAGREE_OPTIONS = [SLIGHTLY_DISAGREE, DEFINITELY_DISAGREE]
198 ALL_FIELD_NAMES = strseq(PREFIX, FIRST_Q, LAST_Q)
199 ALL_QUESTIONS = range(FIRST_Q, LAST_Q + 1)
201 # Areas (domains): see Baron-Cohen et al. (2001), p6.
202 SOCIAL_SKILL_QUESTIONS = [1, 11, 13, 15, 22, 36, 44, 45, 47, 48]
203 ATTENTION_SWITCHING_QUESTIONS = [2, 4, 10, 16, 25, 32, 34, 37, 43, 46]
204 ATTENTION_TO_DETAIL_QUESTIONS = [5, 6, 9, 12, 19, 23, 28, 29, 30, 49]
205 COMMUNICATION_QUESTIONS = [7, 17, 18, 26, 27, 31, 33, 35, 38, 39]
206 IMAGINATION_QUESTIONS = [3, 8, 14, 20, 21, 24, 40, 41, 42, 50]
208 @staticmethod
209 def longname(req: CamcopsRequest) -> str:
210 _ = req.gettext
211 return _("Adult Autism Spectrum Quotient")
213 def is_complete(self) -> bool:
214 # noinspection PyUnresolvedReferences
215 if self.any_fields_none(self.ALL_FIELD_NAMES):
216 return False
218 return True
220 def get_clinical_text(self, req: CamcopsRequest) -> List[CtvInfo]:
221 if not self.is_complete():
222 return CTV_INCOMPLETE
223 return [
224 CtvInfo(
225 content=(
226 f"{req.sstring(SS.TOTAL_SCORE)} "
227 f"{self.score()}/{self.MAX_SCORE}"
228 )
229 )
230 ]
232 def get_summaries(self, req: CamcopsRequest) -> List[SummaryElement]:
233 mas = self.MAX_AREA_SCORE
234 return self.standard_task_summary_fields() + [
235 SummaryElement(
236 name="total",
237 coltype=Integer(),
238 value=self.score(),
239 comment=f"Total score (/{self.MAX_SCORE})",
240 ),
241 SummaryElement(
242 name="social_skill",
243 coltype=Integer(),
244 value=self.social_skill_score(),
245 comment=f"Social skill domain score (/{mas})",
246 ),
247 SummaryElement(
248 name="attention_switching",
249 coltype=Integer(),
250 value=self.attention_switching_score(),
251 comment=f"Attention switching domain score (/{mas})",
252 ),
253 SummaryElement(
254 name="attention_to_detail",
255 coltype=Integer(),
256 value=self.attention_to_detail_score(),
257 comment=f"Attention to detail domain score (/{mas})",
258 ),
259 SummaryElement(
260 name="communication",
261 coltype=Integer(),
262 value=self.communication_score(),
263 comment=f"Communication domain score (/{mas})",
264 ),
265 SummaryElement(
266 name="imagination",
267 coltype=Integer(),
268 value=self.imagination_score(),
269 comment=f"Imagination domain score (/{mas})",
270 ),
271 ]
273 def score(self) -> Optional[int]:
274 return self.questions_score(self.ALL_QUESTIONS)
276 def social_skill_score(self) -> Optional[int]:
277 return self.questions_score(self.SOCIAL_SKILL_QUESTIONS)
279 def attention_switching_score(self) -> Optional[int]:
280 return self.questions_score(self.ATTENTION_SWITCHING_QUESTIONS)
282 def attention_to_detail_score(self) -> Optional[int]:
283 return self.questions_score(self.ATTENTION_TO_DETAIL_QUESTIONS)
285 def communication_score(self) -> Optional[int]:
286 return self.questions_score(self.COMMUNICATION_QUESTIONS)
288 def imagination_score(self) -> Optional[int]:
289 return self.questions_score(self.IMAGINATION_QUESTIONS)
291 def questions_score(self, q_nums: Iterable[int]) -> Optional[int]:
292 total = 0
294 for q_num in q_nums:
295 score = self.question_score(q_num)
296 if score is None:
297 return None
299 total += score
301 return total
303 def question_score(self, q_num: int) -> Optional[int]:
304 """
305 Returns 1 if the answer reflects autistic-like behaviour, mildly or
306 strongly (per Baron-Cohen et al. 2001, p6). Returns 0 for the opposite.
307 Returns None for no answer or an invalid answer.
308 """
309 q_field = self.PREFIX + str(q_num)
310 a = getattr(self, q_field)
311 if a is None:
312 return None
314 if q_num in self.AGREE_SCORING_QUESTIONS:
315 # Questions where agreement indicates autistic-like traits
316 if a in self.AGREE_OPTIONS:
317 return 1
318 elif a in self.DISAGREE_OPTIONS:
319 return 0
320 else:
321 # Shouldn't happen, but safety check
322 return None
323 else:
324 # Questions where disagreement indicates autistic-like traits
325 if a in self.AGREE_OPTIONS:
326 return 0
327 elif a in self.DISAGREE_OPTIONS:
328 return 1
329 else:
330 # Shouldn't happen, but safety check
331 return None
333 def get_task_html(self, req: CamcopsRequest) -> str:
334 rows = self.get_task_html_rows(req)
336 html = """
337 <div class="{CssClass.SUMMARY}">
338 <table class="{CssClass.SUMMARY}">
339 {tr_is_complete}
340 {total_score}
341 {social_skill_score}
342 {attention_switching_score}
343 {attention_to_detail_score}
344 {communication_score}
345 {imagination_score}
346 </table>
347 </div>
348 <table class="{CssClass.TASKDETAIL}">
349 {rows}
350 </table>
351 <div class="{CssClass.FOOTNOTES}">
352 [1] Questions {social_skill_q_nums}.
353 [2] Questions {attention_switching_q_nums}.
354 [3] Questions {attention_to_detail_q_nums}.
355 [4] Questions {communication_q_nums}.
356 [5] Questions {imagination_q_nums}.
357 </div>
358 """.format(
359 CssClass=CssClass,
360 tr_is_complete=self.get_is_complete_tr(req),
361 total_score=tr(
362 req.sstring(SS.TOTAL_SCORE),
363 answer(self.score()) + f" / {self.MAX_SCORE}",
364 ),
365 social_skill_score=tr(
366 self.wxstring(req, "social_skill_score") + " <sup>[1]</sup>",
367 answer(self.social_skill_score())
368 + f" / {self.MAX_AREA_SCORE}",
369 ),
370 attention_switching_score=tr(
371 self.wxstring(req, "attention_switching_score")
372 + " <sup>[2]</sup>",
373 answer(self.attention_switching_score())
374 + f" / {self.MAX_AREA_SCORE}",
375 ),
376 attention_to_detail_score=tr(
377 self.wxstring(req, "attention_to_detail_score")
378 + " <sup>[3]</sup>",
379 answer(self.attention_to_detail_score())
380 + f" / {self.MAX_AREA_SCORE}",
381 ),
382 communication_score=tr(
383 self.wxstring(req, "communication_score") + " <sup>[4]</sup>",
384 answer(self.communication_score())
385 + f" / {self.MAX_AREA_SCORE}",
386 ),
387 imagination_score=tr(
388 self.wxstring(req, "imagination_score") + " <sup>[5]</sup>",
389 answer(self.imagination_score()) + f" / {self.MAX_AREA_SCORE}",
390 ),
391 social_skill_q_nums=to_csv(self.SOCIAL_SKILL_QUESTIONS),
392 attention_switching_q_nums=to_csv(
393 self.ATTENTION_SWITCHING_QUESTIONS
394 ),
395 attention_to_detail_q_nums=to_csv(
396 self.ATTENTION_TO_DETAIL_QUESTIONS
397 ),
398 communication_q_nums=to_csv(self.COMMUNICATION_QUESTIONS),
399 imagination_q_nums=to_csv(self.IMAGINATION_QUESTIONS),
400 rows=rows,
401 )
402 return html
404 def get_task_html_rows(self, req: CamcopsRequest) -> str:
405 _ = req.gettext
406 score_text = _("Score")
407 header = f"""
408 <tr>
409 <th width="70%">Statement</th>
410 <th width="20%">Answer</th>
411 <th width="10%">{score_text}</th>
412 </tr>
413 """
414 return header + self.get_task_html_rows_for_range(
415 req, self.FIRST_Q, self.LAST_Q
416 )
418 def get_task_html_rows_for_range(
419 self, req: CamcopsRequest, first_q: int, last_q: int
420 ) -> str:
421 rows = ""
422 for q_num in range(first_q, last_q + 1):
423 field = self.PREFIX + str(q_num)
424 question_cell = f"{q_num}. {self.xstring(req, field)}"
425 score = self.question_score(q_num)
427 rows += tr(
428 question_cell,
429 answer(self.get_answer_cell(req, q_num)),
430 score,
431 )
433 return rows
435 def get_answer_cell(
436 self, req: CamcopsRequest, q_num: int
437 ) -> Optional[str]:
438 q_field = self.PREFIX + str(q_num)
440 response = getattr(self, q_field)
441 if response is None:
442 return response
444 return self.wxstring(req, f"option_{response}")
446 def get_fhir_questionnaire(
447 self, req: CamcopsRequest
448 ) -> List[FHIRAnsweredQuestion]:
449 items = [] # type: List[FHIRAnsweredQuestion]
450 options = {} # type: Dict[int, str]
451 for index in range(4):
452 options[index] = self.wxstring(req, f"option_{index}")
453 for q_field in self.ALL_FIELD_NAMES:
454 items.append(
455 FHIRAnsweredQuestion(
456 qname=q_field,
457 qtext=self.xstring(req, q_field),
458 qtype=FHIRQuestionType.CHOICE,
459 answer_type=FHIRAnswerType.INTEGER,
460 answer=getattr(self, q_field),
461 answer_options=options,
462 )
463 )
464 return items
466 # No SNOMED codes for the AQ as of 2024-06-26.