Coverage for tasks/das28.py: 46%

128 statements  

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

1""" 

2camcops_server/tasks/das28.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**Disease Activity Score-28 (DAS28) 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_html import ( 

35 answer, 

36 table_row, 

37 th, 

38 td, 

39 tr, 

40 tr_qa, 

41) 

42from camcops_server.cc_modules.cc_request import CamcopsRequest 

43from camcops_server.cc_modules.cc_sqla_coltypes import ( 

44 bool_column, 

45 camcops_column, 

46 PermittedValueChecker, 

47 SummaryCategoryColType, 

48) 

49from camcops_server.cc_modules.cc_summaryelement import SummaryElement 

50from camcops_server.cc_modules.cc_task import ( 

51 Task, 

52 TaskHasPatientMixin, 

53 TaskHasClinicianMixin, 

54) 

55from camcops_server.cc_modules.cc_trackerhelpers import ( 

56 TrackerAxisTick, 

57 TrackerInfo, 

58 TrackerLabel, 

59) 

60 

61import cardinal_pythonlib.rnc_web as ws 

62from sqlalchemy import Column, Float, Integer 

63 

64 

65class Das28( # type: ignore[misc] 

66 TaskHasPatientMixin, 

67 TaskHasClinicianMixin, 

68 Task, 

69): 

70 __tablename__ = "das28" 

71 shortname = "DAS28" 

72 provides_trackers = True 

73 

74 @classmethod 

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

76 for field_name in cls.get_joint_field_names(): 

77 setattr( 

78 cls, field_name, bool_column(field_name, comment="0 no, 1 yes") 

79 ) 

80 

81 setattr( 

82 cls, 

83 "vas", 

84 camcops_column( 

85 "vas", 

86 Integer, 

87 comment="Patient assessment of health (0-100mm)", 

88 permitted_value_checker=PermittedValueChecker( 

89 minimum=0, maximum=100 

90 ), 

91 ), 

92 ) 

93 

94 setattr(cls, "crp", Column("crp", Float, comment="CRP (0-300 mg/L)")) 

95 

96 setattr(cls, "esr", Column("esr", Float, comment="ESR (1-300 mm/h)")) 

97 

98 JOINTS = ( 

99 ["shoulder", "elbow", "wrist"] 

100 + [f"mcp_{n}" for n in range(1, 6)] 

101 + [f"pip_{n}" for n in range(1, 6)] 

102 + ["knee"] 

103 ) 

104 

105 SIDES = ["left", "right"] 

106 STATES = ["swollen", "tender"] 

107 

108 OTHER_FIELD_NAMES = ["vas", "crp", "esr"] 

109 

110 # as recommended by https://rmdopen.bmj.com/content/3/1/e000382 

111 CRP_REMISSION_LOW_CUTOFF = 2.4 

112 CRP_LOW_MODERATE_CUTOFF = 2.9 

113 CRP_MODERATE_HIGH_CUTOFF = 4.6 

114 

115 # https://onlinelibrary.wiley.com/doi/full/10.1002/acr.21649 

116 # (has same cutoffs for CRP) 

117 ESR_REMISSION_LOW_CUTOFF = 2.6 

118 ESR_LOW_MODERATE_CUTOFF = 3.2 

119 ESR_MODERATE_HIGH_CUTOFF = 5.1 

120 

121 @classmethod 

122 def field_name(cls, side: str, joint: str, state: str) -> str: 

123 return f"{side}_{joint}_{state}" 

124 

125 @classmethod 

126 def get_joint_field_names(cls) -> List: 

127 field_names = [] 

128 

129 for joint in cls.JOINTS: 

130 for side in cls.SIDES: 

131 for state in cls.STATES: 

132 field_names.append(cls.field_name(side, joint, state)) 

133 

134 return field_names 

135 

136 @classmethod 

137 def get_all_field_names(cls) -> List: 

138 return cls.get_joint_field_names() + cls.OTHER_FIELD_NAMES 

139 

140 @staticmethod 

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

142 _ = req.gettext 

143 return _("Disease Activity Score-28") 

144 

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

146 return self.standard_task_summary_fields() + [ 

147 SummaryElement( 

148 name="das28_crp", 

149 coltype=Float(), 

150 value=self.das28_crp(), 

151 comment="DAS28-CRP", 

152 ), 

153 SummaryElement( 

154 name="activity_state_crp", 

155 coltype=SummaryCategoryColType, 

156 value=self.activity_state_crp(req, self.das28_crp()), 

157 comment="Activity state (CRP)", 

158 ), 

159 SummaryElement( 

160 name="das28_esr", 

161 coltype=Float(), 

162 value=self.das28_esr(), 

163 comment="DAS28-ESR", 

164 ), 

165 SummaryElement( 

166 name="activity_state_esr", 

167 coltype=SummaryCategoryColType, 

168 value=self.activity_state_esr(req, self.das28_esr()), 

169 comment="Activity state (ESR)", 

170 ), 

171 ] 

172 

173 def is_complete(self) -> bool: 

174 if self.any_fields_none(self.get_joint_field_names() + ["vas"]): 

175 return False 

176 

177 # noinspection PyUnresolvedReferences 

178 if self.crp is None and self.esr is None: # type: ignore[attr-defined] 

179 return False 

180 

181 if not self.field_contents_valid(): 

182 return False 

183 

184 return True 

185 

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

187 return [self.get_crp_tracker(req), self.get_esr_tracker(req)] 

188 

189 def get_crp_tracker(self, req: CamcopsRequest) -> TrackerInfo: 

190 axis_min = -0.5 

191 axis_max = 9.0 

192 axis_ticks = [ 

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

194 ] 

195 

196 horizontal_lines = [ 

197 self.CRP_MODERATE_HIGH_CUTOFF, 

198 self.CRP_LOW_MODERATE_CUTOFF, 

199 self.CRP_REMISSION_LOW_CUTOFF, 

200 0, 

201 ] 

202 

203 horizontal_labels = [ 

204 TrackerLabel(6.8, self.wxstring(req, "high")), 

205 TrackerLabel(3.75, self.wxstring(req, "moderate")), 

206 TrackerLabel(2.65, self.wxstring(req, "low")), 

207 TrackerLabel(1.2, self.wxstring(req, "remission")), 

208 ] 

209 

210 return TrackerInfo( 

211 value=self.das28_crp(), 

212 plot_label="DAS28-CRP", 

213 axis_label="DAS28-CRP", 

214 axis_min=axis_min, 

215 axis_max=axis_max, 

216 axis_ticks=axis_ticks, 

217 horizontal_lines=horizontal_lines, 

218 horizontal_labels=horizontal_labels, 

219 ) 

220 

221 def get_esr_tracker(self, req: CamcopsRequest) -> TrackerInfo: 

222 axis_min = -0.5 

223 axis_max = 10.0 

224 axis_ticks = [ 

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

226 ] 

227 

228 horizontal_lines = [ 

229 self.ESR_MODERATE_HIGH_CUTOFF, 

230 self.ESR_LOW_MODERATE_CUTOFF, 

231 self.ESR_REMISSION_LOW_CUTOFF, 

232 0, 

233 ] 

234 

235 horizontal_labels = [ 

236 TrackerLabel(7.55, self.wxstring(req, "high")), 

237 TrackerLabel(4.15, self.wxstring(req, "moderate")), 

238 TrackerLabel(2.9, self.wxstring(req, "low")), 

239 TrackerLabel(1.3, self.wxstring(req, "remission")), 

240 ] 

241 

242 return TrackerInfo( 

243 value=self.das28_esr(), 

244 plot_label="DAS28-ESR", 

245 axis_label="DAS28-ESR", 

246 axis_min=axis_min, 

247 axis_max=axis_max, 

248 axis_ticks=axis_ticks, 

249 horizontal_lines=horizontal_lines, 

250 horizontal_labels=horizontal_labels, 

251 ) 

252 

253 def swollen_joint_count(self) -> int: 

254 return self.count_booleans( 

255 [n for n in self.get_joint_field_names() if n.endswith("swollen")] 

256 ) 

257 

258 def tender_joint_count(self) -> int: 

259 return self.count_booleans( 

260 [n for n in self.get_joint_field_names() if n.endswith("tender")] 

261 ) 

262 

263 def das28_crp(self) -> Optional[float]: 

264 # noinspection PyUnresolvedReferences 

265 if self.crp is None or self.vas is None: # type: ignore[attr-defined] 

266 return None 

267 

268 # noinspection PyUnresolvedReferences 

269 return ( 

270 0.56 * math.sqrt(self.tender_joint_count()) 

271 + 0.28 * math.sqrt(self.swollen_joint_count()) 

272 + 0.36 * math.log(self.crp + 1) # type: ignore[attr-defined] 

273 + 0.014 * self.vas # type: ignore[attr-defined] 

274 + 0.96 

275 ) 

276 

277 def das28_esr(self) -> Optional[float]: 

278 # noinspection PyUnresolvedReferences 

279 if self.esr is None or self.vas is None: # type: ignore[attr-defined] 

280 return None 

281 

282 # noinspection PyUnresolvedReferences 

283 return ( 

284 0.56 * math.sqrt(self.tender_joint_count()) 

285 + 0.28 * math.sqrt(self.swollen_joint_count()) 

286 + 0.70 * math.log(self.esr) # type: ignore[attr-defined] 

287 + 0.014 * self.vas # type: ignore[attr-defined] 

288 ) 

289 

290 def activity_state_crp(self, req: CamcopsRequest, measurement: Any) -> str: 

291 if measurement is None: 

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

293 

294 if measurement < self.CRP_REMISSION_LOW_CUTOFF: 

295 return self.wxstring(req, "remission") 

296 

297 if measurement < self.CRP_LOW_MODERATE_CUTOFF: 

298 return self.wxstring(req, "low") 

299 

300 if measurement > self.CRP_MODERATE_HIGH_CUTOFF: 

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

302 

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

304 

305 def activity_state_esr(self, req: CamcopsRequest, measurement: Any) -> str: 

306 if measurement is None: 

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

308 

309 if measurement < self.ESR_REMISSION_LOW_CUTOFF: 

310 return self.wxstring(req, "remission") 

311 

312 if measurement < self.ESR_LOW_MODERATE_CUTOFF: 

313 return self.wxstring(req, "low") 

314 

315 if measurement > self.ESR_MODERATE_HIGH_CUTOFF: 

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

317 

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

319 

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

321 sides_strings = [self.wxstring(req, s) for s in self.SIDES] 

322 states_strings = [self.wxstring(req, s) for s in self.STATES] 

323 

324 joint_rows = table_row([""] + sides_strings, colspans=[1, 2, 2]) 

325 

326 joint_rows += table_row([""] + states_strings * 2) 

327 

328 for joint in self.JOINTS: 

329 cells = [th(self.wxstring(req, joint))] 

330 for side in self.SIDES: 

331 for state in self.STATES: 

332 value = "?" 

333 fval = getattr(self, self.field_name(side, joint, state)) 

334 if fval is not None: 

335 value = "✓" if fval else "×" 

336 

337 cells.append(td(value)) 

338 

339 joint_rows += tr(*cells, literal=True) 

340 

341 das28_crp = self.das28_crp() 

342 das28_esr = self.das28_esr() 

343 

344 other_rows = "".join( 

345 [ 

346 tr_qa(self.wxstring(req, f), getattr(self, f)) 

347 for f in self.OTHER_FIELD_NAMES 

348 ] 

349 ) 

350 

351 html = """ 

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

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

