Coverage for tasks/das28.py: 46%

1"""

2 camcops_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

30 import math

31 from typing import Any, List, Optional, Type

32

33 from camcops_server.cc_modules.cc_constants import CssClass

34 from camcops_server.cc_modules.cc_html import (

35 answer,

36 table_row,

37 th,

38 td,

39 tr,

40 tr_qa,

41)

42 from camcops_server.cc_modules.cc_request import CamcopsRequest

43 from camcops_server.cc_modules.cc_sqla_coltypes import (

44 bool_column,

45 camcops_column,

46 PermittedValueChecker,

47 SummaryCategoryColType,

48)

49 from camcops_server.cc_modules.cc_summaryelement import SummaryElement

50 from camcops_server.cc_modules.cc_task import (

51 Task,

52 TaskHasPatientMixin,

53 TaskHasClinicianMixin,

54)

55 from camcops_server.cc_modules.cc_trackerhelpers import (

56 TrackerAxisTick,

57 TrackerInfo,

58 TrackerLabel,

59)

60

61 import cardinal_pythonlib.rnc_web as ws

62 from sqlalchemy import Column, Float, Integer

63

64

65 class 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