Coverage for tasks/asdas.py: 44%

99 statements  

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

1""" 

2camcops_server/tasks/asdas.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**Ankylosing Spondylitis Disease Activity Score (ASDAS) task.** 

27 

28""" 

29 

30import math 

31from typing import Any, List, Optional, Type 

32 

33from camcops_server.cc_modules.cc_constants import CssClass 

34from camcops_server.cc_modules.cc_db import add_multiple_columns 

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

36from camcops_server.cc_modules.cc_request import CamcopsRequest 

37from camcops_server.cc_modules.cc_sqla_coltypes import SummaryCategoryColType 

38from camcops_server.cc_modules.cc_summaryelement import SummaryElement 

39from camcops_server.cc_modules.cc_task import TaskHasPatientMixin, Task 

40from camcops_server.cc_modules.cc_trackerhelpers import ( 

41 TrackerAxisTick, 

42 TrackerInfo, 

43 TrackerLabel, 

44) 

45 

46import cardinal_pythonlib.rnc_web as ws 

47from cardinal_pythonlib.stringfunc import strseq 

48from sqlalchemy import Column, Float 

49 

50 

51class Asdas( # type: ignore[misc] 

52 TaskHasPatientMixin, 

53 Task, 

54): 

55 __tablename__ = "asdas" 

56 shortname = "ASDAS" 

57 provides_trackers = True 

58 

59 N_SCALE_QUESTIONS = 4 

60 MAX_SCORE_SCALE = 10 

61 N_QUESTIONS = 6 

62 

63 @classmethod 

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

65 

66 add_multiple_columns( 

67 cls, 

68 "q", 

69 1, 

70 cls.N_SCALE_QUESTIONS, 

71 minimum=0, 

72 maximum=10, 

73 comment_fmt="Q{n} - {s}", 

74 comment_strings=[ 

75 "back pain 0-10 (None - very severe)", 

76 "morning stiffness 0-10 (None - 2+ hours)", 

77 "patient global 0-10 (Not active - very active)", 

78 "peripheral pain 0-10 (None - very severe)", 

79 ], 

80 ) 

81 

82 setattr( 

83 cls, 

84 cls.CRP_FIELD_NAME, 

85 Column(cls.CRP_FIELD_NAME, Float, comment="CRP (mg/L)"), 

86 ) 

87 

88 setattr( 

89 cls, 

90 cls.ESR_FIELD_NAME, 

91 Column(cls.ESR_FIELD_NAME, Float, comment="ESR (mm/h)"), 

92 ) 

93 

94 SCALE_FIELD_NAMES = strseq("q", 1, N_SCALE_QUESTIONS) 

95 CRP_FIELD_NAME = "q5" 

96 ESR_FIELD_NAME = "q6" 

97 

98 INACTIVE_MODERATE_CUTOFF = 1.3 

99 MODERATE_HIGH_CUTOFF = 2.1 

100 HIGH_VERY_HIGH_CUTOFF = 3.5 

101 

102 @staticmethod 

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

104 _ = req.gettext 

105 return _("Ankylosing Spondylitis Disease Activity Score") 

106 

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

108 return self.standard_task_summary_fields() + [ 

109 SummaryElement( 

110 name="asdas_crp", 

111 coltype=Float(), 

112 value=self.asdas_crp(), 

113 comment="ASDAS-CRP", 

114 ), 

115 SummaryElement( 

116 name="activity_state_crp", 

117 coltype=SummaryCategoryColType, 

118 value=self.activity_state(req, self.asdas_crp()), 

119 comment="Activity state (CRP)", 

120 ), 

121 SummaryElement( 

122 name="asdas_esr", 

123 coltype=Float(), 

124 value=self.asdas_esr(), 

125 comment="ASDAS-ESR", 

126 ), 

127 SummaryElement( 

128 name="activity_state_esr", 

129 coltype=SummaryCategoryColType, 

130 value=self.activity_state(req, self.asdas_esr()), 

131 comment="Activity state (ESR)", 

132 ), 

133 ] 

134 

135 def is_complete(self) -> bool: 

136 if self.any_fields_none(self.SCALE_FIELD_NAMES): 

137 return False 

138 

139 crp = getattr(self, self.CRP_FIELD_NAME) 

140 esr = getattr(self, self.ESR_FIELD_NAME) 

141 

142 if crp is None and esr is None: 

143 return False 

144 

145 if not self.field_contents_valid(): 

146 return False 

147 

148 return True 

149 

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

151 axis_min = -0.5 

152 axis_max = 7.5 

153 axis_ticks = [ 

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

155 ] 

156 

157 horizontal_lines = [ 

158 self.HIGH_VERY_HIGH_CUTOFF, 

159 self.MODERATE_HIGH_CUTOFF, 

160 self.INACTIVE_MODERATE_CUTOFF, 

161 0, 

162 ] 

163 

164 horizontal_labels = [ 

165 TrackerLabel(5.25, self.wxstring(req, "very_high")), 

166 TrackerLabel(2.8, self.wxstring(req, "high")), 

167 TrackerLabel(1.7, self.wxstring(req, "moderate")), 

168 TrackerLabel(0.65, self.wxstring(req, "inactive")), 

169 ] 

170 

