Coverage for tasks/ciwa.py: 53%

77 statements  

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

1""" 

2camcops_server/tasks/ciwa.py 

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""" 

27 

28from typing import Any, cast, List, Optional, Type 

29 

30from cardinal_pythonlib.stringfunc import strseq 

31from sqlalchemy.orm import Mapped, mapped_column 

32from sqlalchemy.sql.sqltypes import Integer 

33 

34from camcops_server.cc_modules.cc_constants import CssClass 

35from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo 

36from camcops_server.cc_modules.cc_db import add_multiple_columns 

37from camcops_server.cc_modules.cc_html import ( 

38 answer, 

39 subheading_spanning_two_columns, 

40 tr, 

41 tr_qa, 

42) 

43from camcops_server.cc_modules.cc_request import CamcopsRequest 

44from camcops_server.cc_modules.cc_snomed import SnomedExpression, SnomedLookup 

45from camcops_server.cc_modules.cc_sqla_coltypes import ( 

46 mapped_camcops_column, 

47 MIN_ZERO_CHECKER, 

48 PermittedValueChecker, 

49 SummaryCategoryColType, 

50) 

51from camcops_server.cc_modules.cc_summaryelement import SummaryElement 

52from camcops_server.cc_modules.cc_task import ( 

53 get_from_dict, 

54 Task, 

55 TaskHasClinicianMixin, 

56 TaskHasPatientMixin, 

57) 

58from camcops_server.cc_modules.cc_text import SS 

59from camcops_server.cc_modules.cc_trackerhelpers import ( 

60 TrackerLabel, 

61 TrackerInfo, 

62) 

63 

64 

65# ============================================================================= 

66# CIWA 

67# ============================================================================= 

68 

69 

70class Ciwa( # type: ignore[misc] 

71 TaskHasPatientMixin, 

72 TaskHasClinicianMixin, 

73 Task, 

74): 

75 """ 

76 Server implementation of the CIWA-Ar task. 

77 """ 

78 

79 __tablename__ = "ciwa" 

80 shortname = "CIWA-Ar" 

81 provides_trackers = True 

82 

83 NSCOREDQUESTIONS = 10 

84 

85 @classmethod 

86 def extend_columns(cls: Type["Ciwa"], **kwargs: Any) -> None: 

87 add_multiple_columns( 

88 cls, 

89 "q", 

90 1, 

91 cls.NSCOREDQUESTIONS - 1, 

92 minimum=0, 

93 maximum=7, 

94 comment_fmt="Q{n}, {s} (0-7, higher worse)", 

95 comment_strings=[ 

96 "nausea/vomiting", 

97 "tremor", 

98 "paroxysmal sweats", 

99 "anxiety", 

100 "agitation", 

101 "tactile disturbances", 

102 "auditory disturbances", 

103 "visual disturbances", 

104 "headache/fullness in head", 

105 ], 

106 ) 

107 

108 SCORED_QUESTIONS = strseq("q", 1, NSCOREDQUESTIONS) 

109 

110 q10: Mapped[Optional[int]] = mapped_camcops_column( 

111 permitted_value_checker=PermittedValueChecker(minimum=0, maximum=4), 

112 comment="Q10, orientation/clouding of sensorium (0-4, higher worse)", 

113 ) 

114 t: Mapped[Optional[float]] = mapped_column( 

115 comment="Temperature (degrees C)" 

116 ) 

117 hr: Mapped[Optional[int]] = mapped_camcops_column( 

118 permitted_value_checker=MIN_ZERO_CHECKER, 

119 comment="Heart rate (beats/minute)", 

120 ) 

121 sbp: Mapped[Optional[int]] = mapped_camcops_column( 

122 permitted_value_checker=MIN_ZERO_CHECKER, 

123 comment="Systolic blood pressure (mmHg)", 

124 ) 

125 dbp: Mapped[Optional[int]] = mapped_camcops_column( 

126 permitted_value_checker=MIN_ZERO_CHECKER, 

127 comment="Diastolic blood pressure (mmHg)", 

128 ) 

129 rr: Mapped[Optional[int]] = mapped_camcops_column( 

130 permitted_value_checker=MIN_ZERO_CHECKER, 

131 comment="Respiratory rate (breaths/minute)", 

132 ) 

133 

134 MAX_SCORE = 67 

135 

136 @staticmethod 

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

138 _ = req.gettext 

139 return _( 

140 "Clinical Institute Withdrawal Assessment for Alcohol " 

141 "Scale, Revised" 

142 ) 

143 

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

