Coverage for tasks/edeq.py: 51%

108 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2022-11-08 23:14 +0000

1#!/usr/bin/env python 

2 

3""" 

4camcops_server/tasks/edeq.py 

5 

6=============================================================================== 

7 

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

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

10 

11 This file is part of CamCOPS. 

12 

13 CamCOPS is free software: you can redistribute it and/or modify 

14 it under the terms of the GNU General Public License as published by 

15 the Free Software Foundation, either version 3 of the License, or 

16 (at your option) any later version. 

17 

18 CamCOPS is distributed in the hope that it will be useful, 

19 but WITHOUT ANY WARRANTY; without even the implied warranty of 

20 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

21 GNU General Public License for more details. 

22 

23 You should have received a copy of the GNU General Public License 

24 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>. 

25 

26=============================================================================== 

27 

28**Eating Disorder Examination Questionnaire (EDE-Q 6.0) task.** 

29 

30""" 

31 

32import statistics 

33from typing import Any, Dict, List, Optional, Type, Tuple 

34 

35from cardinal_pythonlib.stringfunc import strnumlist, strseq 

36from sqlalchemy import Column 

37from sqlalchemy.ext.declarative import DeclarativeMeta 

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

39 

40from camcops_server.cc_modules.cc_constants import CssClass 

41from camcops_server.cc_modules.cc_db import add_multiple_columns 

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

43from camcops_server.cc_modules.cc_request import CamcopsRequest 

44from camcops_server.cc_modules.cc_task import TaskHasPatientMixin, Task 

45from camcops_server.cc_modules.cc_text import SS 

46 

47 

48class EdeqMetaclass(DeclarativeMeta): 

49 def __init__( 

50 cls: Type["Edeq"], 

51 name: str, 

52 bases: Tuple[Type, ...], 

53 classdict: Dict[str, Any], 

54 ) -> None: 

55 

56 add_multiple_columns( 

57 cls, 

58 "q", 

59 1, 

60 12, 

61 coltype=Integer, 

62 minimum=0, 

63 maximum=6, 

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

65 comment_strings=[ 

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

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

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

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

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

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

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

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

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

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

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

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

78 ], 

79 ) 

80 

81 add_multiple_columns( 

82 cls, 

83 "q", 

84 13, 

85 18, 

86 coltype=Integer, 

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

88 comment_strings=[ 

89 "times eaten unusually large amount of food", 

90 "times sense lost control", 

91 "days episodes of overeating", 

92 "times made self sick", 

93 "times taken laxatives", 

94 "times exercised in driven or compulsive way", 

95 ], 

96 ) 

97 

98 add_multiple_columns( 

99 cls, 

100 "q", 

101 19, 

102 21, 

103 coltype=Integer, 

104 minimum=0, 

105 maximum=6, 

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

107 comment_strings=[ 

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

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

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

111 ], 

112 ) 

113 

114 add_multiple_columns( 

115 cls, 

116 "q", 

117 22, 

118 28, 

119 coltype=Integer, 

120 minimum=0, 

121 maximum=6, 

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

123 comment_strings=[ 

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

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

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

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

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

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

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

131 ], 

132 ) 

133 

134 setattr( 

135 cls, 

136 "mass_kg", 

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

138 ) 

139 

140 setattr( 

141 cls, 

142 "height_m", 

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

144 ) 

145 

146 setattr( 

147 cls, 

148 "num_periods_missed", 

149 Column( 

150 "num_periods_missed", 

151 Integer, 

152 comment="Number of periods missed", 

153 ), 

154 ) 

155 

156 setattr( 

157 cls, 

158 "pill", 

159 Column( 

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

161 ), 

162 ) 

163 

164 super().__init__(name, bases, classdict) 

165 

166 

167class Edeq(TaskHasPatientMixin, Task, metaclass=EdeqMetaclass): 

168 __tablename__ = "edeq" 

169 shortname = "EDE-Q" 

170 

171 N_QUESTIONS = 28 

172 

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

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

175 

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

177 

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

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

180 RESTRAINT_FIELD_NAMES = strnumlist("q", RESTRAINT_Q_NUMS) 

181 

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

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

