Coverage for tasks/bdi.py: 47%

1"""

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

28 from typing import Any, cast, List, Type

29

30 from cardinal_pythonlib.stringfunc import strseq

31 import cardinal_pythonlib.rnc_web as ws

32 from sqlalchemy.sql.schema import Column

33 from sqlalchemy.sql.sqltypes import Integer, String

34

35 from camcops_server.cc_modules.cc_constants import (

36 CssClass,

37 DATA_COLLECTION_ONLY_DIV,

38)

39 from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo

40 from camcops_server.cc_modules.cc_db import add_multiple_columns

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

42 from camcops_server.cc_modules.cc_request import CamcopsRequest

43 from camcops_server.cc_modules.cc_snomed import SnomedExpression, SnomedLookup

44 from camcops_server.cc_modules.cc_string import AS

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 from camcops_server.cc_modules.cc_trackerhelpers import TrackerInfo

49

50

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

52# Constants

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

54

55 BDI_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}

79 BDI_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}

103 BDI_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}

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

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

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

133 TOPICS_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

139 NQUESTIONS = 21

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

141 MAX_SCORE = NQUESTIONS * 3

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

143 SUICIDALITY_FN = "q9" # fieldname

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

145 CUSTOM_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

155 class 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