Coverage for tasks/rand36.py: 36%
172 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/rand36.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"""
30from typing import Any, Dict, List, Optional, Tuple, Type
32from cardinal_pythonlib.maths_py import mean
33from cardinal_pythonlib.stringfunc import strseq
34from sqlalchemy.ext.declarative import DeclarativeMeta
35from sqlalchemy.sql.sqltypes import Float, Integer
37from camcops_server.cc_modules.cc_constants import CssClass
38from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo
39from camcops_server.cc_modules.cc_db import add_multiple_columns
40from camcops_server.cc_modules.cc_html import answer, identity, tr, tr_span_col
41from camcops_server.cc_modules.cc_request import CamcopsRequest
42from camcops_server.cc_modules.cc_sqla_coltypes import (
43 CamcopsColumn,
44 ONE_TO_FIVE_CHECKER,
45 ONE_TO_SIX_CHECKER,
46)
47from camcops_server.cc_modules.cc_summaryelement import SummaryElement
48from camcops_server.cc_modules.cc_task import Task, TaskHasPatientMixin
49from camcops_server.cc_modules.cc_trackerhelpers import TrackerInfo
52# =============================================================================
53# RAND-36
54# =============================================================================
57class Rand36Metaclass(DeclarativeMeta):
58 # noinspection PyInitNewSignature
59 def __init__(
60 cls: Type["Rand36"],
61 name: str,
62 bases: Tuple[Type, ...],
63 classdict: Dict[str, Any],
64 ) -> None:
65 add_multiple_columns(
66 cls,
67 "q",
68 3,
69 12,
70 minimum=1,
71 maximum=3,
72 comment_fmt="Q{n} ({s}) (1 limited a lot - 3 not limited at all)",
73 comment_strings=[
74 "Vigorous activities",
75 "Moderate activities",
76 "Lifting or carrying groceries",
77 "Climbing several flights of stairs",
78 "Climbing one flight of stairs",
79 "Bending, kneeling, or stooping",
80 "Walking more than a mile",
81 "Walking several blocks",
82 "Walking one block",
83 "Bathing or dressing yourself",
84 ],
85 )
86 add_multiple_columns(
87 cls,
88 "q",
89 13,
90 16,
91 minimum=1,
92 maximum=2,
93 comment_fmt="Q{n} (physical health: {s}) (1 yes, 2 no)",
94 comment_strings=[
95 "Cut down work/other activities",
96 "Accomplished less than would like",
97 "Were limited in the kind of work or other activities",
98 "Had difficulty performing the work or other activities",
99 ],
100 )
101 add_multiple_columns(
102 cls,
103 "q",
104 17,
105 19,
106 minimum=1,
107 maximum=2,
108 comment_fmt="Q{n} (emotional problems: {s}) (1 yes, 2 no)",
109 comment_strings=[
110 "Cut down work/other activities",
111 "Accomplished less than would like",
112 "Didn't do work or other activities as carefully as usual",
113 "Had difficulty performing the work or other activities",
114 ],
115 )
116 add_multiple_columns(
117 cls,
118 "q",
119 23,
120 31,
121 minimum=1,
122 maximum=6,
123 comment_fmt="Q{n} (past 4 weeks: {s}) (1 all of the time - "
124 "6 none of the time)",
125 comment_strings=[
126 "Did you feel full of pep?",
127 "Have you been a very nervous person?",
128 "Have you felt so down in the dumps that nothing could cheer "
129 "you up?",
130 "Have you felt calm and peaceful?",
131 "Did you have a lot of energy?",
132 "Have you felt downhearted and blue?",
133 "Did you feel worn out?",
134 "Have you been a happy person?",
135 "Did you feel tired?",
136 ],
137 )
138 add_multiple_columns(
139 cls,
140 "q",
141 33,
142 36,
143 minimum=1,
144 maximum=5,
145 comment_fmt="Q{n} (how true/false: {s}) (1 definitely true - "
146 "5 definitely false)",
147 comment_strings=[
148 "I seem to get sick a little easier than other people",
149 "I am as healthy as anybody I know",
150 "I expect my health to get worse",
151 "My health is excellent",
152 ],
153 )
154 super().__init__(name, bases, classdict)
157class Rand36(TaskHasPatientMixin, Task, metaclass=Rand36Metaclass):
158 """
159 Server implementation of the RAND-36 task.
160 """
162 __tablename__ = "rand36"
163 shortname = "RAND-36"
164 provides_trackers = True
166 NQUESTIONS = 36
168 q1 = CamcopsColumn(
169 "q1",
170 Integer,
171 permitted_value_checker=ONE_TO_FIVE_CHECKER,
172 comment="Q1 (general health) (1 excellent - 5 poor)",
173 )
174 q2 = CamcopsColumn(
175 "q2",
176 Integer,
177 permitted_value_checker=ONE_TO_FIVE_CHECKER,
178 comment="Q2 (health cf. 1y ago) (1 much better - 5 much worse)",
179 )
181 q20 = CamcopsColumn(
182 "q20",
183 Integer,
184 permitted_value_checker=ONE_TO_FIVE_CHECKER,
185 comment="Q20 (past 4 weeks, to what extent physical health/"
186 "emotional problems interfered with social activity) "
187 "(1 not at all - 5 extremely)",
188 )
189 q21 = CamcopsColumn(
190 "q21",
191 Integer,
192 permitted_value_checker=ONE_TO_SIX_CHECKER,
193 comment="Q21 (past 4 weeks, how much pain (1 none - 6 very severe)",
194 )
195 q22 = CamcopsColumn(
196 "q22",
197 Integer,
198 permitted_value_checker=ONE_TO_FIVE_CHECKER,
199 comment="Q22 (past 4 weeks, pain interfered with normal activity "
200 "(1 not at all - 5 extremely)",
201 )
203 q32 = CamcopsColumn(
204 "q32",
205 Integer,
206 permitted_value_checker=ONE_TO_FIVE_CHECKER,
207 comment="Q32 (past 4 weeks, how much of the time has physical "
208 "health/emotional problems interfered with social activities "
209 "(1 all of the time - 5 none of the time)",
210 )
211 # ... note Q32 extremely similar to Q20.
213 TASK_FIELDS = strseq("q", 1, NQUESTIONS)
215 @staticmethod
216 def longname(req: "CamcopsRequest") -> str:
217 _ = req.gettext
218 return _("RAND 36-Item Short Form Health Survey 1.0")
220 def is_complete(self) -> bool:
221 return (
222 self.all_fields_not_none(self.TASK_FIELDS)
223 and self.field_contents_valid()
224 )
226 @classmethod
227 def tracker_element(cls, value: float, plot_label: str) -> TrackerInfo:
228 return TrackerInfo(
229 value=value,
230 plot_label="RAND-36: " + plot_label,
231 axis_label="Scale score (out of 100)",
232 axis_min=-0.5,
233 axis_max=100.5,
234 )
236 def get_trackers(self, req: CamcopsRequest) -> List[TrackerInfo]:
237 return [
238 self.tracker_element(
239 self.score_overall(), self.wxstring(req, "score_overall")
240 ),
241 self.tracker_element(
242 self.score_physical_functioning(),
243 self.wxstring(req, "score_physical_functioning"),
244 ),
245 self.tracker_element(
246 self.score_role_limitations_physical(),
247 self.wxstring(req, "score_role_limitations_physical"),
248 ),
249 self.tracker_element(
250 self.score_role_limitations_emotional(),
251 self.wxstring(req, "score_role_limitations_emotional"),
252 ),
253 self.tracker_element(
254 self.score_energy(), self.wxstring(req, "score_energy")
255 ),
256 self.tracker_element(
257 self.score_emotional_wellbeing(),
258 self.wxstring(req, "score_emotional_wellbeing"),
259 ),
260 self.tracker_element(
261 self.score_social_functioning(),
262 self.wxstring(req, "score_social_functioning"),
263 ),
264 self.tracker_element(
265 self.score_pain(), self.wxstring(req, "score_pain")
266 ),
267 self.tracker_element(
268 self.score_general_health(),
269 self.wxstring(req, "score_general_health"),
270 ),
271 ]
273 def get_clinical_text(self, req: CamcopsRequest) -> List[CtvInfo]:
274 if not self.is_complete():
275 return CTV_INCOMPLETE
276 return [
277 CtvInfo(
278 content=(
279 "RAND-36 (scores out of 100, 100 best): overall {ov}, "
280 "physical functioning {pf}, physical role "
281 "limitations {prl}, emotional role limitations {erl}, "
282 "energy {e}, emotional wellbeing {ew}, social "
283 "functioning {sf}, pain {p}, general health {gh}.".format(
284 ov=self.score_overall(),
285 pf=self.score_physical_functioning(),
286 prl=self.score_role_limitations_physical(),
287 erl=self.score_role_limitations_emotional(),
288 e=self.score_energy(),
289 ew=self.score_emotional_wellbeing(),
290 sf=self.score_social_functioning(),
291 p=self.score_pain(),
292 gh=self.score_general_health(),
293 )
294 )
295 )
296 ]
298 def get_summaries(self, req: CamcopsRequest) -> List[SummaryElement]:
299 return self.standard_task_summary_fields() + [
300 SummaryElement(
301 name="overall",
302 coltype=Float(),
303 value=self.score_overall(),
304 comment="Overall mean score (0-100, higher better)",
305 ),
306 SummaryElement(
307 name="physical_functioning",
308 coltype=Float(),
309 value=self.score_physical_functioning(),
310 comment="Physical functioning score (0-100, higher better)",
311 ),
312 SummaryElement(
313 name="role_limitations_physical",
314 coltype=Float(),
315 value=self.score_role_limitations_physical(),
316 comment="Role limitations due to physical health score "
317 "(0-100, higher better)",
318 ),
319 SummaryElement(
320 name="role_limitations_emotional",
321 coltype=Float(),
322 value=self.score_role_limitations_emotional(),
323 comment="Role limitations due to emotional problems score "
324 "(0-100, higher better)",
325 ),
326 SummaryElement(
327 name="energy",
328 coltype=Float(),
329 value=self.score_energy(),
330 comment="Energy/fatigue score (0-100, higher better)",
331 ),
332 SummaryElement(
333 name="emotional_wellbeing",
334 coltype=Float(),
335 value=self.score_emotional_wellbeing(),
336 comment="Emotional well-being score (0-100, higher better)",
337 ),
338 SummaryElement(
339 name="social_functioning",
340 coltype=Float(),
341 value=self.score_social_functioning(),
342 comment="Social functioning score (0-100, higher better)",
343 ),
344 SummaryElement(
345 name="pain",
346 coltype=Float(),
347 value=self.score_pain(),
348 comment="Pain score (0-100, higher better)",
349 ),
350 SummaryElement(
351 name="general_health",
352 coltype=Float(),
353 value=self.score_general_health(),
354 comment="General health score (0-100, higher better)",
355 ),
356 ]
358 # Scoring
359 def recode(self, q: int) -> Optional[float]:
360 x = getattr(self, "q" + str(q)) # response
361 if x is None or x < 1:
362 return None
363 # http://m.rand.org/content/dam/rand/www/external/health/
364 # surveys_tools/mos/mos_core_36item_scoring.pdf
365 if q == 1 or q == 2 or q == 20 or q == 22 or q == 34 or q == 36:
366 # 1 becomes 100, 2 => 75, 3 => 50, 4 =>25, 5 => 0
367 if x > 5:
368 return None
369 return 100 - 25 * (x - 1)
370 elif 3 <= q <= 12:
371 # 1 => 0, 2 => 50, 3 => 100
372 if x > 3:
373 return None
374 return 50 * (x - 1)
375 elif 13 <= q <= 19:
376 # 1 => 0, 2 => 100
377 if x > 2:
378 return None
379 return 100 * (x - 1)
380 elif q == 21 or q == 23 or q == 26 or q == 27 or q == 30:
381 # 1 => 100, 2 => 80, 3 => 60, 4 => 40, 5 => 20, 6 => 0
382 if x > 6:
383 return None
384 return 100 - 20 * (x - 1)
385 elif q == 24 or q == 25 or q == 28 or q == 29 or q == 31:
386 # 1 => 0, 2 => 20, 3 => 40, 4 => 60, 5 => 80, 6 => 100
387 if x > 6:
388 return None
389 return 20 * (x - 1)
390 elif q == 32 or q == 33 or q == 35:
391 # 1 => 0, 2 => 25, 3 => 50, 4 => 75, 5 => 100
392 if x > 5:
393 return None
394 return 25 * (x - 1)
395 return None
397 def score_physical_functioning(self) -> Optional[float]:
398 return mean(
399 [
400 self.recode(3),
401 self.recode(4),
402 self.recode(5),
403 self.recode(6),
404 self.recode(7),
405 self.recode(8),
406 self.recode(9),
407 self.recode(10),
408 self.recode(11),
409 self.recode(12),
410 ]
411 )
413 def score_role_limitations_physical(self) -> Optional[float]:
414 return mean(
415 [
416 self.recode(13),
417 self.recode(14),
418 self.recode(15),
419 self.recode(16),
420 ]
421 )
423 def score_role_limitations_emotional(self) -> Optional[float]:
424 return mean([self.recode(17), self.recode(18), self.recode(19)])
426 def score_energy(self) -> Optional[float]:
427 return mean(
428 [
429 self.recode(23),
430 self.recode(27),
431 self.recode(29),
432 self.recode(31),
433 ]
434 )
436 def score_emotional_wellbeing(self) -> Optional[float]:
437 return mean(
438 [
439 self.recode(24),
440 self.recode(25),
441 self.recode(26),
442 self.recode(28),
443 self.recode(30),
444 ]
445 )
447 def score_social_functioning(self) -> Optional[float]:
448 return mean([self.recode(20), self.recode(32)])
450 def score_pain(self) -> Optional[float]:
451 return mean([self.recode(21), self.recode(22)])
453 def score_general_health(self) -> Optional[float]:
454 return mean(
455 [
456 self.recode(1),
457 self.recode(33),
458 self.recode(34),
459 self.recode(35),
460 self.recode(36),
461 ]
462 )
464 @staticmethod
465 def format_float_for_display(val: Optional[float]) -> Optional[str]:
466 if val is None:
467 return None
468 return f"{val:.1f}"
470 def score_overall(self) -> Optional[float]:
471 values = []
472 for q in range(1, self.NQUESTIONS + 1):
473 values.append(self.recode(q))
474 return mean(values)
476 @staticmethod
477 def section_row_html(text: str) -> str:
478 return tr_span_col(text, cols=3, tr_class=CssClass.SUBHEADING)
480 def answer_text(
481 self, req: CamcopsRequest, q: int, v: Any
482 ) -> Optional[str]:
483 if v is None:
484 return None
485 # wxstring has its own validity checking, so we can do:
486 if q == 1 or q == 2 or (20 <= q <= 22) or q == 32:
487 return self.wxstring(req, "q" + str(q) + "_option" + str(v))
488 elif 3 <= q <= 12:
489 return self.wxstring(req, "activities_option" + str(v))
490 elif 13 <= q <= 19:
491 return self.wxstring(req, "yesno_option" + str(v))
492 elif 23 <= q <= 31:
493 return self.wxstring(req, "last4weeks_option" + str(v))
494 elif 33 <= q <= 36:
495 return self.wxstring(req, "q33to36_option" + str(v))
496 else:
497 return None
499 def answer_row_html(self, req: CamcopsRequest, q: int) -> str:
500 qtext = self.wxstring(req, "q" + str(q))
501 v = getattr(self, "q" + str(q))
502 atext = self.answer_text(req, q, v)
503 s = self.recode(q)
504 return tr(
505 qtext,
506 answer(v) + ": " + answer(atext),
507 answer(s, formatter_answer=identity),
508 )
510 @staticmethod
511 def scoreline(text: str, footnote_num: int, score: Optional[float]) -> str:
512 return tr(
513 text + f" <sup>[{footnote_num}]</sup>", answer(score) + " / 100"
514 )
516 def get_task_html(self, req: CamcopsRequest) -> str:
517 h = f"""
518 <div class="{CssClass.SUMMARY}">
519 <table class="{CssClass.SUMMARY}">
520 {self.get_is_complete_tr(req)}
521 """
522 h += self.scoreline(
523 self.wxstring(req, "score_overall"),
524 1,
525 self.format_float_for_display(self.score_overall()),
526 )
527 h += self.scoreline(
528 self.wxstring(req, "score_physical_functioning"),
529 2,
530 self.format_float_for_display(self.score_physical_functioning()),
531 )
532 h += self.scoreline(
533 self.wxstring(req, "score_role_limitations_physical"),
534 3,
535 self.format_float_for_display(
536 self.score_role_limitations_physical()
537 ),
538 )
539 h += self.scoreline(
540 self.wxstring(req, "score_role_limitations_emotional"),
541 4,
542 self.format_float_for_display(
543 self.score_role_limitations_emotional()
544 ),
545 )
546 h += self.scoreline(
547 self.wxstring(req, "score_energy"),
548 5,
549 self.format_float_for_display(self.score_energy()),
550 )
551 h += self.scoreline(
552 self.wxstring(req, "score_emotional_wellbeing"),
553 6,
554 self.format_float_for_display(self.score_emotional_wellbeing()),
555 )
556 h += self.scoreline(
557 self.wxstring(req, "score_social_functioning"),
558 7,
559 self.format_float_for_display(self.score_social_functioning()),
560 )
561 h += self.scoreline(
562 self.wxstring(req, "score_pain"),
563 8,
564 self.format_float_for_display(self.score_pain()),
565 )
566 h += self.scoreline(
567 self.wxstring(req, "score_general_health"),
568 9,
569 self.format_float_for_display(self.score_general_health()),
570 )
571 h += f"""
572 </table>
573 </div>
574 <table class="{CssClass.TASKDETAIL}">
575 <tr>
576 <th width="60%">Question</th>
577 <th width="30%">Answer</th>
578 <th width="10%">Score</th>
579 </tr>
580 """
581 for q in range(1, 2 + 1):
582 h += self.answer_row_html(req, q)
583 h += self.section_row_html(self.wxstring(req, "activities_q"))
584 for q in range(3, 12 + 1):
585 h += self.answer_row_html(req, q)
586 h += self.section_row_html(
587 self.wxstring(req, "work_activities_physical_q")
588 )
589 for q in range(13, 16 + 1):
590 h += self.answer_row_html(req, q)
591 h += self.section_row_html(
592 self.wxstring(req, "work_activities_emotional_q")
593 )
594 for q in range(17, 19 + 1):
595 h += self.answer_row_html(req, q)
596 h += self.section_row_html("<br>")
597 h += self.answer_row_html(req, 20)
598 h += self.section_row_html("<br>")
599 for q in range(21, 22 + 1):
600 h += self.answer_row_html(req, q)
601 h += self.section_row_html(
602 self.wxstring(req, "last4weeks_q_a")
603 + " "
604 + self.wxstring(req, "last4weeks_q_b")
605 )
606 for q in range(23, 31 + 1):
607 h += self.answer_row_html(req, q)
608 h += self.section_row_html("<br>")
609 for q in (32,):
610 h += self.answer_row_html(req, q)
611 h += self.section_row_html(self.wxstring(req, "q33to36stem"))
612 for q in range(33, 36 + 1):
613 h += self.answer_row_html(req, q)
614 h += f"""
615 </table>
616 <div class="{CssClass.COPYRIGHT}">
617 The RAND 36-Item Short Form Health Survey was developed at RAND
618 as part of the Medical Outcomes Study. See
619 <a href="https://www.rand.org/health/surveys_tools/mos/mos_core_36item.html">
620 https://www.rand.org/health/surveys_tools/mos/mos_core_36item.html</a>
621 </div>
622 <div class="{CssClass.FOOTNOTES}">
623 All questions are first transformed to a score in the range
624 0–100. Higher scores are always better. Then:
625 [1] Mean of all 36 questions.
626 [2] Mean of Q3–12 inclusive.
627 [3] Q13–16.
628 [4] Q17–19.
629 [5] Q23, 27, 29, 31.
630 [6] Q24, 25, 26, 28, 30.
631 [7] Q20, 32.
632 [8] Q21, 22.
633 [9] Q1, 33–36.
634 </div>
635 """ # noqa
636 return h