Coverage for tasks/bdi.py: 47%

103 statements  

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

1""" 

2camcops_server/tasks/bdi.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""" 

27 

28from typing import Any, cast, List, Type 

29 

30from cardinal_pythonlib.stringfunc import strseq 

31import cardinal_pythonlib.rnc_web as ws 

32from sqlalchemy.sql.schema import Column 

33from sqlalchemy.sql.sqltypes import Integer, String 

34 

35from camcops_server.cc_modules.cc_constants import ( 

36 CssClass, 

37 DATA_COLLECTION_ONLY_DIV, 

38) 

39from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo 

40from camcops_server.cc_modules.cc_db import add_multiple_columns 

41from camcops_server.cc_modules.cc_html import answer, bold, doi, td, tr, tr_qa 

42from camcops_server.cc_modules.cc_request import CamcopsRequest 

43from camcops_server.cc_modules.cc_snomed import SnomedExpression, SnomedLookup 

44from camcops_server.cc_modules.cc_string import AS 

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 

48from camcops_server.cc_modules.cc_trackerhelpers import TrackerInfo 

49 

50 

51# ============================================================================= 

52# Constants 

53# ============================================================================= 

54 

55BDI_I_QUESTION_TOPICS = { 

56 # from Beck 1988, https://doi.org/10.1016/0272-7358(88)90050-5 

57 1: "mood", # a 

58 2: "pessimism", # b 

59 3: "sense of failure", # c 

60 4: "lack of satisfaction", # d 

61 5: "guilt feelings", # e 

62 6: "sense of punishment", # f 

63 7: "self-dislike", # g 

64 8: "self-accusation", # h 

65 9: "suicidal wishes", # i 

66 10: "crying", # j 

67 11: "irritability", # k 

68 12: "social withdrawal", # l 

69 13: "indecisiveness", # m 

70 14: "distortion of body image", # n 

71 15: "work inhibition", # o 

72 16: "sleep disturbance", # p 

73 17: "fatigability", # q 

74 18: "loss of appetite", # r 

75 19: "weight loss", # s 

76 20: "somatic preoccupation", # t 

77 21: "loss of libido", # u 

78} 

79BDI_IA_QUESTION_TOPICS = { 

80 # from [Beck1996b] 

81 1: "sadness", 

82 2: "pessimism", 

83 3: "sense of failure", 

84 4: "self-dissatisfaction", 

85 5: "guilt", 

86 6: "punishment", 

87 7: "self-dislike", 

88 8: "self-accusations", 

89 9: "suicidal ideas", 

90 10: "crying", 

91 11: "irritability", 

92 12: "social withdrawal", 

93 13: "indecisiveness", 

94 14: "body image change", 

95 15: "work difficulty", 

96 16: "insomnia", 

97 17: "fatigability", 

98 18: "loss of appetite", 

99 19: "weight loss", 

100 20: "somatic preoccupation", 

101 21: "loss of libido", 

102} 

103BDI_II_QUESTION_TOPICS = { 

104 # from https://www.ncbi.nlm.nih.gov/pmc/articles/PMC5889520/; 

105 # also https://www.ncbi.nlm.nih.gov/pubmed/10100838; 

106 # also [Beck1996b] 

107 # matches BDI-II paper version 

108 1: "sadness", 

109 2: "pessimism", 

110 3: "past failure", 

111 4: "loss of pleasure", 

112 5: "guilty feelings", 

113 6: "punishment feelings", 

114 7: "self-dislike", 

115 8: "self-criticalness", 

116 9: "suicidal thoughts or wishes", 

117 10: "crying", 

118 11: "agitation", 

119 12: "loss of interest", 

120 13: "indecisiveness", 

121 14: "worthlessness", 

122 15: "loss of energy", 

123 16: "changes in sleeping pattern", # decrease or increase 

124 17: "irritability", 

125 18: "changes in appetite", # decrease or increase 

126 19: "concentration difficulty", 

127 20: "tiredness or fatigue", 

128 21: "loss of interest in sex", 

129} 

130SCALE_BDI_I = "BDI-I" # must match client 

131SCALE_BDI_IA = "BDI-IA" # must match client 

132SCALE_BDI_II = "BDI-II" # must match client 

133TOPICS_BY_SCALE = { 

134 SCALE_BDI_I: BDI_I_QUESTION_TOPICS, 

135 SCALE_BDI_IA: BDI_IA_QUESTION_TOPICS, 

136 SCALE_BDI_II: BDI_II_QUESTION_TOPICS, 

137} 

138 

139NQUESTIONS = 21 

140TASK_SCORED_FIELDS = strseq("q", 1, NQUESTIONS) 

141MAX_SCORE = NQUESTIONS * 3 

142SUICIDALITY_QNUM = 9 # Q9 in all versions of the BDI (I, IA, II) 

