Coverage for tasks/demqol.py: 51%

140 statements  

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

1""" 

2camcops_server/tasks/demqol.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, List, Optional, Tuple, Type, Union 

29 

30from cardinal_pythonlib.stringfunc import strseq 

31import cardinal_pythonlib.rnc_web as ws 

32from sqlalchemy.orm import Mapped 

33from sqlalchemy.sql.sqltypes import Float 

34 

35from camcops_server.cc_modules.cc_constants import CssClass 

36from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo 

37from camcops_server.cc_modules.cc_db import add_multiple_columns 

38from camcops_server.cc_modules.cc_html import ( 

39 answer, 

40 get_yes_no, 

41 subheading_spanning_two_columns, 

42 tr_qa, 

43) 

44from camcops_server.cc_modules.cc_request import CamcopsRequest 

45from camcops_server.cc_modules.cc_sqla_coltypes import ( 

46 mapped_camcops_column, 

47 PermittedValueChecker, 

48) 

49from camcops_server.cc_modules.cc_summaryelement import SummaryElement 

50from camcops_server.cc_modules.cc_task import ( 

51 get_from_dict, 

52 Task, 

53 TaskHasClinicianMixin, 

54 TaskHasPatientMixin, 

55 TaskHasRespondentMixin, 

56) 

57from camcops_server.cc_modules.cc_trackerhelpers import TrackerInfo 

58 

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 

86 

87class Demqol( # type: ignore[misc] 

88 TaskHasPatientMixin, 

89 TaskHasClinicianMixin, 

90 Task, 

91): 

92 """ 

93 Server implementation of the DEMQOL task. 

94 """ 

95 

96 __tablename__ = "demqol" 

97 shortname = "DEMQOL" 

98 provides_trackers = True 

99 

100 @classmethod 

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

102 add_multiple_columns( 

103 cls, 

104 "q", 

105 1, 

106 cls.N_SCORED_QUESTIONS, 

107 pv=PERMITTED_VALUES, 

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

109 comment_strings=[ 

110 # 1-13 

111 "cheerful", 

112 "worried/anxious", 

113 "enjoying life", 

114 "frustrated", 

115 "confident", 

116 "full of energy", 

117 "sad", 

118 "lonely", 

119 "distressed", 

120 "lively", 

121 "irritable", 

122 "fed up", 

123 "couldn't do things", 

124 # 14-19 

125 "worried: forget recent", 

126 "worried: forget people", 

127 "worried: forget day", 

128 "worried: muddled", 

129 "worried: difficulty making decisions", 

130 "worried: poor concentration", 

131 # 20-28 

132 "worried: not enough company", 

133 "worried: get on with people close", 

134 "worried: affection", 

135 "worried: people not listening", 

136 "worried: making self understood", 

137 "worried: getting help", 

138 "worried: toilet", 

139 "worried: feel in self", 

140 "worried: health overall", 

141 ], 

142 ) 

143 

144 q29: Mapped[Optional[int]] = mapped_camcops_column( 

145 permitted_value_checker=PermittedValueChecker( 

146 permitted_values=PERMITTED_VALUES 

147 ), 

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

149 "-99 no response).", 

150 ) 

151 

152 NQUESTIONS = 29 

153 N_SCORED_QUESTIONS = 28 

154 MINIMUM_N_FOR_TOTAL_SCORE = 14 

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

156 MIN_SCORE = N_SCORED_QUESTIONS 

157 MAX_SCORE = MIN_SCORE * 4 

158 

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

160 

161 @staticmethod 

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

163 _ = req.gettext 

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

165 

166 def is_complete(self) -> bool: 

167 return ( 

168 self.all_fields_not_none(self.COMPLETENESS_FIELDS) 

169 and self.field_contents_valid() 

170 ) 

171 

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

173 return [ 

174 TrackerInfo( 

175 value=self.total_score(), 

176 plot_label="DEMQOL total score", 

177 axis_label=( 

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

179 f"higher better)" 

180 ), 

181 axis_min=self.MIN_SCORE - 0.5, 

182 axis_max=self.MAX_SCORE + 0.5, 

183 ) 

184 ] 

185 

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

187 if not self.is_complete(): 

188 return CTV_INCOMPLETE 

189 return [ 

190 CtvInfo( 

191 content=( 

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

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

194 ) 

195 ) 

196 ] 

197 

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

199 return self.standard_task_summary_fields() + [ 

200 SummaryElement( 

201 name="total", 

202 coltype=Float(), 

203 value=self.total_score(), 

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

205 ) 

206 ] 

207 

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

209 return calc_total_score( 

210 obj=self, 

211 n_scored_questions=self.N_SCORED_QUESTIONS, 

212 reverse_score_qs=self.REVERSE_SCORE, 

213 minimum_n_for_total_score=self.MINIMUM_N_FOR_TOTAL_SCORE, 

214 ) 

215 

216 def total_score(self) -> float: 

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

218 return total 

219 

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

221 nstr = str(n) 

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

223 

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

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

226 main_dict = { 

227 None: None, 

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

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

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

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

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

233 } 

234 last_q_dict = { 

235 None: None, 

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

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

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

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

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

241 } 

242 instruction_dict = { 

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

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

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

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

247 } 

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

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

250 h = f""" 

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

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

253 {self.get_is_complete_tr(req)} 

254 <tr> 

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

256 higher better</td> 

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

258 </tr> 

259 <tr> 

260 <td>Total score extrapolated using incomplete 

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

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

263 </tr> 

264 </table> 

265 </div> 

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

267 <tr> 

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

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

270 </tr> 

