Source code for camcops_server.tasks.rand36

#!/usr/bin/env python

"""
camcops_server/tasks/rand36.py

===============================================================================

    Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
    Created by Rudolf Cardinal (rnc1001@cam.ac.uk).

    This file is part of CamCOPS.

    CamCOPS is free software: you can redistribute it and/or modify
    it under the terms of the GNU General Public License as published by
    the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.

    CamCOPS is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
    GNU General Public License for more details.

    You should have received a copy of the GNU General Public License
    along with CamCOPS. If not, see <https://www.gnu.org/licenses/>.

===============================================================================

"""

from typing import Any, Dict, List, Optional, Tuple, Type

from cardinal_pythonlib.maths_py import mean
from cardinal_pythonlib.stringfunc import strseq
from sqlalchemy.ext.declarative import DeclarativeMeta
from sqlalchemy.sql.sqltypes import Float, Integer

from camcops_server.cc_modules.cc_constants import CssClass
from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo
from camcops_server.cc_modules.cc_db import add_multiple_columns
from camcops_server.cc_modules.cc_html import answer, identity, tr, tr_span_col
from camcops_server.cc_modules.cc_request import CamcopsRequest
from camcops_server.cc_modules.cc_sqla_coltypes import (
    CamcopsColumn,
    ONE_TO_FIVE_CHECKER,
    ONE_TO_SIX_CHECKER,
)
from camcops_server.cc_modules.cc_summaryelement import SummaryElement
from camcops_server.cc_modules.cc_task import Task, TaskHasPatientMixin
from camcops_server.cc_modules.cc_trackerhelpers import TrackerInfo


# =============================================================================
# RAND-36
# =============================================================================


class Rand36Metaclass(DeclarativeMeta):
    # noinspection PyInitNewSignature
    def __init__(
        cls: Type["Rand36"],
        name: str,
        bases: Tuple[Type, ...],
        classdict: Dict[str, Any],
    ) -> None:
        add_multiple_columns(
            cls,
            "q",
            3,
            12,
            minimum=1,
            maximum=3,
            comment_fmt="Q{n} ({s}) (1 limited a lot - 3 not limited at all)",
            comment_strings=[
                "Vigorous activities",
                "Moderate activities",
                "Lifting or carrying groceries",
                "Climbing several flights of stairs",
                "Climbing one flight of stairs",
                "Bending, kneeling, or stooping",
                "Walking more than a mile",
                "Walking several blocks",
                "Walking one block",
                "Bathing or dressing yourself",
            ],
        )
        add_multiple_columns(
            cls,
            "q",
            13,
            16,
            minimum=1,
            maximum=2,
            comment_fmt="Q{n} (physical health: {s}) (1 yes, 2 no)",
            comment_strings=[
                "Cut down work/other activities",
                "Accomplished less than would like",
                "Were limited in the kind of work or other activities",
                "Had difficulty performing the work or other activities",
            ],
        )
        add_multiple_columns(
            cls,
            "q",
            17,
            19,
            minimum=1,
            maximum=2,
            comment_fmt="Q{n} (emotional problems: {s}) (1 yes, 2 no)",
            comment_strings=[
                "Cut down work/other activities",
                "Accomplished less than would like",
                "Didn't do work or other activities as carefully as usual",
                "Had difficulty performing the work or other activities",
            ],
        )
        add_multiple_columns(
            cls,
            "q",
            23,
            31,
            minimum=1,
            maximum=6,
            comment_fmt="Q{n} (past 4 weeks: {s}) (1 all of the time - "
            "6 none of the time)",
            comment_strings=[
                "Did you feel full of pep?",
                "Have you been a very nervous person?",
                "Have you felt so down in the dumps that nothing could cheer "
                "you up?",
                "Have you felt calm and peaceful?",
                "Did you have a lot of energy?",
                "Have you felt downhearted and blue?",
                "Did you feel worn out?",
                "Have you been a happy person?",
                "Did you feel tired?",
            ],
        )
        add_multiple_columns(
            cls,
            "q",
            33,
            36,
            minimum=1,
            maximum=5,
            comment_fmt="Q{n} (how true/false: {s}) (1 definitely true - "
            "5 definitely false)",
            comment_strings=[
                "I seem to get sick a little easier than other people",
                "I am as healthy as anybody I know",
                "I expect my health to get worse",
                "My health is excellent",
            ],
        )
        super().__init__(name, bases, classdict)


