Coverage for tasks/aq.py: 47%

122 statements  

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

1""" 

2camcops_server/tasks/aq.py 

3 

4=============================================================================== 

5 

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

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

8 

9 This file is part of CamCOPS. 

10 

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

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

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

14 (at your option) any later version. 

15 

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

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

18 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

19 GNU General Public License for more details. 

20 

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

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

23 

24=============================================================================== 

25 

26**The Adult Autism Spectrum Quotient (AQ) Ages 16+ task.** 

27 

28""" 

29 

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

31 

32from cardinal_pythonlib.stringfunc import strseq 

33from sqlalchemy.sql.sqltypes import Integer 

34 

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 

48 

49 

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) 

55 

56 

57class Aq( # type: ignore[misc] 

58 TaskHasPatientMixin, 

59 Task, 

60): 

61 __tablename__ = "aq" 

62 shortname = "AQ" 

63 

64 prohibits_commercial = True 

65 

66 FIRST_Q = 1 

67 LAST_Q = 50 

68 PREFIX = "q" 

69 MAX_AREA_SCORE = 10 

70 MAX_SCORE = 50 

71 

72 # Questions where agreement indicates autistic-like traits. 

73 

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 ) 

148 

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 ] 

188 

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 

194 

195 AGREE_OPTIONS = [DEFINITELY_AGREE, SLIGHTLY_AGREE] 

196 DISAGREE_OPTIONS = [SLIGHTLY_DISAGREE, DEFINITELY_DISAGREE] 

197 

198 ALL_FIELD_NAMES = strseq(PREFIX, FIRST_Q, LAST_Q) 

199 ALL_QUESTIONS = range(FIRST_Q, LAST_Q + 1) 

200 

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] 

207 

208 @staticmethod 

209 def longname(req: CamcopsRequest) -> str: 

210 _ = req.gettext 

211 return _("Adult Autism Spectrum Quotient") 

212 

213 def is_complete(self) -> bool: 

214 # noinspection PyUnresolvedReferences 

215 if self.any_fields_none(self.ALL_FIELD_NAMES): 

216 return False 

217 

218 return True 

219 

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 ] 

231 

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 ] 

272 

273 def score(self) -> Optional[int]: 

274 return self.questions_score(self.ALL_QUESTIONS) 

275 

276 def social_skill_score(self) -> Optional[int]: 

277 return self.questions_score(self.SOCIAL_SKILL_QUESTIONS) 

278 

279 def attention_switching_score(self) -> Optional[int]: 

280 return self.questions_score(self.ATTENTION_SWITCHING_QUESTIONS) 

281 

282 def attention_to_detail_score(self) -> Optional[int]: 

283 return self.questions_score(self.ATTENTION_TO_DETAIL_QUESTIONS) 

284 

285 def communication_score(self) -> Optional[int]: 

286 return self.questions_score(self.COMMUNICATION_QUESTIONS) 

287 

288 def imagination_score(self) -> Optional[int]: 

289 return self.questions_score(self.IMAGINATION_QUESTIONS) 

290 

291 def questions_score(self, q_nums: Iterable[int]) -> Optional[int]: 

292 total = 0 

293 

294 for q_num in q_nums: 

295 score = self.question_score(q_num) 

296 if score is None: 

297 return None 

298 

299 total += score 

300 

301 return total 

302 

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 

313 

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 

332 

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

334 rows = self.get_task_html_rows(req) 

335 

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 

403 

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 ) 

417 

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) 

426 

427 rows += tr( 

428 question_cell, 

429 answer(self.get_answer_cell(req, q_num)), 

430 score, 

431 ) 

432 

433 return rows 

434 

435 def get_answer_cell( 

436 self, req: CamcopsRequest, q_num: int 

437 ) -> Optional[str]: 

438 q_field = self.PREFIX + str(q_num) 

439 

440 response = getattr(self, q_field) 

441 if response is None: 

442 return response 

443 

444 return self.wxstring(req, f"option_{response}") 

445 

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 

465 

466 # No SNOMED codes for the AQ as of 2024-06-26.