Coverage for tasks/cesdr.py: 52%

1"""

2 camcops_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

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

29

30 from cardinal_pythonlib.classes import classproperty

31 from cardinal_pythonlib.stringfunc import strseq

32 from semantic_version import Version

33 from sqlalchemy.sql.sqltypes import Boolean

34

35 from camcops_server.cc_modules.cc_constants import CssClass

36 from camcops_server.cc_modules.cc_ctvinfo import CtvInfo, CTV_INCOMPLETE

37 from camcops_server.cc_modules.cc_db import add_multiple_columns

38 from camcops_server.cc_modules.cc_html import get_yes_no, tr, tr_qa

39 from camcops_server.cc_modules.cc_request import CamcopsRequest

40

41 from camcops_server.cc_modules.cc_summaryelement import SummaryElement

42 from camcops_server.cc_modules.cc_task import (

43 get_from_dict,

44 Task,

45 TaskHasPatientMixin,

46)

47 from camcops_server.cc_modules.cc_text import SS

48 from 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

61 class 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