Coverage for tasks/bdi.py: 48%

106 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2022-11-08 23:14 +0000

1#!/usr/bin/env python 

2 

3""" 

4camcops_server/tasks/bdi.py 

5 

6=============================================================================== 

7 

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

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

10 

11 This file is part of CamCOPS. 

12 

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

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

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

16 (at your option) any later version. 

17 

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

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

20 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

21 GNU General Public License for more details. 

22 

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

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

25 

26=============================================================================== 

27 

28""" 

29 

30from typing import Any, Dict, List, Tuple, Type 

31 

32from cardinal_pythonlib.stringfunc import strseq 

33import cardinal_pythonlib.rnc_web as ws 

34from sqlalchemy.ext.declarative import DeclarativeMeta 

35from sqlalchemy.sql.schema import Column 

36from sqlalchemy.sql.sqltypes import Integer, String 

37 

38from camcops_server.cc_modules.cc_constants import ( 

39 CssClass, 

40 DATA_COLLECTION_ONLY_DIV, 

41) 

42from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo 

43from camcops_server.cc_modules.cc_db import add_multiple_columns 

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

45from camcops_server.cc_modules.cc_request import CamcopsRequest 

46from camcops_server.cc_modules.cc_snomed import SnomedExpression, SnomedLookup 

47from camcops_server.cc_modules.cc_string import AS 

48from camcops_server.cc_modules.cc_summaryelement import SummaryElement 

49from camcops_server.cc_modules.cc_task import Task, TaskHasPatientMixin 

50from camcops_server.cc_modules.cc_text import SS 

51from camcops_server.cc_modules.cc_trackerhelpers import TrackerInfo 

52 

53 

54# ============================================================================= 

55# Constants 

56# ============================================================================= 

57 

58BDI_I_QUESTION_TOPICS = { 

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

60 1: "mood", # a 

61 2: "pessimism", # b 

62 3: "sense of failure", # c 

63 4: "lack of satisfaction", # d 

64 5: "guilt feelings", # e 

65 6: "sense of punishment", # f 

66 7: "self-dislike", # g 

67 8: "self-accusation", # h 

68 9: "suicidal wishes", # i 

69 10: "crying", # j 

70 11: "irritability", # k 

71 12: "social withdrawal", # l 

72 13: "indecisiveness", # m 

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

74 15: "work inhibition", # o 

75 16: "sleep disturbance", # p 

76 17: "fatigability", # q 

77 18: "loss of appetite", # r 

78 19: "weight loss", # s 

79 20: "somatic preoccupation", # t 

80 21: "loss of libido", # u 

81} 

82BDI_IA_QUESTION_TOPICS = { 

83 # from [Beck1996b] 

84 1: "sadness", 

85 2: "pessimism", 

86 3: "sense of failure", 

87 4: "self-dissatisfaction", 

88 5: "guilt", 

89 6: "punishment", 

90 7: "self-dislike", 

91 8: "self-accusations", 

92 9: "suicidal ideas", 

93 10: "crying", 

94 11: "irritability", 

95 12: "social withdrawal", 

96 13: "indecisiveness", 

97 14: "body image change", 

98 15: "work difficulty", 

99 16: "insomnia", 

100 17: "fatigability", 

101 18: "loss of appetite", 

102 19: "weight loss", 

103 20: "somatic preoccupation", 

104 21: "loss of libido", 

105} 

106BDI_II_QUESTION_TOPICS = { 

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

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

109 # also [Beck1996b] 

110 # matches BDI-II paper version 

111 1: "sadness", 

112 2: "pessimism", 

113 3: "past failure", 

114 4: "loss of pleasure", 

115 5: "guilty feelings", 

116 6: "punishment feelings", 

117 7: "self-dislike", 

118 8: "self-criticalness", 

119 9: "suicidal thoughts or wishes", 

120 10: "crying", 

121 11: "agitation", 

122 12: "loss of interest", 

123 13: "indecisiveness", 

124 14: "worthlessness", 

125 15: "loss of energy", 

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

127 17: "irritability", 

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

129 19: "concentration difficulty", 

130 20: "tiredness or fatigue", 

131 21: "loss of interest in sex", 

132} 

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

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

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

136TOPICS_BY_SCALE = { 

137 SCALE_BDI_I: BDI_I_QUESTION_TOPICS, 

138 SCALE_BDI_IA: BDI_IA_QUESTION_TOPICS, 

139 SCALE_BDI_II: BDI_II_QUESTION_TOPICS, 

140} 

141 

142NQUESTIONS = 21 

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

144MAX_SCORE = NQUESTIONS * 3 

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

146SUICIDALITY_FN = "q9" # fieldname 

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

148CUSTOM_SOMATIC_KHANDAKER_BDI_II_FIELDS = Task.fieldnames_from_list( 

149 "q", CUSTOM_SOMATIC_KHANDAKER_BDI_II_QNUMS 

150) 

151 

152 

153# ============================================================================= 

154# BDI (crippled) 

155# ============================================================================= 

156 

157 

158class BdiMetaclass(DeclarativeMeta): 

159 # noinspection PyInitNewSignature 

160 def __init__( 

161 cls: Type["Bdi"], 

162 name: str, 

163 bases: Tuple[Type, ...], 

164 classdict: Dict[str, Any], 

165 ) -> None: 

166 add_multiple_columns( 

167 cls, 

168 "q", 

169 1, 

170 NQUESTIONS, 

171 minimum=0, 

172 maximum=3, 

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

174 comment_strings=[ 

175 ( 

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

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

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

179 ) 

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

181 ], 

182 ) 

