Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

1#!/usr/bin/env python 

2 

3""" 

4camcops_server/tasks/das28.py 

5 

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

7 

8 Copyright (C) 2012-2020 Rudolf Cardinal (rudolf@pobox.com). 

9 

10 This file is part of CamCOPS. 

11 

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

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

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

15 (at your option) any later version. 

16 

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

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

19 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

20 GNU General Public License for more details. 

21 

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

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

24 

25=============================================================================== 

26 

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

28 

29""" 

30 

31import math 

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

33 

34from camcops_server.cc_modules.cc_constants import CssClass 

35from camcops_server.cc_modules.cc_html import ( 

36 answer, 

37 table_row, 

38 th, 

39 td, 

40 tr, 

41 tr_qa, 

42) 

43from camcops_server.cc_modules.cc_request import CamcopsRequest 

44from camcops_server.cc_modules.cc_sqla_coltypes import ( 

45 BoolColumn, 

46 CamcopsColumn, 

47 PermittedValueChecker, 

48 SummaryCategoryColType, 

49) 

50from camcops_server.cc_modules.cc_summaryelement import SummaryElement 

51from camcops_server.cc_modules.cc_task import ( 

52 Task, 

53 TaskHasPatientMixin, 

54 TaskHasClinicianMixin, 

55) 

56from camcops_server.cc_modules.cc_trackerhelpers import ( 

57 TrackerAxisTick, 

58 TrackerInfo, 

59 TrackerLabel, 

60) 

61 

62import cardinal_pythonlib.rnc_web as ws 

63from sqlalchemy import Column, Float, Integer 

64from sqlalchemy.ext.declarative import DeclarativeMeta 

65 

66 

67class Das28Metaclass(DeclarativeMeta): 

68 # noinspection PyInitNewSignature 

69 def __init__(cls: Type['Das28'], 

70 name: str, 

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

72 classdict: Dict[str, Any]) -> None: 

73 for field_name in cls.get_joint_field_names(): 

