Coverage for tasks/core10.py: 62%

96 statements  

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

1""" 

2camcops_server/tasks/core10.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 cast, Dict, List, Optional, Type 

30 

31from cardinal_pythonlib.classes import classproperty 

32from cardinal_pythonlib.stringfunc import strseq 

33from semantic_version import Version 

34from sqlalchemy.orm import Mapped 

35from sqlalchemy.sql.sqltypes import Integer 

36 

37from camcops_server.cc_modules.cc_constants import CssClass 

38from camcops_server.cc_modules.cc_ctvinfo import CtvInfo, CTV_INCOMPLETE 

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

40from camcops_server.cc_modules.cc_report import ( 

41 AverageScoreReport, 

42 ScoreDetails, 

43) 

44from camcops_server.cc_modules.cc_request import CamcopsRequest 

45from camcops_server.cc_modules.cc_snomed import SnomedExpression, SnomedLookup 

46from camcops_server.cc_modules.cc_sqla_coltypes import ( 

47 mapped_camcops_column, 

48 ZERO_TO_FOUR_CHECKER, 

49) 

50from camcops_server.cc_modules.cc_summaryelement import SummaryElement 

51from camcops_server.cc_modules.cc_task import ( 

52 get_from_dict, 

53 Task, 

54 TaskHasPatientMixin, 

55) 

56from camcops_server.cc_modules.cc_trackerhelpers import ( 

57 TrackerAxisTick, 

58 TrackerInfo, 

59) 

60 

61log = logging.getLogger(__name__) 

62 

63 

64# ============================================================================= 

65# CORE-10 

66# ============================================================================= 

67 

68 

69class Core10(TaskHasPatientMixin, Task): # type: ignore[misc] 

70 """ 

71 Server implementation of the CORE-10 task. 

