Coverage for tasks/eq5d5l.py: 56%

62 statements  

« prev     ^ index     » next       coverage.py v7.9.2, created at 2025-07-15 14:23 +0100

1# camcops_server/tasks/eq5d5l.py 

2 

3""" 

4=============================================================================== 

5 

6 Copyright (C) 2012, University of Cambridge, Department of Psychiatry. 

7 Created by Rudolf Cardinal (rnc1001@cam.ac.uk). 

8 

9 This file is part of CamCOPS. 

10 

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. 

15 

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. 

20 

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/>. 

23 

24=============================================================================== 

25 

26- By Joe Kearney, Rudolf Cardinal. 

27 

28""" 

29 

30from typing import List, Optional 

31 

32from cardinal_pythonlib.stringfunc import strseq 

33from sqlalchemy.orm import Mapped 

34from sqlalchemy.sql.sqltypes import Integer, String 

35 

36from camcops_server.cc_modules.cc_constants import CssClass 

37from camcops_server.cc_modules.cc_html import tr_qa 

38from camcops_server.cc_modules.cc_request import CamcopsRequest 

39from camcops_server.cc_modules.cc_snomed import SnomedExpression, SnomedLookup 

40from camcops_server.cc_modules.cc_sqla_coltypes import ( 

41 mapped_camcops_column, 

42 ONE_TO_FIVE_CHECKER, 

43 ZERO_TO_100_CHECKER, 

44) 

45from camcops_server.cc_modules.cc_summaryelement import SummaryElement 

46from camcops_server.cc_modules.cc_task import ( 

47 get_from_dict, 

48 Task, 

49 TaskHasPatientMixin, 

50) 

51from camcops_server.cc_modules.cc_trackerhelpers import ( 

52 equally_spaced_int, 

53 regular_tracker_axis_ticks_int, 

54 TrackerInfo, 

55) 

56 

57 

58# ============================================================================= 

59# EQ-5D-5L 

60# ============================================================================= 

61 

62 

63class Eq5d5l(TaskHasPatientMixin, Task): # type: ignore[misc] 

64 """ 

65 Server implementation of the EQ-5D-5L task. 

66 

67 Note that the "index value" summary (e.g. SNOMED-CT code 736534008) is not 

68 implemented. This is a country-specific conversion of the raw values to a 

69 unitary health value; see 

70 

71 - https://euroqol.org/publications/key-euroqol-references/value-sets/ 

72 - https://euroqol.org/eq-5d-instruments/eq-5d-3l-about/valuation/choosing-a-value-set/ 

73 """ # noqa 

74 

75 __tablename__ = "eq5d5l" 

76 shortname = "EQ-5D-5L" 

77 provides_trackers = True 

78 

79 q1: Mapped[Optional[int]] = mapped_camcops_column( 

80 comment="Q1 (mobility) (1 no problems - 5 unable)", 

81 permitted_value_checker=ONE_TO_FIVE_CHECKER, 

82 ) 

83 

84 q2: Mapped[Optional[int]] = mapped_camcops_column( 

85 comment="Q2 (self-care) (1 no problems - 5 unable)", 

86 permitted_value_checker=ONE_TO_FIVE_CHECKER, 

87 ) 

88 

89 q3: Mapped[Optional[int]] = mapped_camcops_column( 

90 comment="Q3 (usual activities) (1 no problems - 5 unable)", 

91 permitted_value_checker=ONE_TO_FIVE_CHECKER, 

92 ) 

93 

94 q4: Mapped[Optional[int]] = mapped_camcops_column( 

95 comment="Q4 (pain/discomfort) (1 none - 5 extreme)", 

96 permitted_value_checker=ONE_TO_FIVE_CHECKER, 

97 ) 

98 

99 q5: Mapped[Optional[int]] = mapped_camcops_column( 

100 comment="Q5 (anxiety/depression) (1 not - 5 extremely)", 

101 permitted_value_checker=ONE_TO_FIVE_CHECKER, 

102 ) 

103 

104 health_vas: Mapped[Optional[int]] = mapped_camcops_column( 

105 comment="Visual analogue scale for overall health (0 worst - 100 best)", # noqa 

106 permitted_value_checker=ZERO_TO_100_CHECKER, 

107 ) 

108 

109 N_QUESTIONS = 5 

110 MISSING_ANSWER_VALUE = 9 

111 QUESTIONS = strseq("q", 1, N_QUESTIONS) 

112 QUESTIONS += ["health_vas"] 

113 

114 @staticmethod 

115 def longname(req: "CamcopsRequest") -> str: 

116 _ = req.gettext 

117 return _("EuroQol 5-Dimension, 5-Level Health Scale") 

118 

119 def is_complete(self) -> bool: 

120 return self.all_fields_not_none(self.QUESTIONS) 

121 

122 def get_trackers(self, req: CamcopsRequest) -> List[TrackerInfo]: 

