Coverage for tasks/aq.py: 47%

1"""

2 camcops_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

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

31

32 from cardinal_pythonlib.stringfunc import strseq

33 from sqlalchemy.sql.sqltypes import Integer

34

35 from camcops_server.cc_modules.cc_constants import CssClass

36 from camcops_server.cc_modules.cc_ctvinfo import CtvInfo, CTV_INCOMPLETE

37 from camcops_server.cc_modules.cc_db import add_multiple_columns

38 from camcops_server.cc_modules.cc_fhir import (

39 FHIRAnsweredQuestion,

40 FHIRAnswerType,

41 FHIRQuestionType,

42)

43 from camcops_server.cc_modules.cc_html import answer, tr

44 from camcops_server.cc_modules.cc_request import CamcopsRequest

45 from camcops_server.cc_modules.cc_summaryelement import SummaryElement

46 from camcops_server.cc_modules.cc_task import Task, TaskHasPatientMixin

47 from camcops_server.cc_modules.cc_text import SS

48

49

50 def 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

57 class 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.