Coverage for tasks/bdi.py: 47%
103 statements
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-15 14:23 +0100
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-15 14:23 +0100
1"""
2camcops_server/tasks/bdi.py
4===============================================================================
6 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
7 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
9 This file is part of CamCOPS.
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.
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.
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/>.
24===============================================================================
26"""
28from typing import Any, cast, List, Type
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
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
51# =============================================================================
52# Constants
53# =============================================================================
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}
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)
150# =============================================================================
151# BDI (crippled)
152# =============================================================================
155class Bdi( # type: ignore[misc]
156 TaskHasPatientMixin,
157 Task,
158):
159 """
160 Server implementation of the BDI task.
161 """
163 __tablename__ = "bdi"
164 shortname = "BDI"
165 provides_trackers = True
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 )
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 )
193 @staticmethod
194 def longname(req: "CamcopsRequest") -> str:
195 _ = req.gettext
196 return _("Beck Depression Inventory (data collection only)")
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 )
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 ]
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 ]
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 ]
238 def total_score(self) -> int:
239 return cast(int, self.sum_fields(TASK_SCORED_FIELDS))
241 def is_bdi_ii(self) -> bool:
242 return self.bdi_scale == SCALE_BDI_II # type: ignore[return-value]
244 def get_task_html(self, req: CamcopsRequest) -> str:
245 score = self.total_score()
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
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
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 )
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).
342 [2] Insight study: {doi("10.1186/ISRCTN16942542")}
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.
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>
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>
358 </div>
359 {DATA_COLLECTION_ONLY_DIV}
360 """ # noqa
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