143SUICIDALITY_FN = "q9" # fieldname 

144CUSTOM_SOMATIC_KHANDAKER_BDI_II_QNUMS = [4, 15, 16, 18, 19, 20, 21] 

145CUSTOM_SOMATIC_KHANDAKER_BDI_II_FIELDS = Task.fieldnames_from_list( 

146 "q", CUSTOM_SOMATIC_KHANDAKER_BDI_II_QNUMS 

147) 

148 

149 

150# ============================================================================= 

151# BDI (crippled) 

152# ============================================================================= 

153 

154 

155class Bdi( # type: ignore[misc] 

156 TaskHasPatientMixin, 

157 Task, 

158): 

159 """ 

160 Server implementation of the BDI task. 

161 """ 

162 

163 __tablename__ = "bdi" 

164 shortname = "BDI" 

165 provides_trackers = True 

166 

167 @classmethod 

168 def extend_columns(cls: Type["Bdi"], **kwargs: Any) -> None: 

169 add_multiple_columns( 

170 cls, 

171 "q", 

172 1, 

173 NQUESTIONS, 

174 minimum=0, 

175 maximum=3, 

176 comment_fmt="Q{n} [{s}] (0-3, higher worse)", 

177 comment_strings=[ 

178 ( 

179 f"BDI-I: {BDI_I_QUESTION_TOPICS[q]}; " 

180 f"BDI-IA: {BDI_IA_QUESTION_TOPICS[q]}; " 

181 f"BDI-II: {BDI_II_QUESTION_TOPICS[q]}" 

182 ) 

183 for q in range(1, NQUESTIONS + 1) 

184 ], 

185 ) 

186 

187 bdi_scale = Column( 

188 "bdi_scale", 

189 String(length=10), # was Text 

190 comment="Which BDI scale (BDI-I, BDI-IA, BDI-II)?", 

191 ) 

192 

193 @staticmethod 

194 def longname(req: "CamcopsRequest") -> str: 

195 _ = req.gettext 

196 return _("Beck Depression Inventory (data collection only)") 

197 

198 def is_complete(self) -> bool: 

199 return ( 

200 self.field_contents_valid() 

201 and self.bdi_scale is not None 

202 and self.all_fields_not_none(TASK_SCORED_FIELDS) 

203 ) 

204 

205 def get_trackers(self, req: CamcopsRequest) -> List[TrackerInfo]: 

206 return [ 

207 TrackerInfo( 

208 value=self.total_score(), 

209 plot_label="BDI total score (rating depressive symptoms)", 

210 axis_label=f"Score for Q1-21 (out of {MAX_SCORE})", 

211 axis_min=-0.5, 

212 axis_max=MAX_SCORE + 0.5, 

213 ) 

214 ] 

215 

216 def get_clinical_text(self, req: CamcopsRequest) -> List[CtvInfo]: 

217 if not self.is_complete(): 

218 return CTV_INCOMPLETE 

219 return [ 

220 CtvInfo( 

221 content=( 

222 f"{ws.webify(self.bdi_scale)} " 

223 f"total score {self.total_score()}/{MAX_SCORE}" 

224 ) 

225 ) 

226 ] 

227 

228 def get_summaries(self, req: CamcopsRequest) -> List[SummaryElement]: 

229 return self.standard_task_summary_fields() + [ 

230 SummaryElement( 

231 name="total", 

232 coltype=Integer(), 

233 value=self.total_score(), 

234 comment=f"Total score (/{MAX_SCORE})", 

235 ) 

236 ] 

237 

238 def total_score(self) -> int: 

239 return cast(int, self.sum_fields(TASK_SCORED_FIELDS)) 

240 

241 def is_bdi_ii(self) -> bool: 

242 return self.bdi_scale == SCALE_BDI_II # type: ignore[return-value] 

243 

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

245 score = self.total_score() 

246 

247 # Suicidal thoughts: 

248 suicidality_score = getattr(self, SUICIDALITY_FN) 

249 if suicidality_score is None: 

250 suicidality_text = bold("? (not completed)") 

251 suicidality_css_class = CssClass.INCOMPLETE 

252 elif suicidality_score == 0: 

253 suicidality_text = str(suicidality_score) 

254 suicidality_css_class = "" 

255 else: 

256 suicidality_text = bold(str(suicidality_score)) 

257 suicidality_css_class = CssClass.WARNING 

258 

259 # Custom somatic score for Khandaker Insight study: 

260 somatic_css_class = "" 

261 if self.is_bdi_ii(): 

262 somatic_values = self.get_values( 

263 CUSTOM_SOMATIC_KHANDAKER_BDI_II_FIELDS 

264 ) 

265 somatic_missing = False 

266 somatic_score = 0 

267 for v in somatic_values: 

268 if v is None: 

269 somatic_missing = True 