354 {tr_is_complete} 

355 {das28_crp} 

356 {das28_esr} 

357 {swollen_joint_count} 

358 {tender_joint_count} 

359 </table> 

360 </div> 

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

362 {joint_rows} 

363 </table> 

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

365 {other_rows} 

366 </table> 

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

368 [1] 0.56 × √(tender joint count) + 

369 0.28 × √(swollen joint count) + 

370 0.36 × ln([CRP in mg/L] + 1) + 

371 0.014 x VAS disease activity + 

372 0.96. 

373 CRP 0–300 mg/L. VAS: 0–100mm.<br> 

374 Cutoffs: 

375 &lt;2.4 remission, 

376 &lt;2.9 low disease activity, 

377 2.9–4.6 moderate disease activity, 

378 &gt;4.6 high disease activity.<br> 

379 [2] 0.56 × √(tender joint count) + 

380 0.28 × √(swollen joint count) + 

381 0.70 × ln(ESR in mm/h) + 

382 0.014 x VAS disease activity. 

383 ESR 1–300 mm/h. VAS: 0–100mm.<br> 

384 Cutoffs: 

385 &lt;2.6 remission, 

386 &lt;3.2 low disease activity, 

387 3.2–5.1 moderate disease activity, 

388 &gt;5.1 high disease activity.<br> 

389 </div> 

390 """.format( 

391 CssClass=CssClass, 

392 tr_is_complete=self.get_is_complete_tr(req), 

393 das28_crp=tr( 

394 self.wxstring(req, "das28_crp") + " <sup>[1]</sup>", 

395 "{} ({})".format( 

396 answer(ws.number_to_dp(das28_crp, 2, default="?")), 

397 self.activity_state_crp(req, das28_crp), 

398 ), 

399 ), 

400 das28_esr=tr( 

401 self.wxstring(req, "das28_esr") + " <sup>[2]</sup>", 

402 "{} ({})".format( 

403 answer(ws.number_to_dp(das28_esr, 2, default="?")), 

404 self.activity_state_esr(req, das28_esr), 

405 ), 

406 ), 

407 swollen_joint_count=tr( 

408 self.wxstring(req, "swollen_count"), 

409 answer(self.swollen_joint_count()), 

410 ), 

411 tender_joint_count=tr( 

412 self.wxstring(req, "tender_count"), 

413 answer(self.tender_joint_count()), 

414 ), 

415 joint_rows=joint_rows, 

416 other_rows=other_rows, 

417 ) 

418 return html