Coverage for tasks/cet.py: 49%

114 statements  

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

1""" 

2camcops_server/tasks/cet.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 

28import logging 

29from typing import Any, Dict, List, Optional, Type, Union 

30 

31from cardinal_pythonlib.stringfunc import strseq 

32from sqlalchemy.sql.sqltypes import Float 

33 

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) 

55 

56log = logging.getLogger(__name__) 

57 

58 

59# ============================================================================= 

60# Constants 

61# ============================================================================= 

62 

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""" 

74 

75 

76# ============================================================================= 

77# CET 

78# ============================================================================= 

79 

80 

81class Cet( # type: ignore[misc] 

82 TaskHasPatientMixin, 

83 Task, 

84): 

85 """ 

86 Server implementation of the CET task. 

87 """ 

88 

89 __tablename__ = "cet" 

90 shortname = "CET" 

91 provides_trackers = True 

92 

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] 

106 

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 ) 

144 

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 } 

153 

154 @staticmethod 

155 def longname(req: "CamcopsRequest") -> str: 

156 _ = req.gettext 

157 return _("Compulsive Exercise Test") 

158 

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 

165 

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 ] 

186 

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 ] 

210 

211 def subscale_comment( 

212 self, n: int, full: bool = True, description: str = "" 

213 ) -> str: 

214 """ 

215 Returns a comment describing the subscale. 

216 

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}" 

246 

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 ] 

301 

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 

310 

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. 

316 

317 def subscale_1_avoidance_rule_based(self) -> float: 

318 return self.mean_score(self.Q_SUBSCALE_1_AVOID_RULE) 

319 

320 def subscale_2_weight_control(self) -> float: 

321 return self.mean_score(self.Q_SUBSCALE_2_WT_CONTROL) 

322 

323 def subscale_3_mood_improvement(self) -> float: 

324 return self.mean_score(self.Q_SUBSCALE_3_MOOD) 

325 

326 def subscale_4_lack_enjoyment(self) -> float: 

327 return self.mean_score(self.Q_SUBSCALE_4_LACK_EX_ENJOY) 

328 

329 def subscale_5_rigidity(self) -> float: 

330 return self.mean_score(self.Q_SUBSCALE_5_EX_RIGIDITY) 

331 

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 ) 

342 

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}" 

354 

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 

421 

422 # There are no SNOMED codes for "compulsive exercise" as of 2023-12-20. 

423 

424 def get_fhir_questionnaire( 

425 self, req: "CamcopsRequest" 

426 ) -> List[FHIRAnsweredQuestion]: 

427 items = [] # type: List[FHIRAnsweredQuestion] 

428 

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 ) 

443 

444 return items