Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

1#!/usr/bin/env python 

2 

3""" 

4camcops_server/tasks/bdi.py 

5 

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

7 

8 Copyright (C) 2012-2020 Rudolf Cardinal (rudolf@pobox.com). 

9 

10 This file is part of CamCOPS. 

11 

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

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

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

15 (at your option) any later version. 

16 

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

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

19 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

20 GNU General Public License for more details. 

21 

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

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

24 

25=============================================================================== 

26 

27""" 

28 

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

30 

31from cardinal_pythonlib.stringfunc import strseq 

32import cardinal_pythonlib.rnc_web as ws 

33from sqlalchemy.ext.declarative import DeclarativeMeta 

34from sqlalchemy.sql.schema import Column 

35from sqlalchemy.sql.sqltypes import Integer, String 

36 

37from camcops_server.cc_modules.cc_constants import ( 

38 CssClass, 

39 DATA_COLLECTION_ONLY_DIV, 

40) 

41from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo 

42from camcops_server.cc_modules.cc_db import add_multiple_columns 

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

44from camcops_server.cc_modules.cc_request import CamcopsRequest 

45from camcops_server.cc_modules.cc_snomed import SnomedExpression, SnomedLookup 

46from camcops_server.cc_modules.cc_string import AS 

47from camcops_server.cc_modules.cc_summaryelement import SummaryElement 

48from camcops_server.cc_modules.cc_task import Task, TaskHasPatientMixin 

49from camcops_server.cc_modules.cc_text import SS 

50from camcops_server.cc_modules.cc_trackerhelpers import TrackerInfo 

51 

52 

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

54# Constants 

55# ============================================================================= 

56 

57BDI_I_QUESTION_TOPICS = { 

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

59 1: "mood", # a 

60 2: "pessimism", # b 

61 3: "sense of failure", # c 

62 4: "lack of satisfaction", # d 

63 5: "guilt feelings", # e 

64 6: "sense of punishment", # f 

65 7: "self-dislike", # g 

66 8: "self-accusation", # h 

67 9: "suicidal wishes", # i 

68 10: "crying", # j 

69 11: "irritability", # k 

70 12: "social withdrawal", # l 

71 13: "indecisiveness", # m 

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

73 15: "work inhibition", # o 

74 16: "sleep disturbance", # p 

75 17: "fatigability", # q 

76 18: "loss of appetite", # r 

77 19: "weight loss", # s 

78 20: "somatic preoccupation", # t 

79 21: "loss of libido", # u 

80} 

81BDI_IA_QUESTION_TOPICS = { 

82 # from [Beck1996b] 

83 1: "sadness", 

84 2: "pessimism", 

85 3: "sense of failure", 

86 4: "self-dissatisfaction", 

87 5: "guilt", 

88 6: "punishment", 

89 7: "self-dislike", 

90 8: "self-accusations", 

91 9: "suicidal ideas", 

92 10: "crying", 

93 11: "irritability", 

94 12: "social withdrawal", 

95 13: "indecisiveness", 

96 14: "body image change", 

97 15: "work difficulty", 

98 16: "insomnia", 

99 17: "fatigability", 

100 18: "loss of appetite", 

101 19: "weight loss", 

102 20: "somatic preoccupation", 

103 21: "loss of libido", 

104} 

105BDI_II_QUESTION_TOPICS = { 

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

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

108 # also [Beck1996b] 

109 # matches BDI-II paper version 

110 1: "sadness", 

111 2: "pessimism", 

112 3: "past failure", 

113 4: "loss of pleasure", 

114 5: "guilty feelings", 

115 6: "punishment feelings", 

116 7: "self-dislike", 

117 8: "self-criticalness", 

118 9: "suicidal thoughts or wishes", 

119 10: "crying", 

120 11: "agitation", 

121 12: "loss of interest", 

122 13: "indecisiveness", 

123 14: "worthlessness", 

124 15: "loss of energy", 

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

126 17: "irritability", 

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

128 19: "concentration difficulty", 

129 20: "tiredness or fatigue", 

130 21: "loss of interest in sex", 

131} 

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

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

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

135TOPICS_BY_SCALE = { 

136 SCALE_BDI_I: BDI_I_QUESTION_TOPICS, 

137 SCALE_BDI_IA: BDI_IA_QUESTION_TOPICS, 

138 SCALE_BDI_II: BDI_II_QUESTION_TOPICS, 

139} 

140 

141NQUESTIONS = 21 

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

143MAX_SCORE = NQUESTIONS * 3 

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

145SUICIDALITY_FN = "q9" # fieldname 

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

147CUSTOM_SOMATIC_KHANDAKER_BDI_II_FIELDS = Task.fieldnames_from_list( 

148 "q", CUSTOM_SOMATIC_KHANDAKER_BDI_II_QNUMS) 

149 

150 

151# ============================================================================= 

152# BDI (crippled) 

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

154 

155class BdiMetaclass(DeclarativeMeta): 

156 # noinspection PyInitNewSignature 

157 def __init__(cls: Type['Bdi'], 

158 name: str, 

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

160 classdict: Dict[str, Any]) -> None: 

161 add_multiple_columns( 

162 cls, "q", 1, NQUESTIONS, 

163 minimum=0, maximum=3, 

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

165 comment_strings=[ 

166 ( 

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

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

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

170 ) 

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

172 ] 

173 ) 

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

