Coverage for tasks/edeq.py: 50%
109 statements
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-15 14:23 +0100
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-15 14:23 +0100
1"""
2camcops_server/tasks/edeq.py
4===============================================================================
6 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
7 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
9 This file is part of CamCOPS.
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.
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.
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/>.
24===============================================================================
26**Eating Disorder Examination Questionnaire (EDE-Q 6.0) task.**
28"""
30import statistics
31from typing import Any, List, Optional, Type
33from cardinal_pythonlib.stringfunc import strnumlist, strseq
34from sqlalchemy import Column
35from sqlalchemy.sql.sqltypes import Boolean, Float, Integer
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
46class Edeq( # type: ignore[misc]
47 TaskHasPatientMixin,
48 Task,
49):
50 __tablename__ = "edeq"
51 shortname = "EDE-Q"
52 provides_trackers = True
54 N_QUESTIONS = 28
56 MEASUREMENT_FIELD_NAMES = ["mass_kg", "height_m"]
58 @classmethod
59 def extend_columns(cls: Type["Edeq"], **kwargs: Any) -> None:
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 )
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 )
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 )
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 )
139 setattr(
140 cls,
141 "mass_kg",
142 Column("mass_kg", Float, comment="Mass (kg)"),
143 )
145 setattr(
146 cls,
147 "height_m",
148 Column("height_m", Float, comment="Height (m)"),
149 )
151 setattr(
152 cls,
153 "num_periods_missed",
154 Column(
155 "num_periods_missed",
156 Integer,
157 comment="Number of periods missed",
158 ),
159 )
161 setattr(
162 cls,
163 "pill",
164 Column(
165 "pill", Boolean, comment="Taking the (oral contraceptive) pill"
166 ),
167 )
169 COMMON_FIELD_NAMES = strseq("q", 1, N_QUESTIONS) + MEASUREMENT_FIELD_NAMES
171 FEMALE_FIELD_NAMES = ["num_periods_missed", "pill"]
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)
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)
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)
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)
189 @staticmethod
190 def longname(req: CamcopsRequest) -> str:
191 _ = req.gettext
192 return _("Eating Disorder Examination Questionnaire")
194 def is_complete(self) -> bool:
195 if self.any_fields_none(self.COMMON_FIELD_NAMES):
196 return False
198 if self.patient.sex == "F" and self.any_fields_none(
199 self.FEMALE_FIELD_NAMES
200 ):
201 return False
203 return True
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 ]
216 def get_task_html(self, req: CamcopsRequest) -> str:
217 score_range = "[0–6]"
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)
224 rows += tr_qa(question_cell, self.get_answer_cell(req, q_num))
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"
233 rows += tr_qa(self.xstring(req, "mass_kg"), mass)
234 rows += tr_qa(self.xstring(req, "height_m"), height)
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))
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
296 def get_answer_cell(
297 self, req: CamcopsRequest, q_num: int
298 ) -> Optional[str]:
299 q_field = "q" + str(q_num)
301 score = getattr(self, q_field)
302 if score is None or (13 <= q_num <= 18):
303 return score
305 meaning = self.get_score_meaning(req, q_num, score)
307 answer_cell = f"{score} [{meaning}]"
309 return answer_cell
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}")
317 if q_num == 20:
318 return self.wxstring(req, f"freq_option_{score}")
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_}"
325 return self.wxstring(req, f"how_much_option_{score}")
327 def restraint(self) -> Optional[float]:
328 return self.subscale(self.RESTRAINT_FIELD_NAMES)
330 def eating_concern(self) -> Optional[float]:
331 return self.subscale(self.EATING_CONCERN_FIELD_NAMES)
333 def shape_concern(self) -> Optional[float]:
334 return self.subscale(self.SHAPE_CONCERN_FIELD_NAMES)
336 def weight_concern(self) -> Optional[float]:
337 return self.subscale(self.WEIGHT_CONCERN_FIELD_NAMES)
339 def subscale(self, field_names: List[str]) -> Optional[float]:
340 if self.any_fields_none(field_names):
341 return None
343 return self.mean_fields(field_names)
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 ]
353 if None in subscales:
354 return None
356 return statistics.mean(subscales)