Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

1#!/usr/bin/env python 

2 

3""" 

4camcops_server/tasks/demqol.py 

5 

6=============================================================================== 

7 

8 Copyright (C) 2012-2020 Rudolf Cardinal (rudolf@pobox.com). 

9 

10 This file is part of CamCOPS. 

11 

12 CamCOPS is free software: you can redistribute it and/or modify 

13 it under the terms of the GNU General Public License as published by 

14 the Free Software Foundation, either version 3 of the License, or 

15 (at your option) any later version. 

16 

17 CamCOPS is distributed in the hope that it will be useful, 

18 but WITHOUT ANY WARRANTY; without even the implied warranty of 

19 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

20 GNU General Public License for more details. 

21 

22 You should have received a copy of the GNU General Public License 

23 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>. 

24 

25=============================================================================== 

26 

27""" 

28 

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

30 

31from cardinal_pythonlib.stringfunc import strseq 

32import cardinal_pythonlib.rnc_web as ws 

33from sqlalchemy.ext.declarative import DeclarativeMeta 

34from sqlalchemy.sql.sqltypes import Float, Integer 

35 

36from camcops_server.cc_modules.cc_constants import CssClass 

37from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo 

38from camcops_server.cc_modules.cc_db import add_multiple_columns 

39from camcops_server.cc_modules.cc_html import ( 

40 answer, 

41 get_yes_no, 

42 subheading_spanning_two_columns, 

43 tr_qa, 

44) 

45from camcops_server.cc_modules.cc_request import CamcopsRequest 

46from camcops_server.cc_modules.cc_sqla_coltypes import ( 

47 CamcopsColumn, 

48 PermittedValueChecker, 

49) 

50from camcops_server.cc_modules.cc_summaryelement import SummaryElement 

51from camcops_server.cc_modules.cc_task import ( 

52 get_from_dict, 

53 Task, 

54 TaskHasClinicianMixin, 

55 TaskHasPatientMixin, 

56 TaskHasRespondentMixin, 

57) 

58from camcops_server.cc_modules.cc_trackerhelpers import TrackerInfo 

59 

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

61# Constants 

62# ============================================================================= 

63 

64DP = 2 

65MISSING_VALUE = -99 

66PERMITTED_VALUES = list(range(1, 4 + 1)) + [MISSING_VALUE] 

67END_DIV = f""" 

68 </table> 

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

70 [1] Extrapolated total scores are: total_for_responded_questions × 

71 n_questions / n_responses. 

72 </div> 

73""" 

74COPYRIGHT_DIV = f""" 

75 <div class="{CssClass.COPYRIGHT}"> 

76 DEMQOL/DEMQOL-Proxy: Copyright © Institute of Psychiatry, King’s 

77 College London. Reproduced with permission. 

78 </div> 

79""" 

80 

81 

82# ============================================================================= 

83# DEMQOL 

84# ============================================================================= 

85 

86class DemqolMetaclass(DeclarativeMeta): 

87 # noinspection PyInitNewSignature 

88 def __init__(cls: Type['Demqol'], 

89 name: str, 

90 bases: Tuple[Type, ...], 

91 classdict: Dict[str, Any]) -> None: 

92 add_multiple_columns( 

93 cls, "q", 1, cls.N_SCORED_QUESTIONS, 

94 pv=PERMITTED_VALUES, 

95 comment_fmt="Q{n}. {s} (1 a lot - 4 not at all; -99 no response)", 

96 comment_strings=[ 

97 # 1-13 

98 "cheerful", "worried/anxious", "enjoying life", "frustrated", 

99 "confident", "full of energy", "sad", "lonely", "distressed", 

100 "lively", "irritable", "fed up", "couldn't do things", 

101 # 14-19 

102 "worried: forget recent", "worried: forget people", 

103 "worried: forget day", "worried: muddled", 

104 "worried: difficulty making decisions", 

105 "worried: poor concentration", 

106 # 20-28 

107 "worried: not enough company", 

108 "worried: get on with people close", 

109 "worried: affection", "worried: people not listening", 

110 "worried: making self understood", "worried: getting help", 

111 "worried: toilet", "worried: feel in self", 

112 "worried: health overall", 

113 ] 

114 ) 

115 super().__init__(name, bases, classdict) 

116 

117 

118class Demqol(TaskHasPatientMixin, TaskHasClinicianMixin, Task, 

119 metaclass=DemqolMetaclass): 

120 """ 

121 Server implementation of the DEMQOL task. 

122 """ 

123 __tablename__ = "demqol" 

124 shortname = "DEMQOL" 

125 provides_trackers = True 