271 """ 

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

273 if n in instruction_dict: 

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

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

276 q = self.get_q(req, n) 

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

278 h += tr_qa(q, a) 

279 h += END_DIV + COPYRIGHT_DIV 

280 return h 

281 

282 

283# ============================================================================= 

284# DEMQOL-Proxy 

285# ============================================================================= 

286 

287 

288class DemqolProxy( # type: ignore[misc] 

289 TaskHasPatientMixin, 

290 TaskHasRespondentMixin, 

291 TaskHasClinicianMixin, 

292 Task, 

293): 

294 __tablename__ = "demqolproxy" 

295 shortname = "DEMQOL-Proxy" 

296 extrastring_taskname = "demqol" 

297 info_filename_stem = "demqol" 

298 

299 @classmethod 

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

301 add_multiple_columns( 

302 cls, 

303 "q", 

304 1, 

305 cls.N_SCORED_QUESTIONS, 

306 pv=PERMITTED_VALUES, 

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

308 comment_strings=[ 

309 # 1-11 

310 "cheerful", 

311 "worried/anxious", 

312 "frustrated", 

313 "full of energy", 

314 "sad", 

315 "content", 

316 "distressed", 

317 "lively", 

318 "irritable", 

319 "fed up", 

320 "things to look forward to", 

321 # 12-20 

322 "worried: memory in general", 

323 "worried: forget distant", 

324 "worried: forget recent", 

325 "worried: forget people", 

326 "worried: forget place", 

327 "worried: forget day", 

328 "worried: muddled", 

329 "worried: difficulty making decisions", 

330 "worried: making self understood", 

331 # 21-31 

332 "worried: keeping clean", 

333 "worried: keeping self looking nice", 

334 "worried: shopping", 

335 "worried: using money to pay", 

336 "worried: looking after finances", 

337 "worried: taking longer", 

338 "worried: getting in touch with people", 

339 "worried: not enough company", 

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

341 "worried: not playing a useful part", 

342 "worried: physical health", 

343 ], 

344 ) 

345 

346 q32: Mapped[Optional[int]] = mapped_camcops_column( 

347 permitted_value_checker=PermittedValueChecker( 

348 permitted_values=PERMITTED_VALUES 

349 ), 

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

351 "-99 no response).", 

352 ) 

353 

354 NQUESTIONS = 32 

355 N_SCORED_QUESTIONS = 31 

356 MINIMUM_N_FOR_TOTAL_SCORE = 16 

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

358 MIN_SCORE = N_SCORED_QUESTIONS 

359 MAX_SCORE = MIN_SCORE * 4 

360 

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

362 

363 @staticmethod 

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

365 _ = req.gettext 

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

367 

368 def is_complete(self) -> bool: 

369 return ( 

370 self.all_fields_not_none(self.COMPLETENESS_FIELDS) 

371 and self.field_contents_valid() 

372 ) 

373 

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

375 return [ 

376 TrackerInfo( 

377 value=self.total_score(), 

378 plot_label="DEMQOL-Proxy total score", 

379 axis_label=( 

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

381 f" higher better)" 

382 ), 

383 axis_min=self.MIN_SCORE - 0.5, 

384 axis_max=self.MAX_SCORE + 0.5, 

385 ) 

386 ] 

387 

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

389 if not self.is_complete(): 

390 return CTV_INCOMPLETE 

391 return [ 

392 CtvInfo( 

393 content=( 

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

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

396 ) 

397 ) 

398 ] 

399 

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

401 return self.standard_task_summary_fields() + [ 

402 SummaryElement( 

403 name="total", 

404 coltype=Float(), 

405 value=self.total_score(), 

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

407 ) 

408 ] 

409 

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

411 return calc_total_score( 

412 obj=self, 

413 n_scored_questions=self.N_SCORED_QUESTIONS, 

414 reverse_score_qs=self.REVERSE_SCORE, 

415 minimum_n_for_total_score=self.MINIMUM_N_FOR_TOTAL_SCORE, 

416 ) 

417 

418 def total_score(self) -> float: 

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

420 return total 

421 

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

423 nstr = str(n) 

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

425 

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

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

428 main_dict = { 

429 None: None, 

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

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

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

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

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

435 } 

436 last_q_dict = { 

437 None: None, 

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

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

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

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

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

443 } 

444 instruction_dict = { 

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

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

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

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

449 } 

450 h = f""" 

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

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

453 {self.get_is_complete_tr(req)} 

454 <tr> 

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

456 higher better</td> 

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

458 </tr> 

459 <tr> 

460 <td>Total score extrapolated using incomplete 

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

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

463 </tr> 

464 </table> 

465 </div> 

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

467 <tr> 

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

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

470 </tr> 

471 """ 

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

473 if n in instruction_dict: 

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

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

476 q = self.get_q(req, n) 

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

478 h += tr_qa(q, a) 

479 h += END_DIV + COPYRIGHT_DIV 

480 return h 

481 

482 

483# ============================================================================= 

484# Common scoring function 

485# ============================================================================= 

486 

487 

488def calc_total_score( 

489 obj: Union[Demqol, DemqolProxy], 

490 n_scored_questions: int, 

491 reverse_score_qs: List[int], 

492 minimum_n_for_total_score: int, 

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

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

495 n = 0 

496 total = 0 

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

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

499 if x is None or x == MISSING_VALUE: 

500 continue 

501 if q in reverse_score_qs: 

502 x = 5 - x 

503 n += 1 

504 total += x 

505 if n < minimum_n_for_total_score: 

506 return None, False 

507 if n < n_scored_questions: 

508 return n_scored_questions * total / n, True 

509 return total, False