Coverage for tasks/rapid3.py: 52%

112 statements  

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

1""" 

2camcops_server/tasks/rapid3.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**Routine Assessment of Patient Index Data (RAPID 3) task.** 

27 

28""" 

29 

30from typing import Any, List, Optional, Type, Tuple 

31 

32import cardinal_pythonlib.rnc_web as ws 

33from sqlalchemy import Float, Integer 

34 

35from camcops_server.cc_modules.cc_constants import CssClass 

36from camcops_server.cc_modules.cc_html import answer, tr_qa, tr, tr_span_col 

37from camcops_server.cc_modules.cc_request import CamcopsRequest 

38from camcops_server.cc_modules.cc_sqla_coltypes import ( 

39 camcops_column, 

40 PermittedValueChecker, 

41 ZERO_TO_THREE_CHECKER, 

42) 

43from camcops_server.cc_modules.cc_summaryelement import SummaryElement 

44from camcops_server.cc_modules.cc_task import TaskHasPatientMixin, Task 

45from camcops_server.cc_modules.cc_trackerhelpers import ( 

46 TrackerAxisTick, 

47 TrackerInfo, 

48 TrackerLabel, 

49) 

50 

51 

52# ============================================================================= 

53# RAPID 3 

54# ============================================================================= 

55 

56 

57class Rapid3( # type: ignore[misc] 

58 TaskHasPatientMixin, 

59 Task, 

60): 

61 __tablename__ = "rapid3" 

62 shortname = "RAPID3" 

63 provides_trackers = True 

64 

65 N_Q1_QUESTIONS = 13 

66 N_Q1_SCORING_QUESTIONS = 10 

67 

68 # > 12 = HIGH 

69 # 6.1 - 12 = MODERATE 

70 # 3.1 - 6 = LOW 

71 # <= 3 = REMISSION 

72 

73 MINIMUM = 0 

74 NEAR_REMISSION_MAX = 3 

75 LOW_SEVERITY_MAX = 6 

76 MODERATE_SEVERITY_MAX = 12 

77 MAXIMUM = 30 

78 

79 @classmethod 

80 def extend_columns(cls: Type["Rapid3"], **kwargs: Any) -> None: 

81 

82 comment_strings = [ 

83 "get dressed", 

84 "get in bed", 

85 "lift full cup", 

86 "walk outdoors", 

87 "wash body", 

88 "bend down", 

89 "turn taps", 

90 "get in car", 

91 "walk 2 miles", 

92 "sports", 

93 "sleep", 

94 "anxiety", 

95 "depression", 

96 ] 

97 score_comment = "(0 without any difficulty - 3 unable to do)" 

98 

99 for q_index, q_fieldname in cls.q1_all_indexed_fieldnames(): 

100 setattr( 

101 cls, 

102 q_fieldname, 

103 camcops_column( 

104 q_fieldname, 

105 Integer, 

106 permitted_value_checker=ZERO_TO_THREE_CHECKER, 

107 comment="{} ({}) {}".format( 

108 q_fieldname.capitalize(), 

109 comment_strings[q_index], 

110 score_comment, 

111 ), 

112 ), 

113 ) 

114 

115 permitted_scale_values = [v / 2.0 for v in range(0, 20 + 1)] 

116 

117 setattr( 

118 cls, 

119 "q2", 

120 camcops_column( 

121 "q2", 

122 Float, 

123 permitted_value_checker=PermittedValueChecker( 

124 permitted_values=permitted_scale_values 

125 ), 

126 comment=( 

127 "Q2 (pain tolerance) (0 no pain - 10 pain as bad as " 

128 "it could be" 

129 ), 

130 ), 

131 ) 

132 

133 setattr( 

134 cls, 

135 "q3", 

136 camcops_column( 

137 "q3", 

138 Float, 

139 permitted_value_checker=PermittedValueChecker( 

140 permitted_values=permitted_scale_values 

141 ), 

142 comment="Q3 (patient global estimate) " 

143 "(0 very well - very poorly)", 

144 ), 

145 ) 