126 

127 q29 = CamcopsColumn( 

128 "q29", Integer, 

129 permitted_value_checker=PermittedValueChecker( 

130 permitted_values=PERMITTED_VALUES), 

131 comment="Q29. Overall quality of life (1 very good - 4 poor; " 

132 "-99 no response)." 

133 ) 

134 

135 NQUESTIONS = 29 

136 N_SCORED_QUESTIONS = 28 

137 MINIMUM_N_FOR_TOTAL_SCORE = 14 

138 REVERSE_SCORE = [1, 3, 5, 6, 10, 29] # questions scored backwards 

139 MIN_SCORE = N_SCORED_QUESTIONS 

140 MAX_SCORE = MIN_SCORE * 4 

141 

142 COMPLETENESS_FIELDS = strseq("q", 1, NQUESTIONS) 

143 

144 @staticmethod 

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

146 _ = req.gettext 

147 return _("Dementia Quality of Life measure, self-report version") 

148 

149 def is_complete(self) -> bool: 

150 return ( 

151 self.all_fields_not_none(self.COMPLETENESS_FIELDS) and 

152 self.field_contents_valid() 

153 ) 

154 

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

156 return [TrackerInfo( 

157 value=self.total_score(), 

158 plot_label="DEMQOL total score", 

159 axis_label=( 

160 f"Total score (range {self.MIN_SCORE}–{self.MAX_SCORE}, " 

161 f"higher better)" 

162 ), 

163 axis_min=self.MIN_SCORE - 0.5, 

164 axis_max=self.MAX_SCORE + 0.5 

165 )] 

166 

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

168 if not self.is_complete(): 

169 return CTV_INCOMPLETE 

170 return [CtvInfo(content=( 

171 f"Total score {ws.number_to_dp(self.total_score(), DP)} " 

172 f"(range {self.MIN_SCORE}–{self.MAX_SCORE}, higher better)" 

173 ))] 

174 

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

176 return self.standard_task_summary_fields() + [ 

177 SummaryElement( 

178 name="total", 

179 coltype=Float(), 

180 value=self.total_score(), 

181 comment=f"Total score ({self.MIN_SCORE}-{self.MAX_SCORE})"), 

182 ] 

183 

184 def totalscore_extrapolated(self) -> Tuple[float, bool]: 

185 return calc_total_score( 

186 obj=self, 

187 n_scored_questions=self.N_SCORED_QUESTIONS, 

188 reverse_score_qs=self.REVERSE_SCORE, 

189 minimum_n_for_total_score=self.MINIMUM_N_FOR_TOTAL_SCORE 

190 ) 

191 

192 def total_score(self) -> float: 

193 (total, extrapolated) = self.totalscore_extrapolated() 

194 return total 

195 

196 def get_q(self, req: CamcopsRequest, n: int) -> str: 

197 nstr = str(n) 

198 return "Q" + nstr + ". " + self.wxstring(req, "proxy_q" + nstr) 

199 

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

201 (total, extrapolated) = self.totalscore_extrapolated() 

202 main_dict = { 

203 None: None, 

204 1: "1 — " + self.wxstring(req, "a1"), 

205 2: "2 — " + self.wxstring(req, "a2"), 

206 3: "3 — " + self.wxstring(req, "a3"), 

207 4: "4 — " + self.wxstring(req, "a4"), 

208 MISSING_VALUE: self.wxstring(req, "no_response") 

209 } 

210 last_q_dict = { 

211 None: None, 

212 1: "1 — " + self.wxstring(req, "q29_a1"), 

213 2: "2 — " + self.wxstring(req, "q29_a2"), 

214 3: "3 — " + self.wxstring(req, "q29_a3"), 

215 4: "4 — " + self.wxstring(req, "q29_a4"), 

216 MISSING_VALUE: self.wxstring(req, "no_response") 

217 } 

218 instruction_dict = { 

219 1: self.wxstring(req, "instruction11"), 

220 14: self.wxstring(req, "instruction12"), 

221 20: self.wxstring(req, "instruction13"), 

222 29: self.wxstring(req, "instruction14"), 

223 } 

224 # https://docs.python.org/2/library/stdtypes.html#mapping-types-dict 

225 # http://paltman.com/try-except-performance-in-python-a-simple-test/ 

