Coverage for tasks/cesdr.py: 52%
111 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/cesdr.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.classes import classproperty
33from cardinal_pythonlib.stringfunc import strseq
34from semantic_version import Version
35from sqlalchemy.ext.declarative import DeclarativeMeta
36from sqlalchemy.sql.sqltypes import Boolean
38from camcops_server.cc_modules.cc_constants import CssClass
39from camcops_server.cc_modules.cc_ctvinfo import CtvInfo, CTV_INCOMPLETE
40from camcops_server.cc_modules.cc_db import add_multiple_columns
41from camcops_server.cc_modules.cc_html import get_yes_no, tr, tr_qa
42from camcops_server.cc_modules.cc_request import CamcopsRequest
44from camcops_server.cc_modules.cc_summaryelement import SummaryElement
45from camcops_server.cc_modules.cc_task import (
46 get_from_dict,
47 Task,
48 TaskHasPatientMixin,
49)
50from camcops_server.cc_modules.cc_text import SS
51from camcops_server.cc_modules.cc_trackerhelpers import (
52 equally_spaced_int,
53 regular_tracker_axis_ticks_int,
54 TrackerInfo,
55 TrackerLabel,
56)
59# =============================================================================
60# CESD-R
61# =============================================================================
64class CesdrMetaclass(DeclarativeMeta):
65 """
66 There is a multilayer metaclass problem; see hads.py for discussion.
67 """
69 # noinspection PyInitNewSignature
70 def __init__(
71 cls: Type["Cesdr"],
72 name: str,
73 bases: Tuple[Type, ...],
74 classdict: Dict[str, Any],
75 ) -> None:
76 add_multiple_columns(
77 cls,
78 "q",
79 1,
80 cls.N_QUESTIONS,
81 minimum=0,
82 maximum=4,
83 comment_fmt=(
84 "Q{n} ({s}) (0 not at all - "
85 "4 nearly every day for two weeks)"
86 ),
87 comment_strings=[
88 "poor appetite",
89 "unshakable blues",
90 "poor concentration",
91 "depressed",
92 "sleep restless",
93 "sad",
94 "could not get going",
95 "nothing made me happy",
96 "felt a bad person",
97 "loss of interest",
98 "oversleeping",
99 "moving slowly",
100 "fidgety",
101 "wished were dead",
102 "wanted to hurt self",
103 "tiredness",
104 "disliked self",
105 "unintended weight loss",
106 "difficulty getting to sleep",
107 "lack of focus",
108 ],
109 )
110 super().__init__(name, bases, classdict)
113class Cesdr(TaskHasPatientMixin, Task, metaclass=CesdrMetaclass):
114 """
115 Server implementation of the CESD task.
116 """
118 __tablename__ = "cesdr"
119 shortname = "CESD-R"
120 info_filename_stem = "cesd"
121 provides_trackers = True
123 CAT_NONCLINICAL = 0
124 CAT_SUB = 1
125 CAT_POSS_MAJOR = 2
126 CAT_PROB_MAJOR = 3
127 CAT_MAJOR = 4
129 DEPRESSION_RISK_THRESHOLD = 16
131 FREQ_NOT_AT_ALL = 0
132 FREQ_1_2_DAYS_LAST_WEEK = 1
133 FREQ_3_4_DAYS_LAST_WEEK = 2
134 FREQ_5_7_DAYS_LAST_WEEK = 3
135 FREQ_DAILY_2_WEEKS = 4
137 N_QUESTIONS = 20
138 N_ANSWERS = 5
140 POSS_MAJOR_THRESH = 2
141 PROB_MAJOR_THRESH = 3
142 MAJOR_THRESH = 4
144 SCORED_FIELDS = strseq("q", 1, N_QUESTIONS)
145 TASK_FIELDS = SCORED_FIELDS
146 MIN_SCORE = 0
147 MAX_SCORE = 3 * N_QUESTIONS
149 @staticmethod
150 def longname(req: "CamcopsRequest") -> str:
151 _ = req.gettext
152 return _("Center for Epidemiologic Studies Depression Scale (Revised)")
154 # noinspection PyMethodParameters
155 @classproperty
156 def minimum_client_version(cls) -> Version:
157 return Version("2.2.8")
159 def is_complete(self) -> bool:
160 return (
161 self.all_fields_not_none(self.TASK_FIELDS)
162 and self.field_contents_valid()
163 )
165 def total_score(self) -> int:
166 return self.sum_fields(self.SCORED_FIELDS) - self.count_where(
167 self.SCORED_FIELDS, [self.FREQ_DAILY_2_WEEKS]
168 )
170 def get_depression_category(self) -> int:
172 if not self.has_depression_risk():
173 return self.CAT_SUB
175 q_group_anhedonia = [8, 10]
176 q_group_dysphoria = [2, 4, 6]
177 other_q_groups = {
178 "appetite": [1, 18],
179 "sleep": [5, 11, 19],
180 "thinking": [3, 20],
181 "guilt": [9, 17],
182 "tired": [7, 16],
183 "movement": [12, 13],
184 "suicidal": [14, 15],
185 }
187 # Dysphoria or anhedonia must be present at frequency
188 # FREQ_DAILY_2_WEEKS
189 anhedonia_criterion = self.fulfils_group_criteria(
190 q_group_anhedonia, True
191 ) or self.fulfils_group_criteria(q_group_dysphoria, True)
192 if anhedonia_criterion:
193 category_count_high_freq = 0
194 category_count_lower_freq = 0
195 for qgroup in other_q_groups.values():
196 if self.fulfils_group_criteria(qgroup, True):
197 # Category contains an answer == FREQ_DAILY_2_WEEKS
198 category_count_high_freq += 1
199 if self.fulfils_group_criteria(qgroup, False):
200 # Category contains an answer == FREQ_DAILY_2_WEEKS or
201 # FREQ_5_7_DAYS_LAST_WEEK
202 category_count_lower_freq += 1
204 if category_count_high_freq >= self.MAJOR_THRESH:
205 # Anhedonia or dysphoria (at FREQ_DAILY_2_WEEKS)
206 # plus 4 other symptom groups at FREQ_DAILY_2_WEEKS
207 return self.CAT_MAJOR
208 if category_count_lower_freq >= self.PROB_MAJOR_THRESH:
209 # Anhedonia or dysphoria (at FREQ_DAILY_2_WEEKS)
210 # plus 3 other symptom groups at FREQ_DAILY_2_WEEKS or
211 # FREQ_5_7_DAYS_LAST_WEEK
212 return self.CAT_PROB_MAJOR
213 if category_count_lower_freq >= self.POSS_MAJOR_THRESH:
214 # Anhedonia or dysphoria (at FREQ_DAILY_2_WEEKS)
215 # plus 2 other symptom groups at FREQ_DAILY_2_WEEKS or
216 # FREQ_5_7_DAYS_LAST_WEEK
217 return self.CAT_POSS_MAJOR
219 if self.has_depression_risk():
220 # Total CESD-style score >= 16 but doesn't meet other criteria.
221 return self.CAT_SUB
223 return self.CAT_NONCLINICAL
225 def fulfils_group_criteria(
226 self, qnums: List[int], nearly_every_day_2w: bool
227 ) -> bool:
228 qstrings = ["q" + str(qnum) for qnum in qnums]
229 if nearly_every_day_2w:
230 possible_values = [self.FREQ_DAILY_2_WEEKS]
231 else:
232 possible_values = [
233 self.FREQ_5_7_DAYS_LAST_WEEK,
234 self.FREQ_DAILY_2_WEEKS,
235 ]
236 count = self.count_where(qstrings, possible_values)
237 return count > 0
239 def get_trackers(self, req: CamcopsRequest) -> List[TrackerInfo]:
240 line_step = 20
241 threshold_line = self.DEPRESSION_RISK_THRESHOLD - 0.5
242 # noinspection PyTypeChecker
243 return [
244 TrackerInfo(
245 value=self.total_score(),
246 plot_label="CESD-R total score",
247 axis_label=f"Total score ({self.MIN_SCORE}-{self.MAX_SCORE})",
248 axis_min=self.MIN_SCORE - 0.5,
249 axis_max=self.MAX_SCORE + 0.5,
250 axis_ticks=regular_tracker_axis_ticks_int(
251 self.MIN_SCORE, self.MAX_SCORE, step=line_step
252 ),
253 horizontal_lines=equally_spaced_int(
254 self.MIN_SCORE + line_step,
255 self.MAX_SCORE - line_step,
256 step=line_step,
257 )
258 + [threshold_line],
259 horizontal_labels=[
260 TrackerLabel(
261 threshold_line,
262 self.wxstring(req, "depression_or_risk_of"),
263 )
264 ],
265 )
266 ]
268 def get_clinical_text(self, req: CamcopsRequest) -> List[CtvInfo]:
269 if not self.is_complete():
270 return CTV_INCOMPLETE
271 return [CtvInfo(content=f"CESD-R total score {self.total_score()}")]
273 def get_summaries(self, req: CamcopsRequest) -> List[SummaryElement]:
274 return self.standard_task_summary_fields() + [
275 SummaryElement(
276 name="depression_risk",
277 coltype=Boolean(),
278 value=self.has_depression_risk(),
279 comment="Has depression or at risk of depression",
280 )
281 ]
283 def has_depression_risk(self) -> bool:
284 return self.total_score() >= self.DEPRESSION_RISK_THRESHOLD
286 def get_task_html(self, req: CamcopsRequest) -> str:
287 score = self.total_score()
288 answer_dict = {None: None}
289 for option in range(self.N_ANSWERS):
290 answer_dict[option] = (
291 str(option) + " – " + self.wxstring(req, "a" + str(option))
292 )
293 q_a = ""
294 for q in range(1, self.N_QUESTIONS):
295 q_a += tr_qa(
296 self.wxstring(req, "q" + str(q) + "_s"),
297 get_from_dict(answer_dict, getattr(self, "q" + str(q))),
298 )
300 tr_total_score = tr_qa(f"{req.sstring(SS.TOTAL_SCORE)} (0–60)", score)
301 tr_depression_or_risk_of = tr_qa(
302 self.wxstring(req, "depression_or_risk_of") + "? <sup>[1]</sup>",
303 get_yes_no(req, self.has_depression_risk()),
304 )
305 tr_provisional_diagnosis = tr(
306 "Provisional diagnosis <sup>[2]</sup>",
307 self.wxstring(
308 req, "category_" + str(self.get_depression_category())
309 ),
310 )
311 return f"""
312 <div class="{CssClass.SUMMARY}">
313 <table class="{CssClass.SUMMARY}">
314 {self.get_is_complete_tr(req)}
315 {tr_total_score}
316 {tr_depression_or_risk_of}
317 {tr_provisional_diagnosis}
318 </table>
319 </div>
320 <table class="{CssClass.TASKDETAIL}">
321 <tr>
322 <th width="70%">Question</th>
323 <th width="30%">Answer</th>
324 </tr>
325 {q_a}
326 </table>
327 <div class="{CssClass.FOOTNOTES}">
328 [1] Presence of depression (or depression risk) is indicated by a
329 score ≥ 16
330 [2] Diagnostic criteria described at
331 <a href="https://cesd-r.com/cesdr/">https://cesd-r.com/cesdr/</a>
332 </div>
333 """ # noqa