Coverage for tasks/rapid3.py: 52%
112 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/rapid3.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**Routine Assessment of Patient Index Data (RAPID 3) task.**
28"""
30from typing import Any, List, Optional, Type, Tuple
32import cardinal_pythonlib.rnc_web as ws
33from sqlalchemy import Float, Integer
35from camcops_server.cc_modules.cc_constants import CssClass
36from camcops_server.cc_modules.cc_html import answer, tr_qa, tr, tr_span_col
37from camcops_server.cc_modules.cc_request import CamcopsRequest
38from camcops_server.cc_modules.cc_sqla_coltypes import (
39 camcops_column,
40 PermittedValueChecker,
41 ZERO_TO_THREE_CHECKER,
42)
43from camcops_server.cc_modules.cc_summaryelement import SummaryElement
44from camcops_server.cc_modules.cc_task import TaskHasPatientMixin, Task
45from camcops_server.cc_modules.cc_trackerhelpers import (
46 TrackerAxisTick,
47 TrackerInfo,
48 TrackerLabel,
49)
52# =============================================================================
53# RAPID 3
54# =============================================================================
57class Rapid3( # type: ignore[misc]
58 TaskHasPatientMixin,
59 Task,
60):
61 __tablename__ = "rapid3"
62 shortname = "RAPID3"
63 provides_trackers = True
65 N_Q1_QUESTIONS = 13
66 N_Q1_SCORING_QUESTIONS = 10
68 # > 12 = HIGH
69 # 6.1 - 12 = MODERATE
70 # 3.1 - 6 = LOW
71 # <= 3 = REMISSION
73 MINIMUM = 0
74 NEAR_REMISSION_MAX = 3
75 LOW_SEVERITY_MAX = 6
76 MODERATE_SEVERITY_MAX = 12
77 MAXIMUM = 30
79 @classmethod
80 def extend_columns(cls: Type["Rapid3"], **kwargs: Any) -> None:
82 comment_strings = [
83 "get dressed",
84 "get in bed",
85 "lift full cup",
86 "walk outdoors",
87 "wash body",
88 "bend down",
89 "turn taps",
90 "get in car",
91 "walk 2 miles",
92 "sports",
93 "sleep",
94 "anxiety",
95 "depression",
96 ]
97 score_comment = "(0 without any difficulty - 3 unable to do)"
99 for q_index, q_fieldname in cls.q1_all_indexed_fieldnames():
100 setattr(
101 cls,
102 q_fieldname,
103 camcops_column(
104 q_fieldname,
105 Integer,
106 permitted_value_checker=ZERO_TO_THREE_CHECKER,
107 comment="{} ({}) {}".format(
108 q_fieldname.capitalize(),
109 comment_strings[q_index],
110 score_comment,
111 ),
112 ),
113 )
115 permitted_scale_values = [v / 2.0 for v in range(0, 20 + 1)]
117 setattr(
118 cls,
119 "q2",
120 camcops_column(
121 "q2",
122 Float,
123 permitted_value_checker=PermittedValueChecker(
124 permitted_values=permitted_scale_values
125 ),
126 comment=(
127 "Q2 (pain tolerance) (0 no pain - 10 pain as bad as "
128 "it could be"
129 ),
130 ),
131 )
133 setattr(
134 cls,
135 "q3",
136 camcops_column(
137 "q3",
138 Float,
139 permitted_value_checker=PermittedValueChecker(
140 permitted_values=permitted_scale_values
141 ),
142 comment="Q3 (patient global estimate) "
143 "(0 very well - very poorly)",
144 ),
145 )
147 @classmethod
148 def q1_indexed_letters(cls, last: int) -> List[Tuple[int, str]]:
149 return [(i, chr(i + ord("a"))) for i in range(0, last)]
151 @classmethod
152 def q1_indexed_fieldnames(cls, last: int) -> List[Tuple[int, str]]:
153 return [(i, f"q1{c}") for (i, c) in cls.q1_indexed_letters(last)]
155 @classmethod
156 def q1_all_indexed_fieldnames(cls) -> List[Tuple[int, str]]:
157 return [
158 (i, f) for (i, f) in cls.q1_indexed_fieldnames(cls.N_Q1_QUESTIONS)
159 ]
161 @classmethod
162 def q1_all_fieldnames(cls) -> List[str]:
163 return [f for (i, f) in cls.q1_indexed_fieldnames(cls.N_Q1_QUESTIONS)]
165 @classmethod
166 def q1_all_letters(cls) -> List[str]:
167 return [c for (i, c) in cls.q1_indexed_letters(cls.N_Q1_QUESTIONS)]
169 @classmethod
170 def q1_scoring_fieldnames(cls) -> List[str]:
171 return [
172 f
173 for (i, f) in cls.q1_indexed_fieldnames(cls.N_Q1_SCORING_QUESTIONS)
174 ]
176 @classmethod
177 def all_fieldnames(cls) -> List[str]:
178 return cls.q1_all_fieldnames() + ["q2", "q3"]
180 @staticmethod
181 def longname(req: "CamcopsRequest") -> str:
182 _ = req.gettext
183 return _("Routine Assessment of Patient Index Data")
185 def get_summaries(self, req: CamcopsRequest) -> List[SummaryElement]:
186 return self.standard_task_summary_fields() + [
187 SummaryElement(
188 name="rapid3",
189 coltype=Float(),
190 value=self.rapid3(),
191 comment="RAPID3",
192 )
193 ]
195 def get_trackers(self, req: CamcopsRequest) -> List[TrackerInfo]:
196 axis_min = self.MINIMUM - 0.5
197 axis_max = self.MAXIMUM + 0.5
198 axis_ticks = [
199 TrackerAxisTick(n, str(n)) for n in range(0, int(axis_max) + 1, 2)
200 ]
202 horizontal_lines: list[float] = [
203 self.MAXIMUM,
204 self.MODERATE_SEVERITY_MAX,
205 self.LOW_SEVERITY_MAX,
206 self.NEAR_REMISSION_MAX,
207 self.MINIMUM,
208 ]
210 horizontal_labels = [
211 TrackerLabel(
212 self.MODERATE_SEVERITY_MAX + 8.0,
213 self.wxstring(req, "high_severity"),
214 ),
215 TrackerLabel(
216 self.MODERATE_SEVERITY_MAX - 3.0,
217 self.wxstring(req, "moderate_severity"),
218 ),
219 TrackerLabel(
220 self.LOW_SEVERITY_MAX - 1.5, self.wxstring(req, "low_severity")
221 ),
222 TrackerLabel(
223 self.NEAR_REMISSION_MAX - 1.5,
224 self.wxstring(req, "near_remission"),
225 ),
226 ]
228 return [
229 TrackerInfo(
230 value=self.rapid3(),
231 plot_label="RAPID3",
232 axis_label="RAPID3",
233 axis_min=axis_min,
234 axis_max=axis_max,
235 axis_ticks=axis_ticks,
236 horizontal_lines=horizontal_lines,
237 horizontal_labels=horizontal_labels,
238 )
239 ]
241 def rapid3(self) -> Optional[float]:
242 if not self.is_complete():
243 return None
245 return (
246 self.functional_status()
247 + self.pain_tolerance()
248 + self.global_estimate()
249 )
251 def functional_status(self) -> float:
252 return round(self.sum_fields(self.q1_scoring_fieldnames()) / 3, 1)
254 def pain_tolerance(self) -> float:
255 # noinspection PyUnresolvedReferences
256 return self.q2 # type: ignore[attr-defined]
258 def global_estimate(self) -> float:
259 # noinspection PyUnresolvedReferences
260 return self.q3 # type: ignore[attr-defined]
262 def is_complete(self) -> bool:
263 if self.any_fields_none(self.all_fieldnames()):
264 return False
266 if not self.field_contents_valid():
267 return False
269 return True
271 def get_task_html(self, req: CamcopsRequest) -> str:
272 rows = tr_span_col(
273 f'{self.wxstring(req, "q1")}<br>' f'{self.wxstring(req, "q1sub")}',
274 cols=2,
275 )
276 for letter in self.q1_all_letters():
277 q_fieldname = f"q1{letter}"
279 qtext = self.wxstring(req, q_fieldname)
280 score = getattr(self, q_fieldname)
282 description = "?"
283 if score is not None:
284 description = self.wxstring(req, f"q1_option{score}")
286 rows += tr_qa(qtext, f"{score} — {description}")
288 for q_num in (2, 3):
289 q_fieldname = f"q{q_num}"
290 qtext = self.wxstring(req, q_fieldname)
291 min_text = self.wxstring(req, f"{q_fieldname}_min")
292 max_text = self.wxstring(req, f"{q_fieldname}_max")
293 qtext += f" <i>(0.0 = {min_text}, 10.0 = {max_text})</i>"
294 score = getattr(self, q_fieldname)
296 rows += tr_qa(qtext, score)
298 rapid3 = ws.number_to_dp(self.rapid3(), 1, default="?")
300 html = """
301 <div class="{CssClass.SUMMARY}">
302 <table class="{CssClass.SUMMARY}">
303 {tr_is_complete}
304 {rapid3}
305 </table>
306 </div>
307 <table class="{CssClass.TASKDETAIL}">
308 <tr>
309 <th width="60%">Question</th>
310 <th width="40%">Answer</th>
311 </tr>
312 {rows}
313 </table>
314 <div class="{CssClass.FOOTNOTES}">
315 [1] Add scores for questions 1a–1j (ten questions each scored
316 0–3), divide by 3, and round to 1 decimal place (giving a
317 score for Q1 in the range 0–10). Then add this to scores
318 for Q2 and Q3 (each scored 0–10) to get the RAPID3
319 cumulative score (0–30), as shown here.
320 Interpretation of the cumulative score:
321 ≤3: Near remission (NR).
322 3.1–6: Low severity (LS).
323 6.1–12: Moderate severity (MS).
324 >12: High severity (HS).
326 Note also: questions 1k–1m are each scored 0, 1.1, 2.2, or
327 3.3 in the PDF/paper version of the RAPID3, but do not
328 contribute to the formal score. They are shown here with
329 values 0, 1, 2, 3 (and, similarly, do not contribute to
330 the overall score).
332 </div>
333 """.format(
334 CssClass=CssClass,
335 tr_is_complete=self.get_is_complete_tr(req),
336 rapid3=tr(
337 self.wxstring(req, "rapid3") + " (0–30) <sup>[1]</sup>",
338 "{} ({})".format(answer(rapid3), self.disease_severity(req)),
339 ),
340 rows=rows,
341 )
342 return html
344 def disease_severity(self, req: CamcopsRequest) -> str:
345 rapid3 = self.rapid3()
347 if rapid3 is None:
348 return self.wxstring(req, "n_a")
350 if rapid3 <= self.NEAR_REMISSION_MAX:
351 return self.wxstring(req, "near_remission")
353 if rapid3 <= self.LOW_SEVERITY_MAX:
354 return self.wxstring(req, "low_severity")
356 if rapid3 <= self.MODERATE_SEVERITY_MAX:
357 return self.wxstring(req, "moderate_severity")
359 return self.wxstring(req, "high_severity")