226 h = f""" 

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

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

229 {self.get_is_complete_tr(req)} 

230 <tr> 

231 <td>Total score ({self.MIN_SCORE}–{self.MAX_SCORE}), 

232 higher better</td> 

233 <td>{answer(ws.number_to_dp(total, DP))}</td> 

234 </tr> 

235 <tr> 

236 <td>Total score extrapolated using incomplete 

237 responses? <sup>[1]</sup></td> 

238 <td>{answer(get_yes_no(req, extrapolated))}</td> 

239 </tr> 

240 </table> 

241 </div> 

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

243 <tr> 

244 <th width="50%">Question</th> 

245 <th width="50%">Answer</th> 

246 </tr> 

247 """ 

248 for n in range(1, self.NQUESTIONS + 1): 

249 if n in instruction_dict: 

250 h += subheading_spanning_two_columns(instruction_dict.get(n)) 

251 d = main_dict if n <= self.N_SCORED_QUESTIONS else last_q_dict 

252 q = self.get_q(req, n) 

253 a = get_from_dict(d, getattr(self, "q" + str(n))) 

254 h += tr_qa(q, a) 

255 h += END_DIV + COPYRIGHT_DIV 

256 return h 

257 

258 

259# ============================================================================= 

260# DEMQOL-Proxy 

261# ============================================================================= 

262 

263class DemqolProxyMetaclass(DeclarativeMeta): 

264 # noinspection PyInitNewSignature 

265 def __init__(cls: Type['DemqolProxy'], 

266 name: str, 

267 bases: Tuple[Type, ...], 

268 classdict: Dict[str, Any]) -> None: 

269 add_multiple_columns( 

270 cls, "q", 1, cls.N_SCORED_QUESTIONS, 

271 pv=PERMITTED_VALUES, 

272 comment_fmt="Q{n}. {s} (1 a lot - 4 not at all; -99 no response)", 

273 comment_strings=[ 

274 # 1-11 

275 "cheerful", "worried/anxious", "frustrated", "full of energy", 

276 "sad", "content", "distressed", "lively", "irritable", 

277 "fed up", "things to look forward to", 

278 # 12-20 

279 "worried: memory in general", "worried: forget distant", 

280 "worried: forget recent", "worried: forget people", 

281 "worried: forget place", "worried: forget day", 

282 "worried: muddled", 

283 "worried: difficulty making decisions", 

284 "worried: making self understood", 

285 # 21-31 

286 "worried: keeping clean", "worried: keeping self looking nice", 

287 "worried: shopping", "worried: using money to pay", 

288 "worried: looking after finances", "worried: taking longer", 

289 "worried: getting in touch with people", 

290 "worried: not enough company", 

291 "worried: not being able to help others", 

292 "worried: not playing a useful part", 

293 "worried: physical health", 

294 ] 

295 ) 

296 super().__init__(name, bases, classdict) 

297 

298 

299class DemqolProxy(TaskHasPatientMixin, TaskHasRespondentMixin, 

300 TaskHasClinicianMixin, Task, 

301 metaclass=DemqolProxyMetaclass): 

302 __tablename__ = "demqolproxy" 

303 shortname = "DEMQOL-Proxy" 

304 extrastring_taskname = "demqol" 

305 

306 q32 = CamcopsColumn( 

307 "q32", Integer, 

308 permitted_value_checker=PermittedValueChecker( 

309 permitted_values=PERMITTED_VALUES), 

310 comment="Q32. Overall quality of life (1 very good - 4 poor; " 

311 "-99 no response)." 

312 ) 

313 

314 NQUESTIONS = 32 

315 N_SCORED_QUESTIONS = 31 

316 MINIMUM_N_FOR_TOTAL_SCORE = 16 

317 REVERSE_SCORE = [1, 4, 6, 8, 11, 32] # questions scored backwards 

318 MIN_SCORE = N_SCORED_QUESTIONS 

319 MAX_SCORE = MIN_SCORE * 4 

320 

321 COMPLETENESS_FIELDS = strseq("q", 1, NQUESTIONS) 

322 

323 @staticmethod 

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

325 _ = req.gettext 

326 return _("Dementia Quality of Life measure, proxy version") 

327 

328 def is_complete(self) -> bool: 

329 return ( 

330 self.all_fields_not_none(self.COMPLETENESS_FIELDS) and 

331 self.field_contents_valid() 

332 ) 

333 

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

335 return [TrackerInfo( 

336 value=self.total_score(), 

337 plot_label="DEMQOL-Proxy total score", 

338 axis_label=( 

339 f"Total score (range {self.MIN_SCORE}–{self.MAX_SCORE}," 

340 f" higher better)" 

341 ), 

342 axis_min=self.MIN_SCORE - 0.5, 

343 axis_max=self.MAX_SCORE + 0.5 

344 )] 

345 

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

347 if not self.is_complete(): 

348 return CTV_INCOMPLETE 

