Coverage for tasks/core10.py: 62%

1"""

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

28 import logging

29 from typing import cast, Dict, List, Optional, Type

30

31 from cardinal_pythonlib.classes import classproperty

32 from cardinal_pythonlib.stringfunc import strseq

33 from semantic_version import Version

34 from sqlalchemy.orm import Mapped

35 from sqlalchemy.sql.sqltypes import Integer

36

37 from camcops_server.cc_modules.cc_constants import CssClass

38 from camcops_server.cc_modules.cc_ctvinfo import CtvInfo, CTV_INCOMPLETE

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

40 from camcops_server.cc_modules.cc_report import (

41 AverageScoreReport,

42 ScoreDetails,

43)

44 from camcops_server.cc_modules.cc_request import CamcopsRequest

45 from camcops_server.cc_modules.cc_snomed import SnomedExpression, SnomedLookup

46 from camcops_server.cc_modules.cc_sqla_coltypes import (

47 mapped_camcops_column,

48 ZERO_TO_FOUR_CHECKER,

49)

50 from camcops_server.cc_modules.cc_summaryelement import SummaryElement

51 from camcops_server.cc_modules.cc_task import (

52 get_from_dict,

53 Task,

54 TaskHasPatientMixin,

55)

56 from camcops_server.cc_modules.cc_trackerhelpers import (

57 TrackerAxisTick,

58 TrackerInfo,

59)

60

61 log = logging.getLogger(__name__)

62

63

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

65# CORE-10

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

67

68

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

277 class 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 ]