Coverage for tasks/demqol.py: 52%

143 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2022-11-08 23:14 +0000

1#!/usr/bin/env python 

2 

3""" 

4camcops_server/tasks/demqol.py 

5 

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

7 

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

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

10 

11 This file is part of CamCOPS. 

12 

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. 

17 

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. 

22 

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

25 

26=============================================================================== 

27 

28""" 

29 

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

31 

32from cardinal_pythonlib.stringfunc import strseq 

33import cardinal_pythonlib.rnc_web as ws 

34from sqlalchemy.ext.declarative import DeclarativeMeta 

35from sqlalchemy.sql.sqltypes import Float, Integer 

36 

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 ( 

41 answer, 

42 get_yes_no, 

43 subheading_spanning_two_columns, 

44 tr_qa, 

45) 

46from camcops_server.cc_modules.cc_request import CamcopsRequest 

47from camcops_server.cc_modules.cc_sqla_coltypes import ( 

48 CamcopsColumn, 

49 PermittedValueChecker, 

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 TaskHasRespondentMixin, 

58) 

59from camcops_server.cc_modules.cc_trackerhelpers import TrackerInfo 

60 

61 

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

63# Constants 

64# ============================================================================= 

65 

66DP = 2 

67MISSING_VALUE = -99 

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

69END_DIV = f""" 

70 </table> 

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

72 [1] Extrapolated total scores are: total_for_responded_questions × 

73 n_questions / n_responses. 

74 </div> 

75""" 

76COPYRIGHT_DIV = f""" 

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

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

79 College London. Reproduced with permission. 

80 </div> 

81""" 

82 

83 

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

85# DEMQOL 

86# ============================================================================= 

87 

88 

89class DemqolMetaclass(DeclarativeMeta): 

90 # noinspection PyInitNewSignature 

91 def __init__( 

92 cls: Type["Demqol"], 

93 name: str, 

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

95 classdict: Dict[str, Any], 

96 ) -> None: 

97 add_multiple_columns( 

98 cls, 

99 "q", 

100 1, 

101 cls.N_SCORED_QUESTIONS, 

102 pv=PERMITTED_VALUES, 

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

104 comment_strings=[ 

105 # 1-13 

106 "cheerful", 

107 "worried/anxious", 

108 "enjoying life", 

109 "frustrated", 

110 "confident", 

111 "full of energy", 

112 "sad", 

113 "lonely", 

114 "distressed", 

115 "lively", 

116 "irritable", 

117 "fed up", 

118 "couldn't do things", 

119 # 14-19 

120 "worried: forget recent", 

121 "worried: forget people", 

122 "worried: forget day", 

123 "worried: muddled", 

124 "worried: difficulty making decisions", 

125 "worried: poor concentration", 

126 # 20-28 

127 "worried: not enough company", 

128 "worried: get on with people close", 

129 "worried: affection", 

130 "worried: people not listening", 

131 "worried: making self understood", 

132 "worried: getting help", 

133 "worried: toilet", 

134 "worried: feel in self", 

135 "worried: health overall", 

136 ], 

137 ) 

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

139 

140 

141class Demqol( 

142 TaskHasPatientMixin, TaskHasClinicianMixin, Task, metaclass=DemqolMetaclass 

143): 

144 """ 

145 Server implementation of the DEMQOL task. 

146 """ 

147 

148 __tablename__ = "demqol" 

149 shortname = "DEMQOL" 

150 provides_trackers = True 

151 

152 q29 = CamcopsColumn( 

153 "q29", 

154 Integer, 

155 permitted_value_checker=PermittedValueChecker( 

156 permitted_values=PERMITTED_VALUES 

157 ), 

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

159 "-99 no response).", 

160 ) 

161 

162 NQUESTIONS = 29 

163 N_SCORED_QUESTIONS = 28 

164 MINIMUM_N_FOR_TOTAL_SCORE = 14 

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

166 MIN_SCORE = N_SCORED_QUESTIONS 

167 MAX_SCORE = MIN_SCORE * 4 

168 

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

170 