72 """ 

73 

74 __tablename__ = "core10" 

75 shortname = "CORE-10" 

76 provides_trackers = True 

77 

78 COMMENT_NORMAL = " (0 not at all - 4 most or all of the time)" 

79 COMMENT_REVERSED = " (0 most or all of the time - 4 not at all)" 

80 

81 q1: Mapped[Optional[int]] = mapped_camcops_column( 

82 permitted_value_checker=ZERO_TO_FOUR_CHECKER, 

83 comment="Q1 (tension/anxiety)" + COMMENT_NORMAL, 

84 ) 

85 q2: Mapped[Optional[int]] = mapped_camcops_column( 

86 permitted_value_checker=ZERO_TO_FOUR_CHECKER, 

87 comment="Q2 (support)" + COMMENT_REVERSED, 

88 ) 

89 q3: Mapped[Optional[int]] = mapped_camcops_column( 

90 permitted_value_checker=ZERO_TO_FOUR_CHECKER, 

91 comment="Q3 (coping)" + COMMENT_REVERSED, 

92 ) 

93 q4: Mapped[Optional[int]] = mapped_camcops_column( 

94 permitted_value_checker=ZERO_TO_FOUR_CHECKER, 

95 comment="Q4 (talking is too much)" + COMMENT_NORMAL, 

96 ) 

97 q5: Mapped[Optional[int]] = mapped_camcops_column( 

98 permitted_value_checker=ZERO_TO_FOUR_CHECKER, 

99 comment="Q5 (panic)" + COMMENT_NORMAL, 

100 ) 

101 q6: Mapped[Optional[int]] = mapped_camcops_column( 

102 permitted_value_checker=ZERO_TO_FOUR_CHECKER, 

103 comment="Q6 (suicidality)" + COMMENT_NORMAL, 

104 ) 

105 q7: Mapped[Optional[int]] = mapped_camcops_column( 

106 permitted_value_checker=ZERO_TO_FOUR_CHECKER, 

107 comment="Q7 (sleep problems)" + COMMENT_NORMAL, 

108 ) 

109 q8: Mapped[Optional[int]] = mapped_camcops_column( 

110 permitted_value_checker=ZERO_TO_FOUR_CHECKER, 

111 comment="Q8 (despair/hopelessness)" + COMMENT_NORMAL, 

112 ) 

113 q9: Mapped[Optional[int]] = mapped_camcops_column( 

114 permitted_value_checker=ZERO_TO_FOUR_CHECKER, 

115 comment="Q9 (unhappy)" + COMMENT_NORMAL, 

116 ) 

117 q10: Mapped[Optional[int]] = mapped_camcops_column( 

118 permitted_value_checker=ZERO_TO_FOUR_CHECKER, 

119 comment="Q10 (unwanted images)" + COMMENT_NORMAL, 

120 ) 

121 

122 N_QUESTIONS = 10 

123 MAX_SCORE = 4 * N_QUESTIONS 

124 QUESTION_FIELDNAMES = strseq("q", 1, N_QUESTIONS) 

125 

126 @staticmethod 

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

128 _ = req.gettext 

129 return _("Clinical Outcomes in Routine Evaluation, 10-item measure") 

130 

131 # noinspection PyMethodParameters 

132 @classproperty 

133 def minimum_client_version(cls) -> Version: 

134 return Version("2.2.8") 

135 

136 def is_complete(self) -> bool: 

137 return self.all_fields_not_none(self.QUESTION_FIELDNAMES) 

138 

139 def get_trackers(self, req: CamcopsRequest) -> List[TrackerInfo]: 

140 return [ 

141 TrackerInfo( 

142 value=self.clinical_score(), 

143 plot_label="CORE-10 clinical score (rating distress)", 

144 axis_label=f"Clinical score (out of {self.MAX_SCORE})", 

145 axis_min=-0.5, 

146 axis_max=self.MAX_SCORE + 0.5, 

147 axis_ticks=[ 

148 TrackerAxisTick(40, "40"), 

149 TrackerAxisTick(35, "35"), 

150 TrackerAxisTick(30, "30"), 

151 TrackerAxisTick(25, "25"), 

152 TrackerAxisTick(20, "20"), 

153 TrackerAxisTick(15, "15"), 

154 TrackerAxisTick(10, "10"), 

155 TrackerAxisTick(5, "5"), 

156 TrackerAxisTick(0, "0"), 

157 ], 

158 horizontal_lines=[30, 20, 10], 

159 ) 

160 ] 

161 

162 def get_clinical_text(self, req: CamcopsRequest) -> List[CtvInfo]: 

163 if not self.is_complete(): 

164 return CTV_INCOMPLETE 

165 return [ 

166 CtvInfo( 

167 content=( 

168 f"CORE-10 clinical score " 

169 f"{self.clinical_score()}/{self.MAX_SCORE}" 

170 ) 

171 ) 

172 ] 

173 # todo: CORE10: add suicidality to clinical text? 

174 

175 def get_summaries(self, req: CamcopsRequest) -> List[SummaryElement]: 

176 return self.standard_task_summary_fields() + [ 

177 SummaryElement( 

178 name="clinical_score", 

179 coltype=Integer(), 

180 value=self.clinical_score(), 

181 comment=f"Clinical score (/{self.MAX_SCORE})", 

182 ) 

183 ] 

184 

185 def total_score(self) -> int: 

186 return cast(int, self.sum_fields(self.QUESTION_FIELDNAMES)) 

187 

188 def n_questions_complete(self) -> int: 

189 return self.n_fields_not_none(self.QUESTION_FIELDNAMES) 

190 

191 def clinical_score(self) -> float: 

192 n_q_completed = self.n_questions_complete() 

193 if n_q_completed == 0: 

194 # avoid division by zero 

195 return 0 

196 return self.N_QUESTIONS * self.total_score() / n_q_completed 

197 

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

199 normal_dict = { 

200 None: None, 

201 0: "0 — " + self.wxstring(req, "a0"), 

202 1: "1 — " + self.wxstring(req, "a1"), 

203 2: "2 — " + self.wxstring(req, "a2"), 

204 3: "3 — " + self.wxstring(req, "a3"), 

205 4: "4 — " + self.wxstring(req, "a4"), 

206 } 

207 reversed_dict = { 

208 None: None, 

209 0: "0 — " + self.wxstring(req, "a4"), 

210 1: "1 — " + self.wxstring(req, "a3"), 

211 2: "2 — " + self.wxstring(req, "a2"), 

212 3: "3 — " + self.wxstring(req, "a1"), 

213 4: "4 — " + self.wxstring(req, "a0"), 

214 } 

215 

216 def get_tr_qa(qnum_: int, mapping: Dict[Optional[int], str]) -> str: 

217 nstr = str(qnum_) 

218 return tr_qa( 

219 self.wxstring(req, "q" + nstr), 

220 get_from_dict(mapping, getattr(self, "q" + nstr)), 

221 ) 

222 

223 q_a = get_tr_qa(1, normal_dict) 

224 for qnum in (2, 3): 

225 q_a += get_tr_qa(qnum, reversed_dict) 

226 for qnum in range(4, self.N_QUESTIONS + 1): 

227 q_a += get_tr_qa(qnum, normal_dict) 

228 

229 tr_clinical_score = tr( 

230 "Clinical score <sup>[1]</sup>", 

231 answer(self.clinical_score()) + " / {}".format(self.MAX_SCORE), 

232 ) 

233 return f""" 

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

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

