Coverage for tasks/das28.py: 46%
128 statements
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-15 14:23 +0100
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-15 14:23 +0100
1"""
2camcops_server/tasks/das28.py
4===============================================================================
6 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
7 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
9 This file is part of CamCOPS.
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.
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.
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/>.
24===============================================================================
26**Disease Activity Score-28 (DAS28) task.**
28"""
30import math
31from typing import Any, List, Optional, Type
33from camcops_server.cc_modules.cc_constants import CssClass
34from camcops_server.cc_modules.cc_html import (
35 answer,
36 table_row,
37 th,
38 td,
39 tr,
40 tr_qa,
41)
42from camcops_server.cc_modules.cc_request import CamcopsRequest
43from camcops_server.cc_modules.cc_sqla_coltypes import (
44 bool_column,
45 camcops_column,
46 PermittedValueChecker,
47 SummaryCategoryColType,
48)
49from camcops_server.cc_modules.cc_summaryelement import SummaryElement
50from camcops_server.cc_modules.cc_task import (
51 Task,
52 TaskHasPatientMixin,
53 TaskHasClinicianMixin,
54)
55from camcops_server.cc_modules.cc_trackerhelpers import (
56 TrackerAxisTick,
57 TrackerInfo,
58 TrackerLabel,
59)
61import cardinal_pythonlib.rnc_web as ws
62from sqlalchemy import Column, Float, Integer
65class Das28( # type: ignore[misc]
66 TaskHasPatientMixin,
67 TaskHasClinicianMixin,
68 Task,
69):
70 __tablename__ = "das28"
71 shortname = "DAS28"
72 provides_trackers = True
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 )
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 )
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 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 )
105 SIDES = ["left", "right"]
106 STATES = ["swollen", "tender"]
108 OTHER_FIELD_NAMES = ["vas", "crp", "esr"]
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
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
121 @classmethod
122 def field_name(cls, side: str, joint: str, state: str) -> str:
123 return f"{side}_{joint}_{state}"
125 @classmethod
126 def get_joint_field_names(cls) -> List:
127 field_names = []
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))
134 return field_names
136 @classmethod
137 def get_all_field_names(cls) -> List:
138 return cls.get_joint_field_names() + cls.OTHER_FIELD_NAMES
140 @staticmethod
141 def longname(req: "CamcopsRequest") -> str:
142 _ = req.gettext
143 return _("Disease Activity Score-28")
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 ]
173 def is_complete(self) -> bool:
174 if self.any_fields_none(self.get_joint_field_names() + ["vas"]):
175 return False
177 # noinspection PyUnresolvedReferences
178 if self.crp is None and self.esr is None: # type: ignore[attr-defined]
179 return False
181 if not self.field_contents_valid():
182 return False
184 return True
186 def get_trackers(self, req: CamcopsRequest) -> List[TrackerInfo]:
187 return [self.get_crp_tracker(req), self.get_esr_tracker(req)]
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 ]
196 horizontal_lines = [
197 self.CRP_MODERATE_HIGH_CUTOFF,
198 self.CRP_LOW_MODERATE_CUTOFF,
199 self.CRP_REMISSION_LOW_CUTOFF,
200 0,
201 ]
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 ]
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 )
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 ]
228 horizontal_lines = [
229 self.ESR_MODERATE_HIGH_CUTOFF,
230 self.ESR_LOW_MODERATE_CUTOFF,
231 self.ESR_REMISSION_LOW_CUTOFF,
232 0,
233 ]
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 ]
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 )
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 )
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 )
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
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 )
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
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 )
290 def activity_state_crp(self, req: CamcopsRequest, measurement: Any) -> str:
291 if measurement is None:
292 return self.wxstring(req, "n_a")
294 if measurement < self.CRP_REMISSION_LOW_CUTOFF:
295 return self.wxstring(req, "remission")
297 if measurement < self.CRP_LOW_MODERATE_CUTOFF:
298 return self.wxstring(req, "low")
300 if measurement > self.CRP_MODERATE_HIGH_CUTOFF:
301 return self.wxstring(req, "high")
303 return self.wxstring(req, "moderate")
305 def activity_state_esr(self, req: CamcopsRequest, measurement: Any) -> str:
306 if measurement is None:
307 return self.wxstring(req, "n_a")
309 if measurement < self.ESR_REMISSION_LOW_CUTOFF:
310 return self.wxstring(req, "remission")
312 if measurement < self.ESR_LOW_MODERATE_CUTOFF:
313 return self.wxstring(req, "low")
315 if measurement > self.ESR_MODERATE_HIGH_CUTOFF:
316 return self.wxstring(req, "high")
318 return self.wxstring(req, "moderate")
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]
324 joint_rows = table_row([""] + sides_strings, colspans=[1, 2, 2])
326 joint_rows += table_row([""] + states_strings * 2)
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 "×"
337 cells.append(td(value))
339 joint_rows += tr(*cells, literal=True)
341 das28_crp = self.das28_crp()
342 das28_esr = self.das28_esr()
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 )
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 <2.4 remission,
376 <2.9 low disease activity,
377 2.9–4.6 moderate disease activity,
378 >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 <2.6 remission,
386 <3.2 low disease activity,
387 3.2–5.1 moderate disease activity,
388 >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