175 

176 

177class Bdi(TaskHasPatientMixin, Task, 

178 metaclass=BdiMetaclass): 

179 """ 

180 Server implementation of the BDI task. 

181 """ 

182 __tablename__ = "bdi" 

183 shortname = "BDI" 

184 provides_trackers = True 

185 

186 bdi_scale = Column( 

187 "bdi_scale", String(length=10), # was Text 

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

189 ) 

190 

191 @staticmethod 

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

193 _ = req.gettext 

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

195 

196 def is_complete(self) -> bool: 

197 return ( 

198 self.field_contents_valid() and 

199 self.bdi_scale is not None and 

200 self.all_fields_not_none(TASK_SCORED_FIELDS) 

201 ) 

202 

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

204 return [TrackerInfo( 

205 value=self.total_score(), 

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

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

208 axis_min=-0.5, 

209 axis_max=MAX_SCORE + 0.5 

210 )] 

211 

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

213 if not self.is_complete(): 

214 return CTV_INCOMPLETE 

215 return [CtvInfo(content=( 

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

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

218 ))] 

219 

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

221 return self.standard_task_summary_fields() + [ 

222 SummaryElement(name="total", 

223 coltype=Integer(), 

224 value=self.total_score(), 

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

226 ] 

227 

228 def total_score(self) -> int: 

229 return self.sum_fields(TASK_SCORED_FIELDS) 

230 

231 def is_bdi_ii(self) -> bool: 

232 return self.bdi_scale == SCALE_BDI_II 

233 

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

235 score = self.total_score() 

236 

237 # Suicidal thoughts: 

238 suicidality_score = getattr(self, SUICIDALITY_FN) 

239 if suicidality_score is None: 

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

241 suicidality_css_class = CssClass.INCOMPLETE 

242 elif suicidality_score == 0: 

243 suicidality_text = str(suicidality_score) 

244 suicidality_css_class = "" 

245 else: 

246 suicidality_text = bold(str(suicidality_score)) 

247 suicidality_css_class = CssClass.WARNING 

248 

249 # Custom somatic score for Khandaker Insight study: 

250 somatic_css_class = "" 

251 if self.is_bdi_ii(): 

252 somatic_values = self.get_values( 

253 CUSTOM_SOMATIC_KHANDAKER_BDI_II_FIELDS) 

254 somatic_missing = False 

255 somatic_score = 0 

256 for v in somatic_values: 

257 if v is None: 

258 somatic_missing = True 

259 somatic_css_class = CssClass.INCOMPLETE 

260 break 

261 else: 

262 somatic_score += int(v) 

263 somatic_text = ("incomplete" if somatic_missing 

264 else str(somatic_score)) 

265 else: 

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

267 

268 # Question rows: 

269 q_a = "" 

270 qdict = TOPICS_BY_SCALE.get(self.bdi_scale) 

271 topic = "?" 

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

273 if qdict: 

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

275 q_a += tr_qa( 

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

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

278 ) 

279 

280 # HTML: 

281 tr_somatic_score = tr( 

282 td( 

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

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

285 ", ".join("Q" + str(qnum) for qnum in 

286 CUSTOM_SOMATIC_KHANDAKER_BDI_II_QNUMS)) 

287 ), 

288 td(somatic_text, td_class=somatic_css_class), 

289 literal=True 

290 ) 

291 tr_which_scale = tr_qa( 

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

293 ws.webify(self.bdi_scale) 

294 ) 

295 return f""" 

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

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

298 {self.get_is_complete_tr(req)} 

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

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

301 <tr> 

302 <td> 

303 Suicidal thoughts/wishes score 

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

305 </td> 

306 {td(suicidality_text, td_class=suicidality_css_class)} 

307 </tr> 

308 {tr_somatic_score} 

309 </table> 

310 </div> 

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

312 All questions are scored from 0–3 

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

314 </div> 

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

316 <tr> 

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

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

319 </tr> 

320 {tr_which_scale} 

321 {q_a} 

322 </table> 

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

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

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

326 

327 [2] Insight study: 

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

329 

330 [3] See the 

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

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

333 citations that follow. 

334 

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

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

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

338 

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

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

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

342 </ul> 

343 

344 </div> 

345 {DATA_COLLECTION_ONLY_DIV} 

346 """ # noqa 

347 

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

349 scale_lookup = SnomedLookup.BDI_SCALE 

350 if self.bdi_scale in [SCALE_BDI_I, SCALE_BDI_IA]: 

351 score_lookup = SnomedLookup.BDI_SCORE 

352 proc_lookup = SnomedLookup.BDI_PROCEDURE_ASSESSMENT 

353 elif self.bdi_scale == SCALE_BDI_II: 

354 score_lookup = SnomedLookup.BDI_II_SCORE 

355 proc_lookup = SnomedLookup.BDI_II_PROCEDURE_ASSESSMENT 

356 else: 

357 return [] 

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

359 if self.is_complete(): 

360 codes.append(SnomedExpression( 

361 req.snomed(scale_lookup), 

362 { 

363 req.snomed(score_lookup): self.total_score(), 

364 } 

365 )) 

366 return codes