Coverage for tasks/edeq.py: 50%

109 statements  

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

1""" 

2camcops_server/tasks/edeq.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**Eating Disorder Examination Questionnaire (EDE-Q 6.0) task.** 

27 

28""" 

29 

30import statistics 

31from typing import Any, List, Optional, Type 

32 

33from cardinal_pythonlib.stringfunc import strnumlist, strseq 

34from sqlalchemy import Column 

35from sqlalchemy.sql.sqltypes import Boolean, Float, Integer 

36 

37from camcops_server.cc_modules.cc_constants import CssClass 

38from camcops_server.cc_modules.cc_db import add_multiple_columns 

39from camcops_server.cc_modules.cc_html import tr_qa, tr, answer 

40from camcops_server.cc_modules.cc_request import CamcopsRequest 

41from camcops_server.cc_modules.cc_task import TaskHasPatientMixin, Task 

42from camcops_server.cc_modules.cc_text import SS 

43from camcops_server.cc_modules.cc_trackerhelpers import TrackerInfo 

44 

45 

46class Edeq( # type: ignore[misc] 

47 TaskHasPatientMixin, 

48 Task, 

49): 

50 __tablename__ = "edeq" 

51 shortname = "EDE-Q" 

52 provides_trackers = True 

53 

54 N_QUESTIONS = 28 

55 

56 MEASUREMENT_FIELD_NAMES = ["mass_kg", "height_m"] 

57 

58 @classmethod 

59 def extend_columns(cls: Type["Edeq"], **kwargs: Any) -> None: 

60 

61 add_multiple_columns( 

62 cls, 

63 "q", 

64 1, 

65 12, 

66 coltype=Integer, 

67 minimum=0, 

68 maximum=6, 

69 comment_fmt="Q{n} - {s}", 

70 comment_strings=[ 

71 "days limit the amount of food 0-6 (no days - every day)", 

72 "days long periods without eating 0-6 (no days - every day)", 

73 "days exclude from diet 0-6 (no days - every day)", 

74 "days follow rules 0-6 (no days - every day)", 

75 "days desire empty stomach 0-6 (no days - every day)", 

76 "days desire flat stomach 0-6 (no days - every day)", 

77 "days thinking about food 0-6 (no days - every day)", 

78 "days thinking about shape 0-6 (no days - every day)", 

79 "days fear losing control 0-6 (no days - every day)", 

80 "days fear weight gain 0-6 (no days - every day)", 

81 "days felt fat 0-6 (no days - every day)", 

82 "days desire lose weight 0-6 (no days - every day)", 

83 ], 

84 ) 

85 

86 add_multiple_columns( 

87 cls, 

88 "q", 

89 13, 

90 18, 

91 coltype=Integer, 

92 comment_fmt="Q{n} - {s}", 

93 comment_strings=[ 

94 "times eaten unusually large amount of food", 

95 "times sense lost control", 

96 "days episodes of overeating", 

97 "times made self sick", 

98 "times taken laxatives", 

99 "times exercised in driven or compulsive way", 

100 ], 

101 ) 

102 

103 add_multiple_columns( 

104 cls, 

105 "q", 

106 19, 

107 21, 

108 coltype=Integer, 

109 minimum=0, 

110 maximum=6, 

111 comment_fmt="Q{n} - {s}", 

112 comment_strings=[ 

113 "days eaten in secret (no days - every day)", 

114 "times felt guilty (none of the times - every time)", 

115 "concern about people seeing you eat (not at all - markedly)", 

116 ], 

117 ) 

118 

119 add_multiple_columns( 

120 cls, 

121 "q", 

122 22, 

123 28, 

124 coltype=Integer, 

125 minimum=0, 

126 maximum=6, 

127 comment_fmt="Q{n} - {s}", 

128 comment_strings=[ 

129 "weight influenced how you judge self (not at all - markedly)", 

130 "shape influenced how you judge self (not at all - markedly)", 

131 "upset if asked to weigh self (not at all - markedly)", 

132 "dissatisfied with weight (not at all - markedly)", 

133 "dissatisfied with shape (not at all - markedly)", 

134 "uncomfortable seeing body (not at all - markedly)", 

135 "uncomfortable others seeing shape (not at all - markedly)", 

136 ], 

137 ) 

138 

139 setattr( 

140 cls, 

141 "mass_kg", 

142 Column("mass_kg", Float, comment="Mass (kg)"), 

143 ) 