183 super().__init__(name, bases, classdict) 

184 

185 

186class Bdi(TaskHasPatientMixin, Task, metaclass=BdiMetaclass): 

187 """ 

188 Server implementation of the BDI task. 

189 """ 

190 

191 __tablename__ = "bdi" 

192 shortname = "BDI" 

193 provides_trackers = True 

194 

195 bdi_scale = Column( 

196 "bdi_scale", 

197 String(length=10), # was Text 

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

199 ) 

200 

201 @staticmethod 

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

203 _ = req.gettext 

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

205 

206 def is_complete(self) -> bool: 

207 return ( 

208 self.field_contents_valid() 

209 and self.bdi_scale is not None 

210 and self.all_fields_not_none(TASK_SCORED_FIELDS) 

211 ) 

212 

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

214 return [ 

215 TrackerInfo( 

216 value=self.total_score(), 

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

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

219 axis_min=-0.5, 

220 axis_max=MAX_SCORE + 0.5, 

221 ) 

222 ] 

223 

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

225 if not self.is_complete(): 

226 return CTV_INCOMPLETE 

227 return [ 

228 CtvInfo( 

229 content=( 

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

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

232 ) 

233 ) 

234 ] 

235 

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

237 return self.standard_task_summary_fields() + [ 

238 SummaryElement( 

239 name="total", 

240 coltype=Integer(), 

241 value=self.total_score(), 

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

243 ) 

244 ] 

245 

246 def total_score(self) -> int: 

247 return self.sum_fields(TASK_SCORED_FIELDS) 

248 

249 def is_bdi_ii(self) -> bool: 

250 return self.bdi_scale == SCALE_BDI_II 

251 

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

253 score = self.total_score() 

254 

255 # Suicidal thoughts: 

256 suicidality_score = getattr(self, SUICIDALITY_FN) 

257 if suicidality_score is None: 

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

259 suicidality_css_class = CssClass.INCOMPLETE 

260 elif suicidality_score == 0: 

261 suicidality_text = str(suicidality_score) 

262 suicidality_css_class = "" 

263 else: 

264 suicidality_text = bold(str(suicidality_score)) 

265 suicidality_css_class = CssClass.WARNING 

266 

267 # Custom somatic score for Khandaker Insight study: 

268 somatic_css_class = "" 

269 if self.is_bdi_ii(): 

270 somatic_values = self.get_values( 

271 CUSTOM_SOMATIC_KHANDAKER_BDI_II_FIELDS 

272 ) 

273 somatic_missing = False 

274 somatic_score = 0 

275 for v in somatic_values: 

276 if v is None: 

277 somatic_missing = True 

278 somatic_css_class = CssClass.INCOMPLETE 

279 break 

280 else: 

281 somatic_score += int(v) 

282 somatic_text = ( 

283 "incomplete" if somatic_missing else str(somatic_score) 

284 ) 

285 else: 

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

287 

288 # Question rows: 

289 q_a = "" 

290 qdict = TOPICS_BY_SCALE.get(self.bdi_scale) 

291 topic = "?" 

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

293 if qdict: 

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

295 q_a += tr_qa( 

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

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

298 ) 

299 

300 # HTML: 

301 tr_somatic_score = tr( 

302 td( 

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

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

305 ", ".join( 

306 "Q" + str(qnum) 

307 for qnum in CUSTOM_SOMATIC_KHANDAKER_BDI_II_QNUMS 

308 ) 

309 ) 

310 ), 

311 td(somatic_text, td_class=somatic_css_class), 

312 literal=True, 

313 ) 

314 tr_which_scale = tr_qa( 

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

316 ws.webify(self.bdi_scale), 

317 ) 

318 return f""" 

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

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

321 {self.get_is_complete_tr(req)} 

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

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

324 <tr> 

325 <td> 

326 Suicidal thoughts/wishes score 

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

328 </td> 

329 {td(suicidality_text, td_class=suicidality_css_class)} 

330 </tr> 

331 {tr_somatic_score} 

332 </table> 

333 </div> 

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

335 All questions are scored from 0–3 

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

337 </div> 

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

339 <tr> 

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

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

342 </tr> 

343 {tr_which_scale} 

344 {q_a} 

345 </table> 

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

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

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

349 

350 [2] Insight study: 

351 <a href="https://doi.org/10.1186/ISRCTN16942542">doi:10.1186/ISRCTN16942542</a> 

352 

353 [3] See the 

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

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

356 citations that follow. 

357 

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

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

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

361 

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

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

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

365 </ul> 

366 

367 </div> 

368 {DATA_COLLECTION_ONLY_DIV} 

369 """ # noqa 

370 

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

372 scale_lookup = SnomedLookup.BDI_SCALE 

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

374 score_lookup = SnomedLookup.BDI_SCORE 

375 proc_lookup = SnomedLookup.BDI_PROCEDURE_ASSESSMENT 

376 elif self.bdi_scale == SCALE_BDI_II: 

377 score_lookup = SnomedLookup.BDI_II_SCORE 

378 proc_lookup = SnomedLookup.BDI_II_PROCEDURE_ASSESSMENT 

379 else: 

380 return [] 

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

382 if self.is_complete(): 

383 codes.append( 

384 SnomedExpression( 

385 req.snomed(scale_lookup), 

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

387 ) 

388 ) 

389 return codes