Coverage for tasks/das28.py: 47%

131 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2022-11-08 23:14 +0000

1#!/usr/bin/env python 

2 

3""" 

4camcops_server/tasks/das28.py 

5 

6=============================================================================== 

7 

8 Copyright (C) 2012, University of Cambridge, Department of Psychiatry. 

9 Created by Rudolf Cardinal (rnc1001@cam.ac.uk). 

10 

11 This file is part of CamCOPS. 

12 

13 CamCOPS is free software: you can redistribute it and/or modify 

14 it under the terms of the GNU General Public License as published by 

15 the Free Software Foundation, either version 3 of the License, or 

16 (at your option) any later version. 

17 

18 CamCOPS is distributed in the hope that it will be useful, 

19 but WITHOUT ANY WARRANTY; without even the implied warranty of 

20 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

21 GNU General Public License for more details. 

22 

23 You should have received a copy of the GNU General Public License 

24 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>. 

25 

26=============================================================================== 

27 

28**Disease Activity Score-28 (DAS28) task.** 

29 

30""" 

31 

32import math 

33from typing import Any, Dict, List, Optional, Type, Tuple 

34 

35from camcops_server.cc_modules.cc_constants import CssClass 

36from camcops_server.cc_modules.cc_html import ( 

37 answer, 

38 table_row, 

39 th, 

40 td, 

41 tr, 

42 tr_qa, 

43) 

44from camcops_server.cc_modules.cc_request import CamcopsRequest 

45from camcops_server.cc_modules.cc_sqla_coltypes import ( 

46 BoolColumn, 

47 CamcopsColumn, 

48 PermittedValueChecker, 

49 SummaryCategoryColType, 

50) 

51from camcops_server.cc_modules.cc_summaryelement import SummaryElement 

52from camcops_server.cc_modules.cc_task import ( 

53 Task, 

54 TaskHasPatientMixin, 

55 TaskHasClinicianMixin, 

56) 

57from camcops_server.cc_modules.cc_trackerhelpers import ( 

58 TrackerAxisTick, 

59 TrackerInfo, 

60 TrackerLabel, 

61) 

62 

63import cardinal_pythonlib.rnc_web as ws 

64from sqlalchemy import Column, Float, Integer 

65from sqlalchemy.ext.declarative import DeclarativeMeta 

66 

67 

68class Das28Metaclass(DeclarativeMeta): 

69 # noinspection PyInitNewSignature 

70 def __init__( 

71 cls: Type["Das28"], 

72 name: str, 

73 bases: Tuple[Type, ...], 

74 classdict: Dict[str, Any], 

75 ) -> None: 

76 for field_name in cls.get_joint_field_names(): 

77 setattr( 

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

79 ) 

80 