144 

145 setattr( 

146 cls, 

147 "height_m", 

148 Column("height_m", Float, comment="Height (m)"), 

149 ) 

150 

151 setattr( 

152 cls, 

153 "num_periods_missed", 

154 Column( 

155 "num_periods_missed", 

156 Integer, 

157 comment="Number of periods missed", 

158 ), 

159 ) 

160 

161 setattr( 

162 cls, 

163 "pill", 

164 Column( 

165 "pill", Boolean, comment="Taking the (oral contraceptive) pill" 

166 ), 

167 ) 

168 

169 COMMON_FIELD_NAMES = strseq("q", 1, N_QUESTIONS) + MEASUREMENT_FIELD_NAMES 

170 

171 FEMALE_FIELD_NAMES = ["num_periods_missed", "pill"] 

172 

173 RESTRAINT_Q_NUMS = [1, 2, 3, 4, 5] 

174 RESTRAINT_Q_STR = ", ".join(str(q) for q in RESTRAINT_Q_NUMS) 

175 RESTRAINT_FIELD_NAMES = strnumlist("q", RESTRAINT_Q_NUMS) 

176 

177 EATING_CONCERN_Q_NUMS = [7, 9, 19, 20, 21] 

178 EATING_CONCERN_Q_STR = ", ".join(str(q) for q in EATING_CONCERN_Q_NUMS) 

179 EATING_CONCERN_FIELD_NAMES = strnumlist("q", EATING_CONCERN_Q_NUMS) 

180 

181 SHAPE_CONCERN_Q_NUMS = [6, 8, 10, 11, 23, 26, 27, 28] 

182 SHAPE_CONCERN_Q_STR = ", ".join(str(q) for q in SHAPE_CONCERN_Q_NUMS) 

183 SHAPE_CONCERN_FIELD_NAMES = strnumlist("q", SHAPE_CONCERN_Q_NUMS) 

184 

185 WEIGHT_CONCERN_Q_NUMS = [8, 12, 22, 24, 25] 

186 WEIGHT_CONCERN_Q_STR = ", ".join(str(q) for q in WEIGHT_CONCERN_Q_NUMS) 

187 WEIGHT_CONCERN_FIELD_NAMES = strnumlist("q", WEIGHT_CONCERN_Q_NUMS) 

188 

189 @staticmethod 

190 def longname(req: CamcopsRequest) -> str: 

191 _ = req.gettext 

192 return _("Eating Disorder Examination Questionnaire") 

193 

194 def is_complete(self) -> bool: 

195 if self.any_fields_none(self.COMMON_FIELD_NAMES): 

196 return False 

197 

198 if self.patient.sex == "F" and self.any_fields_none( 

199 self.FEMALE_FIELD_NAMES 

200 ): 

201 return False 

202 

203 return True 

204 

205 def get_trackers(self, req: CamcopsRequest) -> List[TrackerInfo]: 

206 return [ 

207 TrackerInfo( 

208 value=self.global_score(), 

209 plot_label="EDE-Q global score", 

210 axis_label="Global score (0–6)", 

211 axis_min=-0.5, 

212 axis_max=6.5, 

213 ), 

214 ] 

215 

216 def get_task_html(self, req: CamcopsRequest) -> str: 

217 score_range = "[0–6]" 

218 

219 rows = "" 

220 for q_num in range(1, self.N_QUESTIONS + 1): 

221 field = "q" + str(q_num) 

222 question_cell = self.xstring(req, field) 

223 

224 rows += tr_qa(question_cell, self.get_answer_cell(req, q_num)) 

225 

226 mass = getattr(self, "mass_kg") 

227 if mass is not None: 

228 mass = f"{mass} kg" 

229 height = getattr(self, "height_m") 

230 if height is not None: 

231 height = f"{height} m" 

232 

233 rows += tr_qa(self.xstring(req, "mass_kg"), mass) 

234 rows += tr_qa(self.xstring(req, "height_m"), height) 

235 

236 if self.patient.is_female(): 

237 for field in self.FEMALE_FIELD_NAMES: 

238 rows += tr_qa(self.xstring(req, field), getattr(self, field)) 

239 

