Coverage for tasks/das28.py: 47%
131 statements
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-08 23:14 +0000
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-08 23:14 +0000
1#!/usr/bin/env python
3"""
4camcops_server/tasks/das28.py
6===============================================================================
8 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
9 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
11 This file is part of CamCOPS.
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.
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.
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/>.
26===============================================================================
28**Disease Activity Score-28 (DAS28) task.**
30"""
32import math
33from typing import Any, Dict, List, Optional, Type, Tuple
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)
63import cardinal_pythonlib.rnc_web as ws
64from sqlalchemy import Column, Float, Integer
65from sqlalchemy.ext.declarative import DeclarativeMeta
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 )
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 )
94 setattr(cls, "crp", Column("crp", Float, comment="CRP (0-300 mg/L)"))
96 setattr(cls, "esr", Column("esr", Float, comment="ESR (1-300 mm/h)"))
98 super().__init__(name, bases, classdict)
101class Das28(
102 TaskHasPatientMixin, TaskHasClinicianMixin, Task, metaclass=Das28Metaclass
103):
104 __tablename__ = "das28"
105 shortname = "DAS28"
106 provides_trackers = True
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 )
115 SIDES = ["left", "right"]
116 STATES = ["swollen", "tender"]
118 OTHER_FIELD_NAMES = ["vas", "crp", "esr"]
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
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
131 @classmethod
132 def field_name(cls, side, joint, state) -> str:
133 return f"{side}_{joint}_{state}"
135 @classmethod
136 def get_joint_field_names(cls) -> List:
137 field_names = []
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))
144 return field_names
146 @classmethod
147 def get_all_field_names(cls) -> List:
148 return cls.get_joint_field_names() + cls.OTHER_FIELD_NAMES
150 @staticmethod
151 def longname(req: "CamcopsRequest") -> str:
152 _ = req.gettext
153 return _("Disease Activity Score-28")
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 ]
183 def is_complete(self) -> bool:
184 if self.any_fields_none(self.get_joint_field_names() + ["vas"]):
185 return False
187 # noinspection PyUnresolvedReferences
188 if self.crp is None and self.esr is None:
189 return False
191 if not self.field_contents_valid():
192 return False
194 return True
196 def get_trackers(self, req: CamcopsRequest) -> List[TrackerInfo]:
197 return [self.get_crp_tracker(req), self.get_esr_tracker(req)]
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 ]
206 horizontal_lines = [
207 self.CRP_MODERATE_HIGH_CUTOFF,
208 self.CRP_LOW_MODERATE_CUTOFF,
209 self.CRP_REMISSION_LOW_CUTOFF,
210 0,
211 ]
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 ]
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 )
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 ]
238 horizontal_lines = [
239 self.ESR_MODERATE_HIGH_CUTOFF,
240 self.ESR_LOW_MODERATE_CUTOFF,
241 self.ESR_REMISSION_LOW_CUTOFF,
242 0,
243 ]
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 ]
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 )
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 )
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 )
273 def das28_crp(self) -> Optional[float]:
274 # noinspection PyUnresolvedReferences
275 if self.crp is None or self.vas is None:
276 return None
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 )
287 def das28_esr(self) -> Optional[float]:
288 # noinspection PyUnresolvedReferences
289 if self.esr is None or self.vas is None:
290 return None
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 )
300 def activity_state_crp(self, req: CamcopsRequest, measurement: Any) -> str:
301 if measurement is None:
302 return self.wxstring(req, "n_a")
304 if measurement < self.CRP_REMISSION_LOW_CUTOFF:
305 return self.wxstring(req, "remission")
307 if measurement < self.CRP_LOW_MODERATE_CUTOFF:
308 return self.wxstring(req, "low")
310 if measurement > self.CRP_MODERATE_HIGH_CUTOFF:
311 return self.wxstring(req, "high")
313 return self.wxstring(req, "moderate")
315 def activity_state_esr(self, req: CamcopsRequest, measurement: Any) -> str:
316 if measurement is None:
317 return self.wxstring(req, "n_a")
319 if measurement < self.ESR_REMISSION_LOW_CUTOFF:
320 return self.wxstring(req, "remission")
322 if measurement < self.ESR_LOW_MODERATE_CUTOFF:
323 return self.wxstring(req, "low")
325 if measurement > self.ESR_MODERATE_HIGH_CUTOFF:
326 return self.wxstring(req, "high")
328 return self.wxstring(req, "moderate")
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]
334 joint_rows = table_row([""] + sides_strings, colspans=[1, 2, 2])
336 joint_rows += table_row([""] + states_strings * 2)
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 "×"
347 cells.append(td(value))
349 joint_rows += tr(*cells, literal=True)
351 das28_crp = self.das28_crp()
352 das28_esr = self.das28_esr()
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 )
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 <2.4 remission,
386 <2.9 low disease activity,
387 2.9–4.6 moderate disease activity,
388 >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 <2.6 remission,
396 <3.2 low disease activity,
397 3.2–5.1 moderate disease activity,
398 >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