171 @staticmethod 

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

173 _ = req.gettext 

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

175 

176 def is_complete(self) -> bool: 

177 return ( 

178 self.all_fields_not_none(self.COMPLETENESS_FIELDS) 

179 and self.field_contents_valid() 

180 ) 

181 

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

183 return [ 

184 TrackerInfo( 

185 value=self.total_score(), 

186 plot_label="DEMQOL total score", 

187 axis_label=( 

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

189 f"higher better)" 

190 ), 

191 axis_min=self.MIN_SCORE - 0.5, 

192 axis_max=self.MAX_SCORE + 0.5, 

193 ) 

194 ] 

195 

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

197 if not self.is_complete(): 

198 return CTV_INCOMPLETE 

199 return [ 

200 CtvInfo( 

201 content=( 

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

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

204 ) 

205 ) 

206 ] 

207 

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

209 return self.standard_task_summary_fields() + [ 

210 SummaryElement( 

211 name="total", 

212 coltype=Float(), 

213 value=self.total_score(), 

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

215 ) 

216 ] 

217 

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

219 return calc_total_score( 

220 obj=self, 

221 n_scored_questions=self.N_SCORED_QUESTIONS, 

222 reverse_score_qs=self.REVERSE_SCORE, 

223 minimum_n_for_total_score=self.MINIMUM_N_FOR_TOTAL_SCORE, 

224 ) 

225 

226 def total_score(self) -> float: 

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

228 return total 

229 

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

231 nstr = str(n) 

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

233 

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

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

236 main_dict = { 

237 None: None, 

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

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

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

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

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

243 } 

244 last_q_dict = { 

245 None: None, 

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

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

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

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

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

251 } 

252 instruction_dict = { 

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

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

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

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

257 } 

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

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

260 h = f""" 

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

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

263 {self.get_is_complete_tr(req)} 

264 <tr> 

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

266 higher better</td> 

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

268 </tr> 

269 <tr> 

270 <td>Total score extrapolated using incomplete 

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

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

273 </tr> 

274 </table> 

275 </div> 

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

277 <tr> 

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

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

280 </tr> 

281 """ 

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

283 if n in instruction_dict: 

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

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

286 q = self.get_q(req, n) 

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

288 h += tr_qa(q, a) 

289 h += END_DIV + COPYRIGHT_DIV 

290 return h 

291 

292 

293# ============================================================================= 

294# DEMQOL-Proxy 

295# ============================================================================= 

296 

297 

298class DemqolProxyMetaclass(DeclarativeMeta): 

299 # noinspection PyInitNewSignature 

300 def __init__( 

301 cls: Type["DemqolProxy"], 

302 name: str, 

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

304 classdict: Dict[str, Any], 

305 ) -> None: 

306 add_multiple_columns( 

307 cls, 

308 "q", 

309 1, 

310 cls.N_SCORED_QUESTIONS, 

311 pv=PERMITTED_VALUES, 

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

313 comment_strings=[ 

314 # 1-11 

315 "cheerful", 

316 "worried/anxious", 

317 "frustrated", 

318 "full of energy", 

319 "sad", 

320 "content", 

321 "distressed", 

322 "lively", 

323 "irritable", 

324 "fed up", 

325 "things to look forward to", 

326 # 12-20 

327 "worried: memory in general", 

328 "worried: forget distant", 

329 "worried: forget recent", 

330 "worried: forget people", 

331 "worried: forget place", 

332 "worried: forget day", 

333 "worried: muddled", 

334 "worried: difficulty making decisions", 

335 "worried: making self understood", 

336 # 21-31 

337 "worried: keeping clean", 

338 "worried: keeping self looking nice", 

339 "worried: shopping", 

340 "worried: using money to pay", 

341 "worried: looking after finances", 

342 "worried: taking longer", 

343 "worried: getting in touch with people", 

344 "worried: not enough company", 

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

346 "worried: not playing a useful part", 

347 "worried: physical health", 

348 ], 

349 ) 

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

351 

352 

