Coverage for tasks/cet.py: 49%
114 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/cet.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"""
28import logging
29from typing import Any, Dict, List, Optional, Type, Union
31from cardinal_pythonlib.stringfunc import strseq
32from sqlalchemy.sql.sqltypes import Float
34from camcops_server.cc_modules.cc_constants import CssClass
35from camcops_server.cc_modules.cc_ctvinfo import CtvInfo, CTV_INCOMPLETE
36from camcops_server.cc_modules.cc_db import add_multiple_columns
37from camcops_server.cc_modules.cc_fhir import (
38 FHIRAnsweredQuestion,
39 FHIRAnswerType,
40 FHIRQuestionType,
41)
42from camcops_server.cc_modules.cc_html import a_href, answer, pmid, tr, tr_qa
43from camcops_server.cc_modules.cc_request import CamcopsRequest
44from camcops_server.cc_modules.cc_summaryelement import SummaryElement
45from camcops_server.cc_modules.cc_task import (
46 get_from_dict,
47 Task,
48 TaskHasPatientMixin,
49)
50from camcops_server.cc_modules.cc_text import SS
51from camcops_server.cc_modules.cc_trackerhelpers import (
52 TrackerAxisTick,
53 TrackerInfo,
54)
56log = logging.getLogger(__name__)
59# =============================================================================
60# Constants
61# =============================================================================
63TARANIS_PHD_URL = (
64 "https://repository.lboro.ac.uk/articles/thesis/"
65 "Compulsive_exercise_and_eating_disorder_related_pathology/9609239/1"
66)
67CET_COPYRIGHT = f"""
68CET: © Lorin Taranis, 2010. See Taranis, L. (2010). Compulsive exercise and
69eating disorder related pathology. PhD thesis, Loughborough University.
70{a_href(TARANIS_PHD_URL)}; EThOS ID: uk.bl.ethos.544467. Licensed under a
71Creative Commons CC BY-NC-ND 2.5 licence. Additional publications include
72Taranis et al. (2011), {pmid(21584918)}; Meyer et al. (2016), {pmid(27547403)}.
73"""
76# =============================================================================
77# CET
78# =============================================================================
81class Cet( # type: ignore[misc]
82 TaskHasPatientMixin,
83 Task,
84):
85 """
86 Server implementation of the CET task.
87 """
89 __tablename__ = "cet"
90 shortname = "CET"
91 provides_trackers = True
93 FIRST_Q = 1
94 N_QUESTIONS = 24
95 MIN_ANSWER = 0
96 MAX_ANSWER = 5
97 MAX_SUBSCALE_SCORE = MAX_ANSWER
98 N_SUBSCALES = 5
99 MAX_TOTAL_SCORE = MAX_SUBSCALE_SCORE * N_SUBSCALES
100 Q_REVERSE_SCORED = [8, 12]
101 Q_SUBSCALE_1_AVOID_RULE = [9, 10, 11, 15, 16, 20, 22, 23]
102 Q_SUBSCALE_2_WT_CONTROL = [2, 6, 8, 13, 18]
103 Q_SUBSCALE_3_MOOD = [1, 4, 14, 17, 24]
104 Q_SUBSCALE_4_LACK_EX_ENJOY = [5, 12, 21]
105 Q_SUBSCALE_5_EX_RIGIDITY = [3, 7, 19]
107 @classmethod
108 def extend_columns(cls: Type["Cet"], **kwargs: Any) -> None:
109 add_multiple_columns(
110 cls,
111 "q",
112 1,
113 cls.N_QUESTIONS,
114 minimum=cls.MIN_ANSWER,
115 maximum=cls.MAX_ANSWER,
116 comment_fmt="Q{n} ({s}) (0 never true - 5 always true)",
117 comment_strings=[
118 "exercise makes happier/positive", # 1
119 "exercise to improve appearance",
120 "exercise part of organised/structured day",
121 "exercise makes less anxious",
122 "exercise a chore", # 5
123 "exercise if eat too much",
124 "exercise pattern repetitive",
125 "do not exercise to be slim",
126 "low/depressed if cannot exercise",
127 "guilty if miss exercise", # 10
128 "continue exercise despite injury/illness",
129 "enjoy exercise",
130 "exercise to burn calories/lose weight",
131 "exercise makes less stressed",
132 "compensate if miss exercise", # 15
133 "agitated/irritable if cannot exercise",
134 "exercise improves mood",
135 "worry will gain weight if cannot exercise",
136 "set routine for exercise",
137 "angry/frustrated if cannot exercise", # 20
138 "do not enjoy exercise",
139 "feel have let self down if miss exercise",
140 "anxious if cannot exercise",
141 "less depressed/low after exercise", # 24
142 ],
143 )
145 QUESTIONS = strseq("q", FIRST_Q, N_QUESTIONS) # fields and string names
146 SUBSCALE_LOOKUP = {
147 1: Q_SUBSCALE_1_AVOID_RULE,
148 2: Q_SUBSCALE_2_WT_CONTROL,
149 3: Q_SUBSCALE_3_MOOD,
150 4: Q_SUBSCALE_4_LACK_EX_ENJOY,
151 5: Q_SUBSCALE_5_EX_RIGIDITY,
152 }
154 @staticmethod
155 def longname(req: "CamcopsRequest") -> str:
156 _ = req.gettext
157 return _("Compulsive Exercise Test")
159 def is_complete(self) -> bool:
160 if self.any_fields_none(self.QUESTIONS):
161 return False
162 if not self.field_contents_valid():
163 return False
164 return True
166 def get_trackers(self, req: CamcopsRequest) -> List[TrackerInfo]:
167 return [
168 TrackerInfo(
169 value=self.total_score(),
170 plot_label="CET total score (sum of subscale scores)",
171 axis_label=f"Score (out of {self.MAX_TOTAL_SCORE})",
172 axis_min=-0.5,
173 axis_max=self.MAX_TOTAL_SCORE + 0.5,
174 axis_ticks=[
175 TrackerAxisTick(120, "120"),
176 TrackerAxisTick(100, "100"),
177 TrackerAxisTick(80, "80"),
178 TrackerAxisTick(60, "60"),
179 TrackerAxisTick(40, "40"),
180 TrackerAxisTick(20, "20"),
181 TrackerAxisTick(0, "0"),
182 ],
183 )
184 # Trackers for subscales may be over the top.
185 ]
187 def get_clinical_text(self, req: CamcopsRequest) -> List[CtvInfo]:
188 if not self.is_complete():
189 return CTV_INCOMPLETE
190 ms = f"{self.MAX_SUBSCALE_SCORE}" # ms = max subscale (score)
191 return [
192 CtvInfo(
193 content=(
194 f"CET total score (sum of subscale scores) "
195 f"{self.total_score()}/{self.MAX_TOTAL_SCORE}. "
196 f"Subscales: "
197 f"#1 avoidance and rule-driven behaviour "
198 f"{self.subscale_1_avoidance_rule_based()}/{ms}; "
199 f"#2 weight control exercise "
200 f"{self.subscale_2_weight_control()}/{ms}; "
201 f"#3 mood improvement "
202 f"{self.subscale_3_mood_improvement()}/{ms}; "
203 f"#4 lack of exercise enjoyment "
204 f"{self.subscale_4_lack_enjoyment()}/{ms}; "
205 f"#5 exercise rigidity "
206 f"{self.subscale_5_rigidity()}/{ms}."
207 )
208 )
209 ]
211 def subscale_comment(
212 self, n: int, full: bool = True, description: str = ""
213 ) -> str:
214 """
215 Returns a comment describing the subscale.
217 Args:
218 n:
219 Subscale number.
220 full:
221 Provide a full comment (for summary tables etc.), rather than
222 a short one (for task footnotes)?
223 description:
224 Only for ``full``. Describe the scale.
225 """
226 assert 1 <= n <= 5
227 questions = self.SUBSCALE_LOOKUP[n]
228 qtext_elements = [] # type: List[str]
229 rev = False
230 for q in questions:
231 qt = str(q)
232 if q in self.Q_REVERSE_SCORED:
233 qt += "*"
234 rev = True
235 qtext_elements.append(qt)
236 qtext = ", ".join(qtext_elements)
237 revtext = " (* reverse-scored)" if rev else ""
238 if full:
239 return (
240 f"Subscale {n} score: {description} "
241 f" (/{self.MAX_SUBSCALE_SCORE}); "
242 f"mean of questions {qtext}{revtext}"
243 )
244 else:
245 return f"Mean of questions {qtext}{revtext}"
247 def get_summaries(self, req: CamcopsRequest) -> List[SummaryElement]:
248 return self.standard_task_summary_fields() + [
249 SummaryElement(
250 name="total",
251 coltype=Float(),
252 value=self.total_score(),
253 comment=f"Total score (sum of subscale scores) "
254 f"(/{self.MAX_TOTAL_SCORE})",
255 ),
256 SummaryElement(
257 name="subscale_1_avoidance_rule_based",
258 coltype=Float(),
259 value=self.subscale_1_avoidance_rule_based(),
260 comment=self.subscale_comment(
261 1,
262 description="avoidance and rule-driven behaviour",
263 ),
264 ),
265 SummaryElement(
266 name="subscale_2_weight_control",
267 coltype=Float(),
268 value=self.subscale_2_weight_control(),
269 comment=self.subscale_comment(
270 2, description="weight control exercise"
271 ),
272 ),
273 SummaryElement(
274 name="subscale_3_mood_improvement",
275 coltype=Float(),
276 value=self.subscale_3_mood_improvement(),
277 comment=self.subscale_comment(
278 3,
279 description="mood improvement",
280 ),
281 ),
282 SummaryElement(
283 name="subscale_4_lack_enjoyment",
284 coltype=Float(),
285 value=self.subscale_4_lack_enjoyment(),
286 comment=self.subscale_comment(
287 4,
288 description="lack of exercise enjoyment",
289 ),
290 ),
291 SummaryElement(
292 name="subscale_5_rigidity",
293 coltype=Float(),
294 value=self.subscale_5_rigidity(),
295 comment=self.subscale_comment(
296 5,
297 description="exercise rigidity",
298 ),
299 ),
300 ]
302 def score(self, q: int) -> Optional[int]:
303 value = getattr(self, "q" + str(q))
304 if value is None:
305 return None
306 if q in self.Q_REVERSE_SCORED:
307 return self.MAX_ANSWER - value
308 else:
309 return value
311 def mean_score(self, questions: List[int]) -> Union[int, float, None]:
312 values = [self.score(q) for q in questions]
313 return self.mean_values(values, ignorevalues=[])
314 # ... not including None in ignorevalues means that no mean will be
315 # produced unless the task is complete.
317 def subscale_1_avoidance_rule_based(self) -> float:
318 return self.mean_score(self.Q_SUBSCALE_1_AVOID_RULE)
320 def subscale_2_weight_control(self) -> float:
321 return self.mean_score(self.Q_SUBSCALE_2_WT_CONTROL)
323 def subscale_3_mood_improvement(self) -> float:
324 return self.mean_score(self.Q_SUBSCALE_3_MOOD)
326 def subscale_4_lack_enjoyment(self) -> float:
327 return self.mean_score(self.Q_SUBSCALE_4_LACK_EX_ENJOY)
329 def subscale_5_rigidity(self) -> float:
330 return self.mean_score(self.Q_SUBSCALE_5_EX_RIGIDITY)
332 def total_score(self) -> Union[int, float]:
333 return self.sum_values(
334 [
335 self.subscale_1_avoidance_rule_based(),
336 self.subscale_2_weight_control(),
337 self.subscale_3_mood_improvement(),
338 self.subscale_4_lack_enjoyment(),
339 self.subscale_5_rigidity(),
340 ]
341 )
343 def get_task_html(self, req: CamcopsRequest) -> str:
344 answerdict: dict[Optional[int], Optional[str]] = {None: None}
345 for a in range(self.MIN_ANSWER, self.MAX_ANSWER + 1):
346 answerdict[a] = f"{a}: " + self.wxstring(req, f"a{a}")
347 q_a = ""
348 for q_field in self.QUESTIONS:
349 q_a += tr_qa(
350 self.wxstring(req, q_field),
351 get_from_dict(answerdict, getattr(self, q_field)),
352 )
353 ms = f" / {self.MAX_SUBSCALE_SCORE}"
355 h = """
356 <div class="{CssClass.SUMMARY}">
357 <table class="{CssClass.SUMMARY}">
358 {tr_is_complete}
359 {subscale_1}
360 {subscale_2}
361 {subscale_3}
362 {subscale_4}
363 {subscale_5}
364 {total_score}
365 </table>
366 </div>
367 <table class="{CssClass.TASKDETAIL}">
368 <tr>
369 <th width="60%">Question</th>
370 <th width="40%">Answer</th>
371 </tr>
372 {q_a}
373 </table>
374 <div class="{CssClass.FOOTNOTES}">
375 [1] {COMMENT_SS_1}.
376 [2] {COMMENT_SS_2}.
377 [3] {COMMENT_SS_3}.
378 [4] {COMMENT_SS_4}.
379 [5] {COMMENT_SS_5}.
380 [6] Sum of all subscale scores.
381 </div>
382 <div class="{CssClass.COPYRIGHT}">
383 {CET_COPYRIGHT}
384 </div>
385 """.format(
386 CssClass=CssClass,
387 tr_is_complete=self.get_is_complete_tr(req),
388 subscale_1=tr(
389 self.wxstring(req, "subscale1") + " <sup>[1]</sup>",
390 answer(self.subscale_1_avoidance_rule_based()) + ms,
391 ),
392 subscale_2=tr(
393 self.wxstring(req, "subscale2") + " <sup>[2]</sup>",
394 answer(self.subscale_2_weight_control()) + ms,
395 ),
396 subscale_3=tr(
397 self.wxstring(req, "subscale3") + " <sup>[3]</sup>",
398 answer(self.subscale_3_mood_improvement()) + ms,
399 ),
400 subscale_4=tr(
401 self.wxstring(req, "subscale4") + " <sup>[4]</sup>",
402 answer(self.subscale_4_lack_enjoyment()) + ms,
403 ),
404 subscale_5=tr(
405 self.wxstring(req, "subscale5") + " <sup>[5]</sup>",
406 answer(self.subscale_5_rigidity()) + ms,
407 ),
408 total_score=tr(
409 req.sstring(SS.TOTAL_SCORE) + " <sup>[6]</sup>",
410 answer(self.total_score()) + f" / {self.MAX_TOTAL_SCORE}",
411 ),
412 q_a=q_a,
413 COMMENT_SS_1=self.subscale_comment(1),
414 COMMENT_SS_2=self.subscale_comment(2),
415 COMMENT_SS_3=self.subscale_comment(3),
416 COMMENT_SS_4=self.subscale_comment(4),
417 COMMENT_SS_5=self.subscale_comment(5),
418 CET_COPYRIGHT=CET_COPYRIGHT,
419 )
420 return h
422 # There are no SNOMED codes for "compulsive exercise" as of 2023-12-20.
424 def get_fhir_questionnaire(
425 self, req: "CamcopsRequest"
426 ) -> List[FHIRAnsweredQuestion]:
427 items = [] # type: List[FHIRAnsweredQuestion]
429 answer_options = {} # type: Dict[int, str]
430 for index in range(self.MIN_ANSWER, self.MAX_ANSWER + 1):
431 answer_options[index] = self.wxstring(req, f"a{index}")
432 for q_field in self.QUESTIONS:
433 items.append(
434 FHIRAnsweredQuestion(
435 qname=q_field,
436 qtext=self.xstring(req, q_field),
437 qtype=FHIRQuestionType.CHOICE,
438 answer_type=FHIRAnswerType.INTEGER,
439 answer=getattr(self, q_field),
440 answer_options=answer_options,
441 )
442 )
444 return items