Coverage for tasks/das28.py : 47%

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
3"""
4camcops_server/tasks/das28.py
6===============================================================================
8 Copyright (C) 2012-2020 Rudolf Cardinal (rudolf@pobox.com).
10 This file is part of CamCOPS.
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.
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.
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/>.
25===============================================================================
27**Disease Activity Score-28 (DAS28) task.**
29"""
31import math
32from typing import Any, Dict, List, Optional, Type, Tuple
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)
62import cardinal_pythonlib.rnc_web as ws
63from sqlalchemy import Column, Float, Integer
64from sqlalchemy.ext.declarative import DeclarativeMeta
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"))
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 )
88 setattr(
89 cls, 'crp',
90 Column('crp', Float, comment="CRP (0-300 mg/L)")
91 )
93 setattr(
94 cls, 'esr',
95 Column('esr', Float, comment="ESR (1-300 mm/h)")
96 )
98 super().__init__(name, bases, classdict)
101class Das28(TaskHasPatientMixin,
102 TaskHasClinicianMixin,
103 Task,
104 metaclass=Das28Metaclass):
105 __tablename__ = "das28"
106 shortname = "DAS28"
107 provides_trackers = True
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 )
116 SIDES = ['left', 'right']
117 STATES = ['swollen', 'tender']
119 OTHER_FIELD_NAMES = ['vas', 'crp', 'esr']
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
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
132 @classmethod
133 def field_name(cls, side, joint, state) -> str:
134 return f"{side}_{joint}_{state}"
136 @classmethod
137 def get_joint_field_names(cls) -> List:
138 field_names = []
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))
145 return field_names
147 @classmethod
148 def get_all_field_names(cls) -> List:
149 return cls.get_joint_field_names() + cls.OTHER_FIELD_NAMES
151 @staticmethod
152 def longname(req: "CamcopsRequest") -> str:
153 _ = req.gettext
154 return _("Disease Activity Score-28")
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 ]
176 def is_complete(self) -> bool:
177 if self.any_fields_none(self.get_joint_field_names() + ['vas']):
178 return False
180 # noinspection PyUnresolvedReferences
181 if self.crp is None and self.esr is None:
182 return False
184 if not self.field_contents_valid():
185 return False
187 return True
189 def get_trackers(self, req: CamcopsRequest) -> List[TrackerInfo]:
190 return [
191 self.get_crp_tracker(req),
192 self.get_esr_tracker(req),
193 ]
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)]
201 horizontal_lines = [
202 self.CRP_MODERATE_HIGH_CUTOFF,
203 self.CRP_LOW_MODERATE_CUTOFF,
204 self.CRP_REMISSION_LOW_CUTOFF,
205 0,
206 ]
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 ]
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 )
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)]
232 horizontal_lines = [
233 self.ESR_MODERATE_HIGH_CUTOFF,
234 self.ESR_LOW_MODERATE_CUTOFF,
235 self.ESR_REMISSION_LOW_CUTOFF,
236 0,
237 ]
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 ]
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 )
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 )
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 )
267 def das28_crp(self) -> Optional[float]:
268 # noinspection PyUnresolvedReferences
269 if self.crp is None or self.vas is None:
270 return None
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 )
281 def das28_esr(self) -> Optional[float]:
282 # noinspection PyUnresolvedReferences
283 if self.esr is None or self.vas is None:
284 return None
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 )
294 def activity_state_crp(self, req: CamcopsRequest, measurement: Any) -> str:
295 if measurement is None:
296 return self.wxstring(req, "n_a")
298 if measurement < self.CRP_REMISSION_LOW_CUTOFF:
299 return self.wxstring(req, "remission")
301 if measurement < self.CRP_LOW_MODERATE_CUTOFF:
302 return self.wxstring(req, "low")
304 if measurement > self.CRP_MODERATE_HIGH_CUTOFF:
305 return self.wxstring(req, "high")
307 return self.wxstring(req, "moderate")
309 def activity_state_esr(self, req: CamcopsRequest, measurement: Any) -> str:
310 if measurement is None:
311 return self.wxstring(req, "n_a")
313 if measurement < self.ESR_REMISSION_LOW_CUTOFF:
314 return self.wxstring(req, "remission")
316 if measurement < self.ESR_LOW_MODERATE_CUTOFF:
317 return self.wxstring(req, "low")
319 if measurement > self.ESR_MODERATE_HIGH_CUTOFF:
320 return self.wxstring(req, "high")
322 return self.wxstring(req, "moderate")
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]
328 joint_rows = table_row([""] + sides_strings,
329 colspans=[1, 2, 2])
331 joint_rows += table_row([""] + states_strings * 2)
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 "×"
342 cells.append(td(value))
344 joint_rows += tr(*cells, literal=True)
346 das28_crp = self.das28_crp()
347 das28_esr = self.das28_esr()
349 other_rows = "".join([tr_qa(self.wxstring(req, f), getattr(self, f))
350 for f in self.OTHER_FIELD_NAMES])
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 <2.4 remission,
377 <2.9 low disease activity,
378 2.9–4.6 moderate disease activity,
379 >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 <2.6 remission,
387 <3.2 low disease activity,
388 3.2–5.1 moderate disease activity,
389 >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