123 return [ 

124 TrackerInfo( 

125 value=self.health_vas, 

126 plot_label="EQ-5D-5L health visual analogue scale", 

127 axis_label="Self-rated health today (out of 100)", 

128 axis_min=-0.5, 

129 axis_max=100.5, 

130 axis_ticks=regular_tracker_axis_ticks_int(0, 100, 25), 

131 horizontal_lines=equally_spaced_int(0, 100, 25), 

132 ) 

133 ] 

134 

135 def get_summaries(self, req: CamcopsRequest) -> List[SummaryElement]: 

136 return self.standard_task_summary_fields() + [ 

137 SummaryElement( 

138 name="health_state", 

139 coltype=String(length=5), 

140 value=self.get_health_state_code(), 

141 comment="Health state as a 5-character string of numbers, " 

142 "with 9 indicating a missing value", 

143 ), 

144 SummaryElement( 

145 name="visual_task_score", 

146 coltype=Integer(), 

147 value=self.get_vis_score_or_999(), 

148 comment="Visual analogue health score " 

149 "(0-100, with 999 replacing None)", 

150 ), 

151 ] 

152 

153 def get_health_state_code(self) -> str: 

154 mcq = "" 

155 for i in range(1, self.N_QUESTIONS + 1): 

156 ans = getattr(self, "q" + str(i)) 

157 if ans is None: 

158 mcq += str(self.MISSING_ANSWER_VALUE) 

159 else: 

160 mcq += str(ans) 

161 return mcq 

162 

163 def get_vis_score_or_999(self) -> int: 

164 vis_score = self.health_vas 

165 if vis_score is None: 

166 return 999 

167 return vis_score 

168 

169 def get_task_html(self, req: CamcopsRequest) -> str: 

170 q_a = "" 

171 

172 for i in range(1, self.N_QUESTIONS + 1): 

173 nstr = str(i) 

174 answers = { 

175 None: None, 

176 1: "1 – " + self.wxstring(req, "q" + nstr + "_o1"), 

177 2: "2 – " + self.wxstring(req, "q" + nstr + "_o2"), 

178 3: "3 – " + self.wxstring(req, "q" + nstr + "_o3"), 

179 4: "4 – " + self.wxstring(req, "q" + nstr + "_o4"), 

180 5: "5 – " + self.wxstring(req, "q" + nstr + "_o5"), 

181 } 

182 

183 q_a += tr_qa( 

184 nstr + ". " + self.wxstring(req, "q" + nstr + "_h"), 

185 get_from_dict(answers, getattr(self, "q" + str(i))), 

186 ) 

187 

188 q_a += tr_qa( 

189 ( 

190 "Self-rated health on a visual analogue scale (0–100) " 

191 "<sup>[2]</sup>" 

192 ), 

193 self.health_vas, 

194 ) 

195 

196 return f""" 

197 <div class="{CssClass.SUMMARY}"> 

198 <table class="{CssClass.SUMMARY}"> 

199 {self.get_is_complete_tr(req)} 

200 {tr_qa("Health state code <sup>[1]</sup>", 

201 self.get_health_state_code())} 

202 {tr_qa("Visual analogue scale summary number <sup>[2]</sup>", 

203 self.get_vis_score_or_999())} 

204 </table> 

205 </div> 

206 <table class="{CssClass.TASKDETAIL}"> 

207 <tr> 

208 <th width="60%">Question</th> 

209 <th width="40%">Answer</th> 

210 </tr> 

211 {q_a} 

212 </table> 

213 <div class="{CssClass.FOOTNOTES}"> 

214 [1] This is a string of digits, not a directly meaningful 

215 number. Each digit corresponds to a question. 

216 A score of 1 indicates no problems in any given dimension. 

217 5 indicates extreme problems. Missing values are 

218 coded as 9. 

219 [2] This is the visual analogue health score, with missing 

220 values coded as 999. 

221 </div> 

222 """ # noqa 

223 

224 def get_snomed_codes(self, req: CamcopsRequest) -> List[SnomedExpression]: 

225 codes = [ 

226 SnomedExpression( 

227 req.snomed(SnomedLookup.EQ5D5L_PROCEDURE_ASSESSMENT) 

228 ) 

229 ] 

230 if self.is_complete(): 

231 codes.append( 

232 SnomedExpression( 

233 req.snomed(SnomedLookup.EQ5D5L_SCALE), 

234 { 

235 # SnomedLookup.EQ5D5L_INDEX_VALUE: not used; see docstring above # noqa 

236 req.snomed( 

237 SnomedLookup.EQ5D5L_MOBILITY_SCORE 

238 ): self.q1, 

239 req.snomed( 

240 SnomedLookup.EQ5D5L_SELF_CARE_SCORE 

241 ): self.q2, 

242 req.snomed( 

243 SnomedLookup.EQ5D5L_USUAL_ACTIVITIES_SCORE 

244 ): self.q3, 

245 req.snomed( 

246 SnomedLookup.EQ5D5L_PAIN_DISCOMFORT_SCORE 

247 ): self.q4, 

248 req.snomed( 

249 SnomedLookup.EQ5D5L_ANXIETY_DEPRESSION_SCORE 

250 ): self.q5, 

251 }, 

252 ) 

253 ) 

254 return codes