146 

147 @classmethod 

148 def q1_indexed_letters(cls, last: int) -> List[Tuple[int, str]]: 

149 return [(i, chr(i + ord("a"))) for i in range(0, last)] 

150 

151 @classmethod 

152 def q1_indexed_fieldnames(cls, last: int) -> List[Tuple[int, str]]: 

153 return [(i, f"q1{c}") for (i, c) in cls.q1_indexed_letters(last)] 

154 

155 @classmethod 

156 def q1_all_indexed_fieldnames(cls) -> List[Tuple[int, str]]: 

157 return [ 

158 (i, f) for (i, f) in cls.q1_indexed_fieldnames(cls.N_Q1_QUESTIONS) 

159 ] 

160 

161 @classmethod 

162 def q1_all_fieldnames(cls) -> List[str]: 

163 return [f for (i, f) in cls.q1_indexed_fieldnames(cls.N_Q1_QUESTIONS)] 

164 

165 @classmethod 

166 def q1_all_letters(cls) -> List[str]: 

167 return [c for (i, c) in cls.q1_indexed_letters(cls.N_Q1_QUESTIONS)] 

168 

169 @classmethod 

170 def q1_scoring_fieldnames(cls) -> List[str]: 

171 return [ 

172 f 

173 for (i, f) in cls.q1_indexed_fieldnames(cls.N_Q1_SCORING_QUESTIONS) 

174 ] 

175 

176 @classmethod 

177 def all_fieldnames(cls) -> List[str]: 

178 return cls.q1_all_fieldnames() + ["q2", "q3"] 

179 

180 @staticmethod 

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

182 _ = req.gettext 

183 return _("Routine Assessment of Patient Index Data") 

184 

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

186 return self.standard_task_summary_fields() + [ 

187 SummaryElement( 

188 name="rapid3", 

189 coltype=Float(), 

190 value=self.rapid3(), 

191 comment="RAPID3", 

192 ) 

193 ] 

194 

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

196 axis_min = self.MINIMUM - 0.5 

197 axis_max = self.MAXIMUM + 0.5 

198 axis_ticks = [ 

199 TrackerAxisTick(n, str(n)) for n in range(0, int(axis_max) + 1, 2) 

200 ] 

201 

202 horizontal_lines: list[float] = [ 

203 self.MAXIMUM, 

204 self.MODERATE_SEVERITY_MAX, 

205 self.LOW_SEVERITY_MAX, 

206 self.NEAR_REMISSION_MAX, 

207 self.MINIMUM, 

208 ] 

209 

210 horizontal_labels = [ 

211 TrackerLabel( 

212 self.MODERATE_SEVERITY_MAX + 8.0, 

213 self.wxstring(req, "high_severity"), 

214 ), 

215 TrackerLabel( 

216 self.MODERATE_SEVERITY_MAX - 3.0, 

217 self.wxstring(req, "moderate_severity"), 

218 ), 

219 TrackerLabel( 

220 self.LOW_SEVERITY_MAX - 1.5, self.wxstring(req, "low_severity") 

221 ), 

222 TrackerLabel( 

223 self.NEAR_REMISSION_MAX - 1.5, 

224 self.wxstring(req, "near_remission"), 

225 ), 

226 ] 

227 

228 return [ 

229 TrackerInfo( 

230 value=self.rapid3(), 

231 plot_label="RAPID3", 

232 axis_label="RAPID3", 

233 axis_min=axis_min, 

234 axis_max=axis_max, 

235 axis_ticks=axis_ticks, 

236 horizontal_lines=horizontal_lines, 

237 horizontal_labels=horizontal_labels, 

238 ) 

239 ] 

240 

241 def rapid3(self) -> Optional[float]: 

242 if not self.is_complete(): 

243 return None 

244 

245 return ( 

246 self.functional_status() 

247 + self.pain_tolerance() 

248 + self.global_estimate() 

249 ) 

250 

251 def functional_status(self) -> float: 

