Coverage for tasks/cesdr.py: 52%

108 statements  

« prev     ^ index     » next       coverage.py v7.9.2, created at 2025-07-15 15:51 +0100

1""" 

2camcops_server/tasks/cesdr.py 

3 

4=============================================================================== 

5 

6 Copyright (C) 2012, University of Cambridge, Department of Psychiatry. 

7 Created by Rudolf Cardinal (rnc1001@cam.ac.uk). 

8 

9 This file is part of CamCOPS. 

10 

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. 

15 

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. 

20 

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/>. 

23 

24=============================================================================== 

25 

26""" 

27 

28from typing import Any, cast, List, Optional, Type 

29 

30from cardinal_pythonlib.classes import classproperty 

31from cardinal_pythonlib.stringfunc import strseq 

32from semantic_version import Version 

33from sqlalchemy.sql.sqltypes import Boolean 

34 

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 

40 

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) 

54 

55 

56# ============================================================================= 

57# CESD-R 

58# ============================================================================= 

59 

60 

61class Cesdr( # type: ignore[misc] 

62 TaskHasPatientMixin, 

63 Task, 

64): 

65 """ 

66 Server implementation of the CESD task. 

67 """ 

68 

69 __tablename__ = "cesdr" 

70 shortname = "CESD-R" 

71 info_filename_stem = "cesd" 

72 provides_trackers = True 

73 

74 CAT_NONCLINICAL = 0 

75 CAT_SUB = 1 

76 CAT_POSS_MAJOR = 2 

77 CAT_PROB_MAJOR = 3 

78 CAT_MAJOR = 4 

79 

80 DEPRESSION_RISK_THRESHOLD = 16 

81 

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 

87 

88 N_QUESTIONS = 20 

89 N_ANSWERS = 5 

90 

91 POSS_MAJOR_THRESH = 2 

92 PROB_MAJOR_THRESH = 3 

93 MAJOR_THRESH = 4 

94 

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 ) 

131 

132 SCORED_FIELDS = strseq("q", 1, N_QUESTIONS) 

133 TASK_FIELDS = SCORED_FIELDS 

134 MIN_SCORE = 0 

135 MAX_SCORE = 3 * N_QUESTIONS 

136 

137 @staticmethod 

138 def longname(req: "CamcopsRequest") -> str: 

139 _ = req.gettext 

140 return _("Center for Epidemiologic Studies Depression Scale (Revised)") 

141 

142 # noinspection PyMethodParameters 

143 @classproperty 

144 def minimum_client_version(cls) -> Version: 

145 return Version("2.2.8") 

146 

147 def is_complete(self) -> bool: 

148 return ( 

149 self.all_fields_not_none(self.TASK_FIELDS) 

150 and self.field_contents_valid() 

151 ) 

152 

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]) 

157 

158 def get_depression_category(self) -> int: 

159 

160 if not self.has_depression_risk(): 

161 return self.CAT_SUB 

162 

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 } 

174 

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 

191 

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 

206 

207 if self.has_depression_risk(): 

208 # Total CESD-style score >= 16 but doesn't meet other criteria. 

209 return self.CAT_SUB 

210 

211 return self.CAT_NONCLINICAL 

212 

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 

226 

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 ] 

255 

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()}")] 

260 

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 ] 

270 

271 def has_depression_risk(self) -> bool: 

272 return self.total_score() >= self.DEPRESSION_RISK_THRESHOLD 

273 

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 ) 

287 

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 &ge; 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