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