353class DemqolProxy( 

354 TaskHasPatientMixin, 

355 TaskHasRespondentMixin, 

356 TaskHasClinicianMixin, 

357 Task, 

358 metaclass=DemqolProxyMetaclass, 

359): 

360 __tablename__ = "demqolproxy" 

361 shortname = "DEMQOL-Proxy" 

362 extrastring_taskname = "demqol" 

363 info_filename_stem = "demqol" 

364 

365 q32 = CamcopsColumn( 

366 "q32", 

367 Integer, 

368 permitted_value_checker=PermittedValueChecker( 

369 permitted_values=PERMITTED_VALUES 

370 ), 

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

372 "-99 no response).", 

373 ) 

374 

375 NQUESTIONS = 32 

376 N_SCORED_QUESTIONS = 31 

377 MINIMUM_N_FOR_TOTAL_SCORE = 16 

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

379 MIN_SCORE = N_SCORED_QUESTIONS 

380 MAX_SCORE = MIN_SCORE * 4 

381 

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

383 

384 @staticmethod 

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

386 _ = req.gettext 

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

388 

389 def is_complete(self) -> bool: 

390 return ( 

391 self.all_fields_not_none(self.COMPLETENESS_FIELDS) 

392 and self.field_contents_valid() 

393 ) 

394 

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

396 return [ 

397 TrackerInfo( 

398 value=self.total_score(), 

399 plot_label="DEMQOL-Proxy total score", 

400 axis_label=( 

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

402 f" higher better)" 

403 ), 

404 axis_min=self.MIN_SCORE - 0.5, 

405 axis_max=self.MAX_SCORE + 0.5, 

406 ) 

407 ] 

408 

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

410 if not self.is_complete(): 

411 return CTV_INCOMPLETE 

412 return [ 

413 CtvInfo( 

414 content=( 

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

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

417 ) 

418 ) 

419 ] 

420 

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

422 return self.standard_task_summary_fields() + [ 

423 SummaryElement( 

424 name="total", 

425 coltype=Float(), 

426 value=self.total_score(), 

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

428 ) 

429 ] 

430 

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

432 return calc_total_score( 

433 obj=self, 

434 n_scored_questions=self.N_SCORED_QUESTIONS, 

435 reverse_score_qs=self.REVERSE_SCORE, 

436 minimum_n_for_total_score=self.MINIMUM_N_FOR_TOTAL_SCORE, 

437 ) 

438 

439 def total_score(self) -> float: 

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

441 return total 

442 

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

444 nstr = str(n) 

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

446 

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

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

449 main_dict = { 

450 None: None, 

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

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

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

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

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

456 } 

457 last_q_dict = { 

458 None: None, 

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

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

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

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

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

464 } 

465 instruction_dict = { 

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

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

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

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

470 } 

471 h = f""" 

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

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

474 {self.get_is_complete_tr(req)} 

475 <tr> 

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

477 higher better</td> 

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

479 </tr> 

480 <tr> 

481 <td>Total score extrapolated using incomplete 

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

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

484 </tr> 

485 </table> 

486 </div> 

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

488 <tr> 

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

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

491 </tr> 

492 """ 

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

494 if n in instruction_dict: 

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

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

497 q = self.get_q(req, n) 

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

499 h += tr_qa(q, a) 

500 h += END_DIV + COPYRIGHT_DIV 

501 return h 

502 

503 

504# ============================================================================= 

505# Common scoring function 

506# ============================================================================= 

507 

508 

509def calc_total_score( 

510 obj: Union[Demqol, DemqolProxy], 

511 n_scored_questions: int, 

512 reverse_score_qs: List[int], 

513 minimum_n_for_total_score: int, 

514) -> Tuple[Optional[float], bool]: 

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

516 n = 0 

517 total = 0 

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

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

520 if x is None or x == MISSING_VALUE: 

521 continue 

522 if q in reverse_score_qs: 

523 x = 5 - x 

524 n += 1 

525 total += x 

526 if n < minimum_n_for_total_score: 

527 return None, False 

528 if n < n_scored_questions: 

529 return n_scored_questions * total / n, True 

530 return total, False