[docs]class Rand36(TaskHasPatientMixin, Task, metaclass=Rand36Metaclass): """ Server implementation of the RAND-36 task. """ __tablename__ = "rand36" shortname = "RAND-36" provides_trackers = True NQUESTIONS = 36 q1 = CamcopsColumn( "q1", Integer, permitted_value_checker=ONE_TO_FIVE_CHECKER, comment="Q1 (general health) (1 excellent - 5 poor)", ) q2 = CamcopsColumn( "q2", Integer, permitted_value_checker=ONE_TO_FIVE_CHECKER, comment="Q2 (health cf. 1y ago) (1 much better - 5 much worse)", ) q20 = CamcopsColumn( "q20", Integer, permitted_value_checker=ONE_TO_FIVE_CHECKER, comment="Q20 (past 4 weeks, to what extent physical health/" "emotional problems interfered with social activity) " "(1 not at all - 5 extremely)", ) q21 = CamcopsColumn( "q21", Integer, permitted_value_checker=ONE_TO_SIX_CHECKER, comment="Q21 (past 4 weeks, how much pain (1 none - 6 very severe)", ) q22 = CamcopsColumn( "q22", Integer, permitted_value_checker=ONE_TO_FIVE_CHECKER, comment="Q22 (past 4 weeks, pain interfered with normal activity " "(1 not at all - 5 extremely)", ) q32 = CamcopsColumn( "q32", Integer, permitted_value_checker=ONE_TO_FIVE_CHECKER, comment="Q32 (past 4 weeks, how much of the time has physical " "health/emotional problems interfered with social activities " "(1 all of the time - 5 none of the time)", ) # ... note Q32 extremely similar to Q20. TASK_FIELDS = strseq("q", 1, NQUESTIONS)
[docs] @staticmethod def longname(req: "CamcopsRequest") -> str: _ = req.gettext return _("RAND 36-Item Short Form Health Survey 1.0")
[docs] def is_complete(self) -> bool: return ( self.all_fields_not_none(self.TASK_FIELDS) and self.field_contents_valid() )
@classmethod def tracker_element(cls, value: float, plot_label: str) -> TrackerInfo: return TrackerInfo( value=value, plot_label="RAND-36: " + plot_label, axis_label="Scale score (out of 100)", axis_min=-0.5, axis_max=100.5, )
[docs] def get_trackers(self, req: CamcopsRequest) -> List[TrackerInfo]: return [ self.tracker_element( self.score_overall(), self.wxstring(req, "score_overall") ), self.tracker_element( self.score_physical_functioning(), self.wxstring(req, "score_physical_functioning"), ), self.tracker_element( self.score_role_limitations_physical(), self.wxstring(req, "score_role_limitations_physical"), ), self.tracker_element( self.score_role_limitations_emotional(), self.wxstring(req, "score_role_limitations_emotional"), ), self.tracker_element( self.score_energy(), self.wxstring(req, "score_energy") ), self.tracker_element( self.score_emotional_wellbeing(), self.wxstring(req, "score_emotional_wellbeing"), ), self.tracker_element( self.score_social_functioning(), self.wxstring(req, "score_social_functioning"), ), self.tracker_element( self.score_pain(), self.wxstring(req, "score_pain") ), self.tracker_element( self.score_general_health(), self.wxstring(req, "score_general_health"), ), ]
[docs] def get_clinical_text(self, req: CamcopsRequest) -> List[CtvInfo]: if not self.is_complete(): return CTV_INCOMPLETE return [ CtvInfo( content=( "RAND-36 (scores out of 100, 100 best): overall {ov}, " "physical functioning {pf}, physical role " "limitations {prl}, emotional role limitations {erl}, " "energy {e}, emotional wellbeing {ew}, social " "functioning {sf}, pain {p}, general health {gh}.".format( ov=self.score_overall(), pf=self.score_physical_functioning(), prl=self.score_role_limitations_physical(), erl=self.score_role_limitations_emotional(), e=self.score_energy(), ew=self.score_emotional_wellbeing(), sf=self.score_social_functioning(), p=self.score_pain(), gh=self.score_general_health(), ) ) ) ]
[docs] def get_summaries(self, req: CamcopsRequest) -> List[SummaryElement]: return self.standard_task_summary_fields() + [ SummaryElement( name="overall", coltype=Float(), value=self.score_overall(), comment="Overall mean score (0-100, higher better)", ), SummaryElement( name="physical_functioning", coltype=Float(), value=self.score_physical_functioning(), comment="Physical functioning score (0-100, higher better)", ), SummaryElement( name="role_limitations_physical", coltype=Float(), value=self.score_role_limitations_physical(), comment="Role limitations due to physical health score " "(0-100, higher better)", ), SummaryElement( name="role_limitations_emotional", coltype=Float(), value=self.score_role_limitations_emotional(), comment="Role limitations due to emotional problems score " "(0-100, higher better)", ), SummaryElement( name="energy", coltype=Float(), value=self.score_energy(), comment="Energy/fatigue score (0-100, higher better)", ), SummaryElement( name="emotional_wellbeing", coltype=Float(), value=self.score_emotional_wellbeing(), comment="Emotional well-being score (0-100, higher better)", ), SummaryElement( name="social_functioning", coltype=Float(), value=self.score_social_functioning(), comment="Social functioning score (0-100, higher better)", ), SummaryElement( name="pain", coltype=Float(), value=self.score_pain(), comment="Pain score (0-100, higher better)", ), SummaryElement( name="general_health", coltype=Float(), value=self.score_general_health(), comment="General health score (0-100, higher better)", ), ]
# Scoring def recode(self, q: int) -> Optional[float]: x = getattr(self, "q" + str(q)) # response if x is None or x < 1: return None # http://m.rand.org/content/dam/rand/www/external/health/ # surveys_tools/mos/mos_core_36item_scoring.pdf if q == 1 or q == 2 or q == 20 or q == 22 or q == 34 or q == 36: # 1 becomes 100, 2 => 75, 3 => 50, 4 =>25, 5 => 0 if x > 5: return None return 100 - 25 * (x - 1) elif 3 <= q <= 12: # 1 => 0, 2 => 50, 3 => 100 if x > 3: return None return 50 * (x - 1) elif 13 <= q <= 19: # 1 => 0, 2 => 100 if x > 2: return None return 100 * (x - 1) elif q == 21 or q == 23 or q == 26 or q == 27 or q == 30: # 1 => 100, 2 => 80, 3 => 60, 4 => 40, 5 => 20, 6 => 0 if x > 6: return None return 100 - 20 * (x - 1) elif q == 24 or q == 25 or q == 28 or q == 29 or q == 31: # 1 => 0, 2 => 20, 3 => 40, 4 => 60, 5 => 80, 6 => 100 if x > 6: return None return 20 * (x - 1) elif q == 32 or q == 33 or q == 35: # 1 => 0, 2 => 25, 3 => 50, 4 => 75, 5 => 100 if x > 5: return None return 25 * (x - 1) return None def score_physical_functioning(self) -> Optional[float]: return mean( [ self.recode(3), self.recode(4), self.recode(5), self.recode(6), self.recode(7), self.recode(8), self.recode(9), self.recode(10), self.recode(11), self.recode(12), ] ) def score_role_limitations_physical(self) -> Optional[float]: return mean( [ self.recode(13), self.recode(14), self.recode(15), self.recode(16), ] ) def score_role_limitations_emotional(self) -> Optional[float]: return mean([self.recode(17), self.recode(18), self.recode(19)]) def score_energy(self) -> Optional[float]: return mean( [ self.recode(23), self.recode(27), self.recode(29), self.recode(31), ] ) def score_emotional_wellbeing(self) -> Optional[float]: return mean( [ self.recode(24), self.recode(25), self.recode(26), self.recode(28), self.recode(30), ] ) def score_social_functioning(self) -> Optional[float]: return mean([self.recode(20), self.recode(32)]) def score_pain(self) -> Optional[float]: return mean([self.recode(21), self.recode(22)]) def score_general_health(self) -> Optional[float]: return mean( [ self.recode(1), self.recode(33), self.recode(34), self.recode(35), self.recode(36), ] ) @staticmethod def format_float_for_display(val: Optional[float]) -> Optional[str]: if val is None: return None return f"{val:.1f}" def score_overall(self) -> Optional[float]: values = [] for q in range(1, self.NQUESTIONS + 1): values.append(self.recode(q)) return mean(values) @staticmethod def section_row_html(text: str) -> str: return tr_span_col(text, cols=3, tr_class=CssClass.SUBHEADING) def answer_text( self, req: CamcopsRequest, q: int, v: Any ) -> Optional[str]: if v is None: return None # wxstring has its own validity checking, so we can do: if q == 1 or q == 2 or (20 <= q <= 22) or q == 32: return self.wxstring(req, "q" + str(q) + "_option" + str(v)) elif 3 <= q <= 12: return self.wxstring(req, "activities_option" + str(v)) elif 13 <= q <= 19: return self.wxstring(req, "yesno_option" + str(v)) elif 23 <= q <= 31: return self.wxstring(req, "last4weeks_option" + str(v)) elif 33 <= q <= 36: return self.wxstring(req, "q33to36_option" + str(v)) else: return None def answer_row_html(self, req: CamcopsRequest, q: int) -> str: qtext = self.wxstring(req, "q" + str(q)) v = getattr(self, "q" + str(q)) atext = self.answer_text(req, q, v) s = self.recode(q) return tr( qtext, answer(v) + ": " + answer(atext), answer(s, formatter_answer=identity), ) @staticmethod def scoreline(text: str, footnote_num: int, score: Optional[float]) -> str: return tr( text + f" <sup>[{footnote_num}]</sup>", answer(score) + " / 100" )
[docs] def get_task_html(self, req: CamcopsRequest) -> str: h = f""" <div class="{CssClass.SUMMARY}"> <table class="{CssClass.SUMMARY}"> {self.get_is_complete_tr(req)} """ h += self.scoreline( self.wxstring(req, "score_overall"), 1, self.format_float_for_display(self.score_overall()), ) h += self.scoreline( self.wxstring(req, "score_physical_functioning"), 2, self.format_float_for_display(self.score_physical_functioning()), ) h += self.scoreline( self.wxstring(req, "score_role_limitations_physical"), 3, self.format_float_for_display( self.score_role_limitations_physical() ), ) h += self.scoreline( self.wxstring(req, "score_role_limitations_emotional"), 4, self.format_float_for_display( self.score_role_limitations_emotional() ), ) h += self.scoreline( self.wxstring(req, "score_energy"), 5, self.format_float_for_display(self.score_energy()), ) h += self.scoreline( self.wxstring(req, "score_emotional_wellbeing"), 6, self.format_float_for_display(self.score_emotional_wellbeing()), ) h += self.scoreline( self.wxstring(req, "score_social_functioning"), 7, self.format_float_for_display(self.score_social_functioning()), ) h += self.scoreline( self.wxstring(req, "score_pain"), 8, self.format_float_for_display(self.score_pain()), ) h += self.scoreline( self.wxstring(req, "score_general_health"), 9, self.format_float_for_display(self.score_general_health()), ) h += f""" </table> </div> <table class="{CssClass.TASKDETAIL}"> <tr> <th width="60%">Question</th> <th width="30%">Answer</th> <th width="10%">Score</th> </tr> """ for q in range(1, 2 + 1): h += self.answer_row_html(req, q) h += self.section_row_html(self.wxstring(req, "activities_q")) for q in range(3, 12 + 1): h += self.answer_row_html(req, q) h += self.section_row_html( self.wxstring(req, "work_activities_physical_q") ) for q in range(13, 16 + 1): h += self.answer_row_html(req, q) h += self.section_row_html( self.wxstring(req, "work_activities_emotional_q") ) for q in range(17, 19 + 1): h += self.answer_row_html(req, q) h += self.section_row_html("<br>") h += self.answer_row_html(req, 20) h += self.section_row_html("<br>") for q in range(21, 22 + 1): h += self.answer_row_html(req, q) h += self.section_row_html( self.wxstring(req, "last4weeks_q_a") + " " + self.wxstring(req, "last4weeks_q_b") ) for q in range(23, 31 + 1): h += self.answer_row_html(req, q) h += self.section_row_html("<br>") for q in (32,): h += self.answer_row_html(req, q) h += self.section_row_html(self.wxstring(req, "q33to36stem")) for q in range(33, 36 + 1): h += self.answer_row_html(req, q) h += f""" </table> <div class="{CssClass.COPYRIGHT}"> The RAND 36-Item Short Form Health Survey was developed at RAND as part of the Medical Outcomes Study. See <a href="https://www.rand.org/health/surveys_tools/mos/mos_core_36item.html"> https://www.rand.org/health/surveys_tools/mos/mos_core_36item.html</a> </div> <div class="{CssClass.FOOTNOTES}"> All questions are first transformed to a score in the range 0–100. Higher scores are always better. Then: [1] Mean of all 36 questions. [2] Mean of Q3–12 inclusive. [3] Q13–16. [4] Q17–19. [5] Q23, 27, 29, 31. [6] Q24, 25, 26, 28, 30. [7] Q20, 32. [8] Q21, 22. [9] Q1, 33–36. </div> """ # noqa return h