240 html = """ 

241 <div class="{CssClass.SUMMARY}"> 

242 <table class="{CssClass.SUMMARY}"> 

243 {tr_is_complete} 

244 {global_score} 

245 {restraint_score} 

246 {eating_concern_score} 

247 {shape_concern_score} 

248 {weight_concern_score} 

249 </table> 

250 </div> 

251 <table class="{CssClass.TASKDETAIL}"> 

252 <tr> 

253 <th width="60%">Question</th> 

254 <th width="40%">Score</th> 

255 </tr> 

256 {rows} 

257 </table> 

258 <div class="{CssClass.FOOTNOTES}"> 

259 [1] Mean of four subscales. 

260 [2] Mean of questions {restraint_q_nums}. 

261 [3] Mean of questions {eating_concern_q_nums}. 

262 [4] Mean of questions {shape_concern_q_nums}. 

263 [5] Mean of questions {weight_concern_q_nums}. 

264 </div> 

265 """.format( 

266 CssClass=CssClass, 

267 tr_is_complete=self.get_is_complete_tr(req), 

268 global_score=tr( 

269 req.sstring(SS.GLOBAL_SCORE) + " <sup>[1]</sup>", 

270 f"{answer(self.global_score())} {score_range}", 

271 ), 

272 restraint_score=tr( 

273 self.wxstring(req, "restraint") + " <sup>[2]</sup>", 

274 f"{answer(self.restraint())} {score_range}", 

275 ), 

276 eating_concern_score=tr( 

277 self.wxstring(req, "eating_concern") + " <sup>[3]</sup>", 

278 f"{answer(self.eating_concern())} {score_range}", 

279 ), 

280 shape_concern_score=tr( 

281 self.wxstring(req, "shape_concern") + " <sup>[4]</sup>", 

282 f"{answer(self.shape_concern())} {score_range}", 

283 ), 

284 weight_concern_score=tr( 

285 self.wxstring(req, "weight_concern") + " <sup>[5]</sup>", 

286 f"{answer(self.weight_concern())} {score_range}", 

287 ), 

288 rows=rows, 

289 restraint_q_nums=self.RESTRAINT_Q_STR, 

290 eating_concern_q_nums=self.EATING_CONCERN_Q_STR, 

291 shape_concern_q_nums=self.SHAPE_CONCERN_Q_STR, 

292 weight_concern_q_nums=self.WEIGHT_CONCERN_Q_STR, 

293 ) 

294 return html 

295 

296 def get_answer_cell( 

297 self, req: CamcopsRequest, q_num: int 

298 ) -> Optional[str]: 

299 q_field = "q" + str(q_num) 

300 

301 score = getattr(self, q_field) 

302 if score is None or (13 <= q_num <= 18): 

303 return score 

304 

305 meaning = self.get_score_meaning(req, q_num, score) 

306 

307 answer_cell = f"{score} [{meaning}]" 

308 

309 return answer_cell 

310 

311 def get_score_meaning( 

312 self, req: CamcopsRequest, q_num: int, score: int 

313 ) -> str: 

314 if q_num <= 12 or q_num == 19: 

315 return self.wxstring(req, f"days_option_{score}") 

316 

317 if q_num == 20: 

318 return self.wxstring(req, f"freq_option_{score}") 

319 

320 if score % 2 == 1: 

321 previous = self.wxstring(req, f"how_much_option_{score-1}") 

322 next_ = self.wxstring(req, f"how_much_option_{score+1}") 

323 return f"{previous}—{next_}" 

324 

325 return self.wxstring(req, f"how_much_option_{score}") 

326 

327 def restraint(self) -> Optional[float]: 

328 return self.subscale(self.RESTRAINT_FIELD_NAMES) 

329 

330 def eating_concern(self) -> Optional[float]: 

331 return self.subscale(self.EATING_CONCERN_FIELD_NAMES) 

332 

333 def shape_concern(self) -> Optional[float]: 

334 return self.subscale(self.SHAPE_CONCERN_FIELD_NAMES) 

335 

336 def weight_concern(self) -> Optional[float]: 

337 return self.subscale(self.WEIGHT_CONCERN_FIELD_NAMES) 

338 

339 def subscale(self, field_names: List[str]) -> Optional[float]: 

340 if self.any_fields_none(field_names): 

341 return None 

342 

343 return self.mean_fields(field_names) 

344 

345 def global_score(self) -> Optional[float]: 

346 subscales = [ 

347 self.restraint(), 

348 self.eating_concern(), 

349 self.shape_concern(), 

350 self.weight_concern(), 

351 ] 

352 

353 if None in subscales: 

354 return None 

355 

356 return statistics.mean(subscales)