184 EATING_CONCERN_FIELD_NAMES = strnumlist("q", EATING_CONCERN_Q_NUMS) 

185 

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

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

188 SHAPE_CONCERN_FIELD_NAMES = strnumlist("q", SHAPE_CONCERN_Q_NUMS) 

189 

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

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

192 WEIGHT_CONCERN_FIELD_NAMES = strnumlist("q", WEIGHT_CONCERN_Q_NUMS) 

193 

194 @staticmethod 

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

196 _ = req.gettext 

197 return _("Eating Disorder Examination Questionnaire") 

198 

199 def is_complete(self) -> bool: 

200 if self.any_fields_none(self.COMMON_FIELD_NAMES): 

201 return False 

202 

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

204 self.FEMALE_FIELD_NAMES 

205 ): 

206 return False 

207 

208 return True 

209 

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

211 score_range = "[0–6]" 

212 

213 rows = "" 

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

215 field = "q" + str(q_num) 

216 question_cell = self.xstring(req, field) 

217 

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

219 

220 mass = getattr(self, "mass_kg") 

221 if mass is not None: 

222 mass = f"{mass} kg" 

223 height = getattr(self, "height_m") 

224 if height is not None: 

225 height = f"{height} m" 

226 

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

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

229 

230 if self.patient.is_female(): 

231 for field in self.FEMALE_FIELD_NAMES: 

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

233 

234 html = """ 

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

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

237 {tr_is_complete} 

238 {global_score} 

239 {restraint_score} 

240 {eating_concern_score} 

241 {shape_concern_score} 

242 {weight_concern_score} 

243 </table> 

244 </div> 

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

246 <tr> 

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

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

249 </tr> 

250 {rows} 

251 </table> 

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

253 [1] Mean of four subscales. 

254 [2] Mean of questions {restraint_q_nums}. 

255 [3] Mean of questions {eating_concern_q_nums}. 

256 [4] Mean of questions {shape_concern_q_nums}. 

257 [5] Mean of questions {weight_concern_q_nums}. 

258 </div> 

259 """.format( 

260 CssClass=CssClass, 

261 tr_is_complete=self.get_is_complete_tr(req), 

262 global_score=tr( 

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

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

265 ), 

266 restraint_score=tr( 

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

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

269 ), 

270 eating_concern_score=tr( 

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

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

273 ), 

274 shape_concern_score=tr( 

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

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

277 ), 

278 weight_concern_score=tr( 

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

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

281 ), 

282 rows=rows, 

283 restraint_q_nums=self.RESTRAINT_Q_STR, 

284 eating_concern_q_nums=self.EATING_CONCERN_Q_STR, 

285 shape_concern_q_nums=self.SHAPE_CONCERN_Q_STR, 

286 weight_concern_q_nums=self.WEIGHT_CONCERN_Q_STR, 

287 ) 

288 return html 

289 

290 def get_answer_cell( 

291 self, req: CamcopsRequest, q_num: int 

292 ) -> Optional[str]: 

293 q_field = "q" + str(q_num) 

294 

295 score = getattr(self, q_field) 

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

297 return score 

298 

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

300 

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

302 

303 return answer_cell 

304 

305 def get_score_meaning( 

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

307 ) -> str: 

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

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

310 

311 if q_num == 20: 

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

313 

314 if score % 2 == 1: 

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

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

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

318 

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

320 

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

322 return self.subscale(self.RESTRAINT_FIELD_NAMES) 

323 

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

325 return self.subscale(self.EATING_CONCERN_FIELD_NAMES) 

326 

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

328 return self.subscale(self.SHAPE_CONCERN_FIELD_NAMES) 

329 

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

331 return self.subscale(self.WEIGHT_CONCERN_FIELD_NAMES) 

332 

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

334 if self.any_fields_none(field_names): 

335 return None 

336 

337 return self.mean_fields(field_names) 

338 

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

340 subscales = [ 

341 self.restraint(), 

342 self.eating_concern(), 

343 self.shape_concern(), 

344 self.weight_concern(), 

345 ] 

346 

347 if None in subscales: 

348 return None 

349 

350 return statistics.mean(subscales)