270 somatic_css_class = CssClass.INCOMPLETE 

271 break 

272 else: 

273 somatic_score += int(v) 

274 somatic_text = ( 

275 "incomplete" if somatic_missing else str(somatic_score) 

276 ) 

277 else: 

278 somatic_text = "N/A" # not the BDI-II 

279 

280 # Question rows: 

281 q_a = "" 

282 qdict = TOPICS_BY_SCALE.get(self.bdi_scale) # type: ignore[call-overload] # noqa: E501 

283 topic = "?" 

284 for q in range(1, NQUESTIONS + 1): 

285 if qdict: 

286 topic = qdict.get(q, "??") 

287 q_a += tr_qa( 

288 f"{req.sstring(SS.QUESTION)} {q} ({topic})", 

289 getattr(self, "q" + str(q)), 

290 ) 

291 

292 # HTML: 

293 tr_somatic_score = tr( 

294 td( 

295 "Custom somatic score for Insight study <sup>[2]</sup> " 

296 "(sum of scores for questions {}, for BDI-II only)".format( 

297 ", ".join( 

298 "Q" + str(qnum) 

299 for qnum in CUSTOM_SOMATIC_KHANDAKER_BDI_II_QNUMS 

300 ) 

301 ) 

302 ), 

303 td(somatic_text, td_class=somatic_css_class), 

304 literal=True, 

305 ) 

306 tr_which_scale = tr_qa( 

307 req.wappstring(AS.BDI_WHICH_SCALE) + " <sup>[3]</sup>", 

308 ws.webify(self.bdi_scale), 

309 ) 

310 return f""" 

311 <div class="{CssClass.SUMMARY}"> 

312 <table class="{CssClass.SUMMARY}"> 

313 {self.get_is_complete_tr(req)} 

314 {tr(req.sstring(SS.TOTAL_SCORE), 

315 answer(score) + " / {}".format(MAX_SCORE))} 

316 <tr> 

317 <td> 

318 Suicidal thoughts/wishes score 

319 (Q{SUICIDALITY_QNUM}) <sup>[1]</sup> 

320 </td> 

321 {td(suicidality_text, td_class=suicidality_css_class)} 

322 </tr> 

323 {tr_somatic_score} 

324 </table> 

325 </div> 

326 <div class="{CssClass.EXPLANATION}"> 

327 All questions are scored from 0–3 

328 (0 free of symptoms, 3 most symptomatic). 

329 </div> 

330 <table class="{CssClass.TASKDETAIL}"> 

331 <tr> 

332 <th width="70%">Question</th> 

333 <th width="30%">Answer</th> 

334 </tr> 

335 {tr_which_scale} 

336 {q_a} 

337 </table> 

338 <div class="{CssClass.FOOTNOTES}"> 

339 [1] Suicidal thoughts are asked about in Q{SUICIDALITY_QNUM} 

340 for all of: BDI-I (1961), BDI-IA (1978), and BDI-II (1996). 

341 

342 [2] Insight study: {doi("10.1186/ISRCTN16942542")} 

343 

344 [3] See the 

345 <a href="https://camcops.readthedocs.io/en/latest/tasks/bdi.html">CamCOPS 

346 BDI help</a> for full references and bibliography for the 

347 citations that follow. 

348 

349 <b>The BDI rates “right now” [Beck1988]. 

350 The BDI-IA rates the past week [Beck1988]. 

351 The BDI-II rates the past two weeks [Beck1996b].</b> 

352 

353 1961 BDI(-I) question topics from [Beck1988]. 

354 1978 BDI-IA question topics from [Beck1996b]. 

355 1996 BDI-II question topics from [Steer1999], [Gary2018]. 

356 </ul> 

357 

358 </div> 

359 {DATA_COLLECTION_ONLY_DIV} 

360 """ # noqa 

361 

362 def get_snomed_codes(self, req: CamcopsRequest) -> List[SnomedExpression]: 

363 scale_lookup = SnomedLookup.BDI_SCALE 

364 if self.bdi_scale in (SCALE_BDI_I, SCALE_BDI_IA): 

365 score_lookup = SnomedLookup.BDI_SCORE 

366 proc_lookup = SnomedLookup.BDI_PROCEDURE_ASSESSMENT 

367 elif self.bdi_scale == SCALE_BDI_II: 

368 score_lookup = SnomedLookup.BDI_II_SCORE 

369 proc_lookup = SnomedLookup.BDI_II_PROCEDURE_ASSESSMENT 

370 else: 

371 return [] 

372 codes = [SnomedExpression(req.snomed(proc_lookup))] 

373 if self.is_complete(): 

374 codes.append( 

375 SnomedExpression( 

376 req.snomed(scale_lookup), 

377 {req.snomed(score_lookup): self.total_score()}, 

378 ) 

379 ) 

380 return codes