171 return [ 

172 TrackerInfo( 

173 value=self.asdas_crp(), 

174 plot_label="ASDAS-CRP", 

175 axis_label="ASDAS-CRP", 

176 axis_min=axis_min, 

177 axis_max=axis_max, 

178 axis_ticks=axis_ticks, 

179 horizontal_lines=horizontal_lines, 

180 horizontal_labels=horizontal_labels, 

181 ), 

182 TrackerInfo( 

183 value=self.asdas_esr(), 

184 plot_label="ASDAS-ESR", 

185 axis_label="ASDAS-ESR", 

186 axis_min=axis_min, 

187 axis_max=axis_max, 

188 axis_ticks=axis_ticks, 

189 horizontal_lines=horizontal_lines, 

190 horizontal_labels=horizontal_labels, 

191 ), 

192 ] 

193 

194 def back_pain(self) -> float: 

195 return getattr(self, "q1") 

196 

197 def morning_stiffness(self) -> float: 

198 return getattr(self, "q2") 

199 

200 def patient_global(self) -> float: 

201 return getattr(self, "q3") 

202 

203 def peripheral_pain(self) -> float: 

204 return getattr(self, "q4") 

205 

206 def asdas_crp(self) -> Optional[float]: 

207 crp = getattr(self, self.CRP_FIELD_NAME) 

208 

209 if crp is None: 

210 return None 

211 

212 crp = max(crp, 2.0) 

213 

214 return ( 

215 0.12 * self.back_pain() 

216 + 0.06 * self.morning_stiffness() 

217 + 0.11 * self.patient_global() 

218 + 0.07 * self.peripheral_pain() 

219 + 0.58 * math.log(crp + 1) 

220 ) 

221 

222 def asdas_esr(self) -> Optional[float]: 

223 esr = getattr(self, self.ESR_FIELD_NAME) 

224 if esr is None: 

225 return None 

226 

227 return ( 

228 0.08 * self.back_pain() 

229 + 0.07 * self.morning_stiffness() 

230 + 0.11 * self.patient_global() 

231 + 0.09 * self.peripheral_pain() 

232 + 0.29 * math.sqrt(esr) 

233 ) 

234 

235 def activity_state(self, req: CamcopsRequest, measurement: Any) -> str: 

236 if measurement is None: 

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

238 

239 if measurement < self.INACTIVE_MODERATE_CUTOFF: 

240 return self.wxstring(req, "inactive") 

241 

242 if measurement < self.MODERATE_HIGH_CUTOFF: 

243 return self.wxstring(req, "moderate") 

244 

245 if measurement > self.HIGH_VERY_HIGH_CUTOFF: 

246 return self.wxstring(req, "very_high") 

247 

248 return self.wxstring(req, "high") 

249 

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

251 rows = "" 

252 for q_num in range(1, self.N_QUESTIONS + 1): 

253 q_field = "q" + str(q_num) 

254 qtext = self.wxstring(req, q_field) 

255 if q_num <= 4: # not for ESR, CRP 

256 min_text = self.wxstring(req, q_field + "_min") 

257 max_text = self.wxstring(req, q_field + "_max") 

258 qtext += f" <i>(0 = {min_text}, 10 = {max_text})</i>" 

259 question_cell = f"{q_num}. {qtext}" 

260 score = getattr(self, q_field) 

261 

262 rows += tr_qa(question_cell, score) 

263 

264 asdas_crp = ws.number_to_dp(self.asdas_crp(), 2, default="?") 

265 asdas_esr = ws.number_to_dp(self.asdas_esr(), 2, default="?") 

266 

267 html = """ 

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

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

270 {tr_is_complete} 

271 {asdas_crp} 

272 {asdas_esr} 

273 </table> 

274 </div> 

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

276 <tr> 

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

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

279 </tr> 

280 {rows} 

281 </table> 

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

283 [1] &lt;1.3 Inactive disease, 

284 &lt;2.1 Moderate disease activity, 

285 2.1-3.5 High disease activity, 

286 &gt;3.5 Very high disease activity.<br> 

287 [2] 0.12 × back pain + 

288 0.06 × duration of morning stiffness + 

289 0.11 × patient global + 

290 0.07 × peripheral pain + 

291 0.58 × ln(CRP + 1). 

292 CRP units: mg/L. When CRP&lt;2mg/L, use 2mg/L to calculate 

293 ASDAS-CRP.<br> 

294 [3] 0.08 x back pain + 

295 0.07 x duration of morning stiffness + 

296 0.11 x patient global + 

297 0.09 x peripheral pain + 

298 0.29 x √(ESR). 

299 ESR units: mm/h. 

300 </div> 

301 """.format( 

302 CssClass=CssClass, 

303 tr_is_complete=self.get_is_complete_tr(req), 

304 asdas_crp=tr( 

305 self.wxstring(req, "asdas_crp") + " <sup>[1][2]</sup>", 

306 "{} ({})".format( 

307 answer(asdas_crp), 

308 self.activity_state(req, self.asdas_crp()), 

309 ), 

310 ), 

311 asdas_esr=tr( 

312 self.wxstring(req, "asdas_esr") + " <sup>[1][3]</sup>", 

313 "{} ({})".format( 

314 answer(asdas_esr), 

315 self.activity_state(req, self.asdas_esr()), 

316 ), 

317 ), 

318 rows=rows, 

319 ) 

320 return html