Coverage for tasks/cesdr.py : 52%

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