252 return round(self.sum_fields(self.q1_scoring_fieldnames()) / 3, 1) 

253 

254 def pain_tolerance(self) -> float: 

255 # noinspection PyUnresolvedReferences 

256 return self.q2 # type: ignore[attr-defined] 

257 

258 def global_estimate(self) -> float: 

259 # noinspection PyUnresolvedReferences 

260 return self.q3 # type: ignore[attr-defined] 

261 

262 def is_complete(self) -> bool: 

263 if self.any_fields_none(self.all_fieldnames()): 

264 return False 

265 

266 if not self.field_contents_valid(): 

267 return False 

268 

269 return True 

270 

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

272 rows = tr_span_col( 

273 f'{self.wxstring(req, "q1")}<br>' f'{self.wxstring(req, "q1sub")}', 

274 cols=2, 

275 ) 

276 for letter in self.q1_all_letters(): 

277 q_fieldname = f"q1{letter}" 

278 

279 qtext = self.wxstring(req, q_fieldname) 

280 score = getattr(self, q_fieldname) 

281 

282 description = "?" 

283 if score is not None: 

284 description = self.wxstring(req, f"q1_option{score}") 

285 

286 rows += tr_qa(qtext, f"{score} — {description}") 

287 

288 for q_num in (2, 3): 

289 q_fieldname = f"q{q_num}" 

290 qtext = self.wxstring(req, q_fieldname) 

291 min_text = self.wxstring(req, f"{q_fieldname}_min") 

292 max_text = self.wxstring(req, f"{q_fieldname}_max") 

293 qtext += f" <i>(0.0 = {min_text}, 10.0 = {max_text})</i>" 

294 score = getattr(self, q_fieldname) 

295 

296 rows += tr_qa(qtext, score) 

297 

298 rapid3 = ws.number_to_dp(self.rapid3(), 1, default="?") 

299 

300 html = """ 

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

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

303 {tr_is_complete} 

304 {rapid3} 

305 </table> 

306 </div> 

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

308 <tr> 

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

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

311 </tr> 

312 {rows} 

313 </table> 

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

315 [1] Add scores for questions 1a–1j (ten questions each scored 

316 0–3), divide by 3, and round to 1 decimal place (giving a 

317 score for Q1 in the range 0–10). Then add this to scores 

318 for Q2 and Q3 (each scored 0–10) to get the RAPID3 

319 cumulative score (0–30), as shown here. 

320 Interpretation of the cumulative score: 

321 ≤3: Near remission (NR). 

322 3.1–6: Low severity (LS). 

323 6.1–12: Moderate severity (MS). 

324 >12: High severity (HS). 

325 

326 Note also: questions 1k–1m are each scored 0, 1.1, 2.2, or 

327 3.3 in the PDF/paper version of the RAPID3, but do not 

328 contribute to the formal score. They are shown here with 

329 values 0, 1, 2, 3 (and, similarly, do not contribute to 

330 the overall score). 

331 

332 </div> 

333 """.format( 

334 CssClass=CssClass, 

335 tr_is_complete=self.get_is_complete_tr(req), 

336 rapid3=tr( 

337 self.wxstring(req, "rapid3") + " (0–30) <sup>[1]</sup>", 

338 "{} ({})".format(answer(rapid3), self.disease_severity(req)), 

339 ), 

340 rows=rows, 

341 ) 

342 return html 

343 

344 def disease_severity(self, req: CamcopsRequest) -> str: 

345 rapid3 = self.rapid3() 

346 

347 if rapid3 is None: 

348 return self.wxstring(req, "n_a") 

349 

350 if rapid3 <= self.NEAR_REMISSION_MAX: 

351 return self.wxstring(req, "near_remission") 

352 

353 if rapid3 <= self.LOW_SEVERITY_MAX: 

354 return self.wxstring(req, "low_severity") 

355 

356 if rapid3 <= self.MODERATE_SEVERITY_MAX: 

357 return self.wxstring(req, "moderate_severity") 

358 

359 return self.wxstring(req, "high_severity")