81 setattr( 

82 cls, 

83 "vas", 

84 CamcopsColumn( 

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 super().__init__(name, bases, classdict) 

99 

100 

101class Das28( 

102 TaskHasPatientMixin, TaskHasClinicianMixin, Task, metaclass=Das28Metaclass 

103): 

104 __tablename__ = "das28" 

105 shortname = "DAS28" 

106 provides_trackers = True 

107 

108 JOINTS = ( 

109 ["shoulder", "elbow", "wrist"] 

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

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

112 + ["knee"] 

113 ) 

114 

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

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

117 

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

119 

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

121 CRP_REMISSION_LOW_CUTOFF = 2.4 

122 CRP_LOW_MODERATE_CUTOFF = 2.9 

123 CRP_MODERATE_HIGH_CUTOFF = 4.6 

124 

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

126 # (has same cutoffs for CRP) 

127 ESR_REMISSION_LOW_CUTOFF = 2.6 

128 ESR_LOW_MODERATE_CUTOFF = 3.2 

129 ESR_MODERATE_HIGH_CUTOFF = 5.1 

130 

131 @classmethod 

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

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

134 

135 @classmethod 

136 def get_joint_field_names(cls) -> List: 

137 field_names = [] 

138 

139 for joint in cls.JOINTS: 

140 for side in cls.SIDES: 

141 for state in cls.STATES: 

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

143 

144 return field_names 

145 

146 @classmethod 

147 def get_all_field_names(cls) -> List: 

148 return cls.get_joint_field_names() + cls.OTHER_FIELD_NAMES 

149 

150 @staticmethod 

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

152 _ = req.gettext 

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

154 

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

156 return self.standard_task_summary_fields() + [ 

157 SummaryElement( 

158 name="das28_crp", 

159 coltype=Float(), 

160 value=self.das28_crp(), 

161 comment="DAS28-CRP", 

162 ), 

163 SummaryElement( 

164 name="activity_state_crp", 

165 coltype=SummaryCategoryColType, 

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

167 comment="Activity state (CRP)", 

168 ), 

169 SummaryElement( 

170 name="das28_esr", 

171 coltype=Float(), 

172 value=self.das28_esr(), 

173 comment="DAS28-ESR", 

174 ), 

175 SummaryElement( 

176 name="activity_state_esr", 

177 coltype=SummaryCategoryColType, 

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

179 comment="Activity state (ESR)", 

180 ), 

181 ] 

182 

183 def is_complete(self) -> bool: 

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

185 return False 

186 

187 # noinspection PyUnresolvedReferences 

188 if self.crp is None and self.esr is None: 

189 return False 

190 

191 if not self.field_contents_valid(): 

192 return False 

193 

194 return True 

195 

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

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

198 

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

200 axis_min = -0.5 

201 axis_max = 9.0 

202 axis_ticks = [ 

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

204 ] 

205 

206 horizontal_lines = [ 

207 self.CRP_MODERATE_HIGH_CUTOFF, 

208 self.CRP_LOW_MODERATE_CUTOFF, 

209 self.CRP_REMISSION_LOW_CUTOFF, 

210 0, 

211 ] 

212 

213 horizontal_labels = [ 

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

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

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

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

218 ] 

219 

220 return TrackerInfo( 

221 value=self.das28_crp(), 

222 plot_label="DAS28-CRP", 

223 axis_label="DAS28-CRP", 

224 axis_min=axis_min, 

225 axis_max=axis_max, 

226 axis_ticks=axis_ticks, 

227 horizontal_lines=horizontal_lines, 

228 horizontal_labels=horizontal_labels, 

229 ) 

230 

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

232 axis_min = -0.5 

233 axis_max = 10.0 

234 axis_ticks = [ 

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

236 ] 

237 

238 horizontal_lines = [ 

239 self.ESR_MODERATE_HIGH_CUTOFF, 

240 self.ESR_LOW_MODERATE_CUTOFF, 

241 self.ESR_REMISSION_LOW_CUTOFF, 

242 0, 

243 ] 

244 

245 horizontal_labels = [ 

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

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

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

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

250 ] 

251 

252 return TrackerInfo( 

253 value=self.das28_esr(), 

254 plot_label="DAS28-ESR", 

255 axis_label="DAS28-ESR", 

256 axis_min=axis_min, 

257 axis_max=axis_max, 

258 axis_ticks=axis_ticks, 

259 horizontal_lines=horizontal_lines, 

260 horizontal_labels=horizontal_labels, 

261 ) 

262 

263 def swollen_joint_count(self): 

264 return self.count_booleans( 

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

266 ) 

267 

268 def tender_joint_count(self): 

269 return self.count_booleans( 

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

271 ) 

272 

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

274 # noinspection PyUnresolvedReferences 

275 if self.crp is None or self.vas is None: 

276 return None 

277 

278 # noinspection PyUnresolvedReferences 

279 return ( 

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

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

282 + 0.36 * math.log(self.crp + 1) 

283 + 0.014 * self.vas 

284 + 0.96 

285 ) 

286 

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

288 # noinspection PyUnresolvedReferences 

289 if self.esr is None or self.vas is None: 

290 return None 

291 

292 # noinspection PyUnresolvedReferences 

293 return ( 

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

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

296 + 0.70 * math.log(self.esr) 

297 + 0.014 * self.vas 

298 ) 

299 

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

301 if measurement is None: 

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

303 

304 if measurement < self.CRP_REMISSION_LOW_CUTOFF: 

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

306 

307 if measurement < self.CRP_LOW_MODERATE_CUTOFF: 

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

309 

310 if measurement > self.CRP_MODERATE_HIGH_CUTOFF: 

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

312 

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

314 

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

316 if measurement is None: 

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

318 

319 if measurement < self.ESR_REMISSION_LOW_CUTOFF: 

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

321 

322 if measurement < self.ESR_LOW_MODERATE_CUTOFF: 

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

324 

325 if measurement > self.ESR_MODERATE_HIGH_CUTOFF: 

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

327 

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

329 

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

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

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

333 

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

335 

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

337 

338 for joint in self.JOINTS: 

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

340 for side in self.SIDES: 

341 for state in self.STATES: 

342 value = "?" 

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

344 if fval is not None: 

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

346 

347 cells.append(td(value)) 

348 

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

350 

351 das28_crp = self.das28_crp() 

352 das28_esr = self.das28_esr() 

353 

354 other_rows = "".join( 

355 [ 

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

357 for f in self.OTHER_FIELD_NAMES 

358 ] 

359 ) 

360 

361 html = """ 

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

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

364 {tr_is_complete} 

365 {das28_crp} 

366 {das28_esr} 

367 {swollen_joint_count} 

368 {tender_joint_count} 

369 </table> 

370 </div> 

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

372 {joint_rows} 

373 </table> 

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

375 {other_rows} 

376 </table> 

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

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

379 0.28 × √(swollen joint count) + 

380 0.36 × ln(CRP + 1) + 

381 0.014 x VAS disease activity + 

382 0.96. 

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

384 Cutoffs: 

385 &lt;2.4 remission, 

386 &lt;2.9 low disease activity, 

387 2.9–4.6 moderate disease activity, 

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

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

390 0.28 × √(swollen joint count) + 

391 0.70 × ln(ESR) + 

392 0.014 x VAS disease activity. 

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

394 Cutoffs: 

395 &lt;2.6 remission, 

396 &lt;3.2 low disease activity, 

397 3.2–5.1 moderate disease activity, 

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

399 </div> 

400 """.format( 

401 CssClass=CssClass, 

402 tr_is_complete=self.get_is_complete_tr(req), 

403 das28_crp=tr( 

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

405 "{} ({})".format( 

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

407 self.activity_state_crp(req, das28_crp), 

408 ), 

409 ), 

410 das28_esr=tr( 

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

412 "{} ({})".format( 

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

414 self.activity_state_esr(req, das28_esr), 

415 ), 

416 ), 

417 swollen_joint_count=tr( 

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

419 answer(self.swollen_joint_count()), 

420 ), 

421 tender_joint_count=tr( 

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

423 answer(self.tender_joint_count()), 

424 ), 

425 joint_rows=joint_rows, 

426 other_rows=other_rows, 

427 ) 

428 return html