Coverage for tasks/demqol.py: 51%

1"""

2 camcops_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

28 from typing import Any, List, Optional, Tuple, Type, Union

29

30 from cardinal_pythonlib.stringfunc import strseq

31 import cardinal_pythonlib.rnc_web as ws

32 from sqlalchemy.orm import Mapped

33 from sqlalchemy.sql.sqltypes import Float

34

35 from camcops_server.cc_modules.cc_constants import CssClass

36 from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo

37 from camcops_server.cc_modules.cc_db import add_multiple_columns

38 from camcops_server.cc_modules.cc_html import (

39 answer,

40 get_yes_no,

41 subheading_spanning_two_columns,

42 tr_qa,

43)

44 from camcops_server.cc_modules.cc_request import CamcopsRequest

45 from camcops_server.cc_modules.cc_sqla_coltypes import (

46 mapped_camcops_column,

47 PermittedValueChecker,

48)

49 from camcops_server.cc_modules.cc_summaryelement import SummaryElement

50 from camcops_server.cc_modules.cc_task import (

51 get_from_dict,

52 Task,

53 TaskHasClinicianMixin,

54 TaskHasPatientMixin,

55 TaskHasRespondentMixin,

56)

57 from camcops_server.cc_modules.cc_trackerhelpers import TrackerInfo

58

59

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

61# Constants

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

63

64 DP = 2

65 MISSING_VALUE = -99

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

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

74 COPYRIGHT_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

87 class 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

288 class 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

488 def 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