Coverage for tasks/cet.py: 49%

1"""

2 camcops_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

28 import logging

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

30

31 from cardinal_pythonlib.stringfunc import strseq

32 from sqlalchemy.sql.sqltypes import Float

33

34 from camcops_server.cc_modules.cc_constants import CssClass

35 from camcops_server.cc_modules.cc_ctvinfo import CtvInfo, CTV_INCOMPLETE

36 from camcops_server.cc_modules.cc_db import add_multiple_columns

37 from camcops_server.cc_modules.cc_fhir import (

38 FHIRAnsweredQuestion,

39 FHIRAnswerType,

40 FHIRQuestionType,

41)

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

43 from camcops_server.cc_modules.cc_request import CamcopsRequest

44 from camcops_server.cc_modules.cc_summaryelement import SummaryElement

45 from camcops_server.cc_modules.cc_task import (

46 get_from_dict,

47 Task,

48 TaskHasPatientMixin,

49)

50 from camcops_server.cc_modules.cc_text import SS

51 from camcops_server.cc_modules.cc_trackerhelpers import (

52 TrackerAxisTick,

53 TrackerInfo,

54)

55

56 log = logging.getLogger(__name__)

57

58

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

60# Constants

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

62

63 TARANIS_PHD_URL = (

64 "https://repository.lboro.ac.uk/articles/thesis/"

65 "Compulsive_exercise_and_eating_disorder_related_pathology/9609239/1"

66)

67 CET_COPYRIGHT = f"""

68 CET: © Lorin Taranis, 2010. See Taranis, L. (2010). Compulsive exercise and

69 eating disorder related pathology. PhD thesis, Loughborough University.

70{a_href(TARANIS_PHD_URL)}; EThOS ID: uk.bl.ethos.544467. Licensed under a

71 Creative Commons CC BY-NC-ND 2.5 licence. Additional publications include

72 Taranis et al. (2011), {pmid(21584918)}; Meyer et al. (2016), {pmid(27547403)}.

73"""

74

75

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

77# CET

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

79

80

81 class 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