Coverage for tasks/bdi.py : 48%

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
3"""
4camcops_server/tasks/bdi.py
6===============================================================================
8 Copyright (C) 2012-2020 Rudolf Cardinal (rudolf@pobox.com).
10 This file is part of CamCOPS.
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.
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.
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/>.
25===============================================================================
27"""
29from typing import Any, Dict, List, Tuple, Type
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
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
53# =============================================================================
54# Constants
55# =============================================================================
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}
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)
151# =============================================================================
152# BDI (crippled)
153# =============================================================================
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)
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
186 bdi_scale = Column(
187 "bdi_scale", String(length=10), # was Text
188 comment="Which BDI scale (BDI-I, BDI-IA, BDI-II)?"
189 )
191 @staticmethod
192 def longname(req: "CamcopsRequest") -> str:
193 _ = req.gettext
194 return _("Beck Depression Inventory (data collection only)")
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 )
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 )]
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 ))]
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 ]
228 def total_score(self) -> int:
229 return self.sum_fields(TASK_SCORED_FIELDS)
231 def is_bdi_ii(self) -> bool:
232 return self.bdi_scale == SCALE_BDI_II
234 def get_task_html(self, req: CamcopsRequest) -> str:
235 score = self.total_score()
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
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
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 )
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).
327 [2] Insight study:
328 <a href="https://doi.org/10.1186/ISRCTN16942542">doi:10.1186/ISRCTN16942542</a>
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.
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>
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>
344 </div>
345 {DATA_COLLECTION_ONLY_DIV}
346 """ # noqa
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