74 setattr(cls, field_name, 

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

76 

77 setattr( 

78 cls, 'vas', 

79 CamcopsColumn( 

80 'vas', 

81 Integer, 

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

83 permitted_value_checker=PermittedValueChecker( 

84 minimum=0, maximum=100) 

85 ) 

86 ) 

87 

88 setattr( 

89 cls, 'crp', 

90 Column('crp', Float, comment="CRP (0-300 mg/L)") 

91 ) 

92 

93 setattr( 

94 cls, 'esr', 

95 Column('esr', Float, comment="ESR (1-300 mm/h)") 

96 ) 

97 

98 super().__init__(name, bases, classdict) 

99 

100 

101class Das28(TaskHasPatientMixin, 

102 TaskHasClinicianMixin, 

103 Task, 

104 metaclass=Das28Metaclass): 

105 __tablename__ = "das28" 

106 shortname = "DAS28" 

107 provides_trackers = True 

108 

109 JOINTS = ( 

110 ['shoulder', 'elbow', 'wrist'] + 

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

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

113 ['knee'] 

114 ) 

115 

116 SIDES = ['left', 'right'] 

117 STATES = ['swollen', 'tender'] 

118 

119 OTHER_FIELD_NAMES = ['vas', 'crp', 'esr'] 

120 

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

122 CRP_REMISSION_LOW_CUTOFF = 2.4 

123 CRP_LOW_MODERATE_CUTOFF = 2.9 

124 CRP_MODERATE_HIGH_CUTOFF = 4.6 

125 

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

127 # (has same cutoffs for CRP) 

128 ESR_REMISSION_LOW_CUTOFF = 2.6 

129 ESR_LOW_MODERATE_CUTOFF = 3.2 

130 ESR_MODERATE_HIGH_CUTOFF = 5.1 

131 

132 @classmethod 

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

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

135 

136 @classmethod 

137 def get_joint_field_names(cls) -> List: 

138 field_names = [] 

139 

140 for joint in cls.JOINTS: 

141 for side in cls.SIDES: 

142 for state in cls.STATES: 

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

144 

145 return field_names 

146 

147 @classmethod 

148 def get_all_field_names(cls) -> List: 

149 return cls.get_joint_field_names() + cls.OTHER_FIELD_NAMES 

150 

151 @staticmethod 

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

153 _ = req.gettext 

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

155 

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

157 return self.standard_task_summary_fields() + [ 

158 SummaryElement( 

159 name="das28_crp", coltype=Float(), 

160 value=self.das28_crp(), 

161 comment="DAS28-CRP"), 

162 SummaryElement( 

163 name="activity_state_crp", coltype=SummaryCategoryColType, 

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

165 comment="Activity state (CRP)"), 

166 SummaryElement( 

167 name="das28_esr", coltype=Float(), 

168 value=self.das28_esr(), 

169 comment="DAS28-ESR"), 

170 SummaryElement( 

171 name="activity_state_esr", coltype=SummaryCategoryColType, 

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

173 comment="Activity state (ESR)"), 

174 ] 

175 

176 def is_complete(self) -> bool: 

177 if self.any_fields_none(self.get_joint_field_names() + ['vas']): 

178 return False 

179 

180 # noinspection PyUnresolvedReferences 

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

182 return False 

183 

184 if not self.field_contents_valid(): 

185 return False 

186 

187 return True 

188 

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

190 return [ 

191 self.get_crp_tracker(req), 

192 self.get_esr_tracker(req), 

193 ] 

194 

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

196 axis_min = -0.5 

197 axis_max = 9.0 

198 axis_ticks = [TrackerAxisTick(n, str(n)) 

199 for n in range(0, int(axis_max) + 1)] 

200 

201 horizontal_lines = [ 

202 self.CRP_MODERATE_HIGH_CUTOFF, 

203 self.CRP_LOW_MODERATE_CUTOFF, 

204 self.CRP_REMISSION_LOW_CUTOFF, 

205 0, 

206 ] 

207 

208 horizontal_labels = [ 

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

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

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

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

213 ] 

214 

215 return TrackerInfo( 

216 value=self.das28_crp(), 

217 plot_label="DAS28-CRP", 

218 axis_label="DAS28-CRP", 

219 axis_min=axis_min, 

220 axis_max=axis_max, 

221 axis_ticks=axis_ticks, 

222 horizontal_lines=horizontal_lines, 

223 horizontal_labels=horizontal_labels, 

224 ) 

225 

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

227 axis_min = -0.5 

228 axis_max = 10.0 

229 axis_ticks = [TrackerAxisTick(n, str(n)) 

230 for n in range(0, int(axis_max) + 1)] 

231 

232 horizontal_lines = [ 

233 self.ESR_MODERATE_HIGH_CUTOFF, 

234 self.ESR_LOW_MODERATE_CUTOFF, 

235 self.ESR_REMISSION_LOW_CUTOFF, 

236 0, 

237 ] 

238 

239 horizontal_labels = [ 

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

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

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

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

244 ] 

245 

246 return TrackerInfo( 

247 value=self.das28_esr(), 

248 plot_label="DAS28-ESR", 

249 axis_label="DAS28-ESR", 

250 axis_min=axis_min, 

251 axis_max=axis_max, 

252 axis_ticks=axis_ticks, 

253 horizontal_lines=horizontal_lines, 

254 horizontal_labels=horizontal_labels, 

255 ) 

256 

257 def swollen_joint_count(self): 

258 return self.count_booleans( 

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

260 ) 

261 

262 def tender_joint_count(self): 

263 return self.count_booleans( 

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

265 ) 

266 

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

268 # noinspection PyUnresolvedReferences 

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

270 return None 

271 

272 # noinspection PyUnresolvedReferences 

273 return ( 

274 0.56 * math.sqrt(self.tender_joint_count()) + 

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

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

277 0.014 * self.vas + 

278 0.96 

279 ) 

280 

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

282 # noinspection PyUnresolvedReferences 

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

284 return None 

285 

286 # noinspection PyUnresolvedReferences 

287 return ( 

288 0.56 * math.sqrt(self.tender_joint_count()) + 

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

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

291 0.014 * self.vas 

292 ) 

293 

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

295 if measurement is None: 

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

297 

298 if measurement < self.CRP_REMISSION_LOW_CUTOFF: 

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

300 

301 if measurement < self.CRP_LOW_MODERATE_CUTOFF: 

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

303 

304 if measurement > self.CRP_MODERATE_HIGH_CUTOFF: 

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

306 

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

308 

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

310 if measurement is None: 

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

312 

313 if measurement < self.ESR_REMISSION_LOW_CUTOFF: 

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

315 

316 if measurement < self.ESR_LOW_MODERATE_CUTOFF: 

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

318 

319 if measurement > self.ESR_MODERATE_HIGH_CUTOFF: 

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

321 

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

323 

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

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

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

327 

328 joint_rows = table_row([""] + sides_strings, 

329 colspans=[1, 2, 2]) 

330 

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

332 

333 for joint in self.JOINTS: 

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

335 for side in self.SIDES: 

336 for state in self.STATES: 

337 value = "?" 

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

339 if fval is not None: 

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

341 

342 cells.append(td(value)) 

343 

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

345 

346 das28_crp = self.das28_crp() 

347 das28_esr = self.das28_esr() 

348 

349 other_rows = "".join([tr_qa(self.wxstring(req, f), getattr(self, f)) 

350 for f in self.OTHER_FIELD_NAMES]) 

351 

352 html = """ 

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

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

355 {tr_is_complete} 

356 {das28_crp} 

357 {das28_esr} 

358 {swollen_joint_count} 

359 {tender_joint_count} 

360 </table> 

361 </div> 

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

363 {joint_rows} 

364 </table> 

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

366 {other_rows} 

367 </table> 

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

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

370 0.28 × √(swollen joint count) + 

371 0.36 × ln(CRP + 1) + 

372 0.014 x VAS disease activity + 

373 0.96. 

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

375 Cutoffs: 

376 &lt;2.4 remission, 

377 &lt;2.9 low disease activity, 

378 2.9–4.6 moderate disease activity, 

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

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

381 0.28 × √(swollen joint count) + 

382 0.70 × ln(ESR) + 

383 0.014 x VAS disease activity. 

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

385 Cutoffs: 

386 &lt;2.6 remission, 

387 &lt;3.2 low disease activity, 

388 3.2–5.1 moderate disease activity, 

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

390 </div> 

391 """.format( 

392 CssClass=CssClass, 

393 tr_is_complete=self.get_is_complete_tr(req), 

394 das28_crp=tr( 

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

396 "{} ({})".format( 

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

398 self.activity_state_crp(req, das28_crp) 

399 ) 

400 ), 

401 das28_esr=tr( 

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

403 "{} ({})".format( 

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

405 self.activity_state_esr(req, das28_esr) 

406 ) 

407 ), 

408 swollen_joint_count=tr( 

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

410 answer(self.swollen_joint_count()) 

411 ), 

412 tender_joint_count=tr( 

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

414 answer(self.tender_joint_count()) 

415 ), 

416 joint_rows=joint_rows, 

417 other_rows=other_rows 

418 ) 

419 return html