349 return [CtvInfo(content=( 

350 f"Total score {ws.number_to_dp(self.total_score(), DP)} " 

351 f"(range {self.MIN_SCORE}–{self.MAX_SCORE}, higher better)" 

352 ))] 

353 

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

355 return self.standard_task_summary_fields() + [ 

356 SummaryElement( 

357 name="total", 

358 coltype=Float(), 

359 value=self.total_score(), 

360 comment=f"Total score ({self.MIN_SCORE}-{self.MAX_SCORE})"), 

361 ] 

362 

363 def totalscore_extrapolated(self) -> Tuple[float, bool]: 

364 return calc_total_score( 

365 obj=self, 

366 n_scored_questions=self.N_SCORED_QUESTIONS, 

367 reverse_score_qs=self.REVERSE_SCORE, 

368 minimum_n_for_total_score=self.MINIMUM_N_FOR_TOTAL_SCORE 

369 ) 

370 

371 def total_score(self) -> float: 

372 (total, extrapolated) = self.totalscore_extrapolated() 

373 return total 

374 

375 def get_q(self, req: CamcopsRequest, n: int) -> str: 

376 nstr = str(n) 

377 return "Q" + nstr + ". " + self.wxstring(req, "proxy_q" + nstr) 

378 

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

380 (total, extrapolated) = self.totalscore_extrapolated() 

381 main_dict = { 

382 None: None, 

383 1: "1 — " + self.wxstring(req, "a1"), 

384 2: "2 — " + self.wxstring(req, "a2"), 

385 3: "3 — " + self.wxstring(req, "a3"), 

386 4: "4 — " + self.wxstring(req, "a4"), 

387 MISSING_VALUE: self.wxstring(req, "no_response") 

388 } 

389 last_q_dict = { 

390 None: None, 

391 1: "1 — " + self.wxstring(req, "q29_a1"), 

392 2: "2 — " + self.wxstring(req, "q29_a2"), 

393 3: "3 — " + self.wxstring(req, "q29_a3"), 

394 4: "4 — " + self.wxstring(req, "q29_a4"), 

395 MISSING_VALUE: self.wxstring(req, "no_response") 

396 } 

397 instruction_dict = { 

398 1: self.wxstring(req, "proxy_instruction11"), 

399 12: self.wxstring(req, "proxy_instruction12"), 

400 21: self.wxstring(req, "proxy_instruction13"), 

401 32: self.wxstring(req, "proxy_instruction14"), 

402 } 

403 h = f""" 

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

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

406 {self.get_is_complete_tr(req)} 

407 <tr> 

408 <td>Total score ({self.MIN_SCORE}–{self.MAX_SCORE}), 

409 higher better</td> 

410 <td>{answer(ws.number_to_dp(total, DP))}</td> 

411 </tr> 

412 <tr> 

413 <td>Total score extrapolated using incomplete 

414 responses? <sup>[1]</sup></td> 

415 <td>{answer(get_yes_no(req, extrapolated))}</td> 

416 </tr> 

417 </table> 

418 </div> 

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

420 <tr> 

421 <th width="50%">Question</th> 

422 <th width="50%">Answer</th> 

423 </tr> 

424 """ 

425 for n in range(1, self.NQUESTIONS + 1): 

426 if n in instruction_dict: 

427 h += subheading_spanning_two_columns(instruction_dict.get(n)) 

428 d = main_dict if n <= self.N_SCORED_QUESTIONS else last_q_dict 

429 q = self.get_q(req, n) 

430 a = get_from_dict(d, getattr(self, "q" + str(n))) 

431 h += tr_qa(q, a) 

432 h += END_DIV + COPYRIGHT_DIV 

433 return h 

434 

435 

436# ============================================================================= 

437# Common scoring function 

438# ============================================================================= 

439 

440def calc_total_score(obj: Union[Demqol, DemqolProxy], 

441 n_scored_questions: int, 

442 reverse_score_qs: List[int], 

443 minimum_n_for_total_score: int) \ 

444 -> Tuple[Optional[float], bool]: 

445 """Returns (total, extrapolated?).""" 

446 n = 0 

447 total = 0 

448 for q in range(1, n_scored_questions + 1): 

449 x = getattr(obj, "q" + str(q)) 

450 if x is None or x == MISSING_VALUE: 

451 continue 

452 if q in reverse_score_qs: 

453 x = 5 - x 

454 n += 1 

455 total += x 

456 if n < minimum_n_for_total_score: 

457 return None, False 

458 if n < n_scored_questions: 

459 return n_scored_questions * total / n, True 

460 return total, False