Coverage for tasks/bdi.py: 48%
106 statements
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-08 23:14 +0000
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-08 23:14 +0000
1#!/usr/bin/env python
3"""
4camcops_server/tasks/bdi.py
6===============================================================================
8 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
9 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
11 This file is part of CamCOPS.
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.
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.
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/>.
26===============================================================================
28"""
30from typing import Any, Dict, List, Tuple, Type
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
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
54# =============================================================================
55# Constants
56# =============================================================================
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}
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)
153# =============================================================================
154# BDI (crippled)
155# =============================================================================
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)
186class Bdi(TaskHasPatientMixin, Task, metaclass=BdiMetaclass):
187 """
188 Server implementation of the BDI task.
189 """
191 __tablename__ = "bdi"
192 shortname = "BDI"
193 provides_trackers = True
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 )
201 @staticmethod
202 def longname(req: "CamcopsRequest") -> str:
203 _ = req.gettext
204 return _("Beck Depression Inventory (data collection only)")
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 )
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 ]
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 ]
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 ]
246 def total_score(self) -> int:
247 return self.sum_fields(TASK_SCORED_FIELDS)
249 def is_bdi_ii(self) -> bool:
250 return self.bdi_scale == SCALE_BDI_II
252 def get_task_html(self, req: CamcopsRequest) -> str:
253 score = self.total_score()
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
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
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 )
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).
350 [2] Insight study:
351 <a href="https://doi.org/10.1186/ISRCTN16942542">doi:10.1186/ISRCTN16942542</a>
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.
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>
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>
367 </div>
368 {DATA_COLLECTION_ONLY_DIV}
369 """ # noqa
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