145 return [ 

146 TrackerInfo( 

147 value=self.total_score(), 

148 plot_label="CIWA total score", 

149 axis_label=f"Total score (out of {self.MAX_SCORE})", 

150 axis_min=-0.5, 

151 axis_max=self.MAX_SCORE + 0.5, 

152 horizontal_lines=[14.5, 7.5], 

153 horizontal_labels=[ 

154 TrackerLabel(17, req.sstring(SS.SEVERE)), 

155 TrackerLabel(11, req.sstring(SS.MODERATE)), 

156 TrackerLabel(3.75, req.sstring(SS.MILD)), 

157 ], 

158 ) 

159 ] 

160 

161 def get_clinical_text(self, req: CamcopsRequest) -> List[CtvInfo]: 

162 if not self.is_complete(): 

163 return CTV_INCOMPLETE 

164 return [ 

165 CtvInfo( 

166 content=f"CIWA total score: " 

167 f"{self.total_score()}/{self.MAX_SCORE}" 

168 ) 

169 ] 

170 

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

172 return self.standard_task_summary_fields() + [ 

173 SummaryElement( 

174 name="total", 

175 coltype=Integer(), 

176 value=self.total_score(), 

177 comment=f"Total score (/{self.MAX_SCORE})", 

178 ), 

179 SummaryElement( 

180 name="severity", 

181 coltype=SummaryCategoryColType, 

182 value=self.severity(req), 

183 comment="Likely severity", 

184 ), 

185 ] 

186 

187 def is_complete(self) -> bool: 

188 return ( 

189 self.all_fields_not_none(self.SCORED_QUESTIONS) 

190 and self.field_contents_valid() 

191 ) 

192 

193 def total_score(self) -> int: 

194 return cast(int, self.sum_fields(self.SCORED_QUESTIONS)) 

195 

196 def severity(self, req: CamcopsRequest) -> str: 

197 score = self.total_score() 

198 if score >= 15: 

199 severity = self.wxstring(req, "category_severe") 

200 elif score >= 8: 

201 severity = self.wxstring(req, "category_moderate") 

202 else: 

203 severity = self.wxstring(req, "category_mild") 

204 return severity 

205 

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

207 score = self.total_score() 

208 severity = self.severity(req) 

209 answer_dicts_dict = {} 

210 for q in self.SCORED_QUESTIONS: 

211 d: dict[Optional[int], Optional[str]] = {None: None} 

212 for option in range(0, 8): 

213 if option > 4 and q == "q10": 

214 continue 

215 d[option] = self.wxstring(req, q + "_option" + str(option)) 

216 answer_dicts_dict[q] = d 

217 q_a = "" 

218 for q in range(1, Ciwa.NSCOREDQUESTIONS + 1): 

219 q_a += tr_qa( 

220 self.wxstring(req, "q" + str(q) + "_s"), 

221 get_from_dict( 

222 answer_dicts_dict["q" + str(q)], 

223 getattr(self, "q" + str(q)), 

224 ), 

225 ) 

226 tr_total_score = tr( 

227 req.sstring(SS.TOTAL_SCORE), answer(score) + f" / {self.MAX_SCORE}" 

228 ) 

229 tr_severity = tr_qa( 

230 self.wxstring(req, "severity") + " <sup>[1]</sup>", severity 

231 ) 

232 return f""" 

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

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

235 {self.get_is_complete_tr(req)} 

236 {tr_total_score} 

237 {tr_severity} 

238 </table> 

239 </div> 

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

241 <tr> 

242 <th width="35%">Question</th> 

243 <th width="65%">Answer</th> 

244 </tr> 

245 {q_a} 

246 {subheading_spanning_two_columns( 

247 self.wxstring(req, "vitals_title"))} 

248 {tr_qa(self.wxstring(req, "t"), self.t)} 

249 {tr_qa(self.wxstring(req, "hr"), self.hr)} 

250 {tr(self.wxstring(req, "bp"), 

251 answer(self.sbp) + " / " + answer(self.dbp))} 

252 {tr_qa(self.wxstring(req, "rr"), self.rr)} 

253 </table> 

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

255 [1] Total score ≥15 severe, ≥8 moderate, otherwise 

256 mild/minimal. 

257 </div> 

258 """ 

259 

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

261 codes = [ 

262 SnomedExpression( 

263 req.snomed(SnomedLookup.CIWA_AR_PROCEDURE_ASSESSMENT) 

264 ) 

265 ] 

266 if self.is_complete(): 

267 codes.append( 

268 SnomedExpression( 

269 req.snomed(SnomedLookup.CIWA_AR_SCALE), 

270 { 

271 req.snomed( 

272 SnomedLookup.CIWA_AR_SCORE 

273 ): self.total_score() 

274 }, 

275 ) 

276 ) 

277 return codes