236 {self.get_is_complete_tr(req)} 

237 {tr_clinical_score} 

238 </table> 

239 </div> 

240 <div class="{CssClass.EXPLANATION}"> 

241 Ratings are over the last week. 

242 </div> 

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

244 <tr> 

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

246 <th width="40%">Answer</th> 

247 </tr> 

248 {q_a} 

249 </table> 

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

251 [1] Clinical score is: number of questions × total score 

252 ÷ number of questions completed. If all questions are 

253 completed, it's just the total score. 

254 </div> 

255 """ 

256 

257 def get_snomed_codes(self, req: CamcopsRequest) -> List[SnomedExpression]: 

258 codes = [ 

259 SnomedExpression( 

260 req.snomed(SnomedLookup.CORE10_PROCEDURE_ASSESSMENT) 

261 ) 

262 ] 

263 if self.is_complete(): 

264 codes.append( 

265 SnomedExpression( 

266 req.snomed(SnomedLookup.CORE10_SCALE), 

267 { 

268 req.snomed( 

269 SnomedLookup.CORE10_SCORE 

270 ): self.total_score() 

271 }, 

272 ) 

273 ) 

274 return codes 

275 

276 

277class Core10Report(AverageScoreReport): 

278 """ 

279 An average score of the people seen at the start of treatment 

280 an average final measure and an average progress score. 

281 """ 

282 

283 # noinspection PyMethodParameters 

284 @classproperty 

285 def report_id(cls) -> str: 

286 return "core10" 

287 

288 @classmethod 

289 def title(cls, req: "CamcopsRequest") -> str: 

290 _ = req.gettext 

291 return _("CORE-10 — Average scores") 

292 

293 # noinspection PyMethodParameters 

294 @classproperty 

295 def task_class(cls) -> Type[Task]: 

296 return Core10 

297 

298 @classmethod 

299 def scoretypes(cls, req: "CamcopsRequest") -> List[ScoreDetails]: 

300 _ = req.gettext 

301 return [ 

302 ScoreDetails( 

303 name=_("CORE-10 clinical score"), 

304 scorefunc=Core10.clinical_score, # type: ignore[arg-type] 

305 minimum=0, 

306 maximum=Core10.MAX_SCORE, 

307 higher_score_is_better=False, 

308 ) 

309 ]