Coverage for tasks/demqol.py: 51%
140 statements
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-15 14:23 +0100
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-15 14:23 +0100
1"""
2camcops_server/tasks/demqol.py
4===============================================================================
6 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
7 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
9 This file is part of CamCOPS.
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.
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.
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/>.
24===============================================================================
26"""
28from typing import Any, List, Optional, Tuple, Type, Union
30from cardinal_pythonlib.stringfunc import strseq
31import cardinal_pythonlib.rnc_web as ws
32from sqlalchemy.orm import Mapped
33from sqlalchemy.sql.sqltypes import Float
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
60# =============================================================================
61# Constants
62# =============================================================================
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"""
82# =============================================================================
83# DEMQOL
84# =============================================================================
87class Demqol( # type: ignore[misc]
88 TaskHasPatientMixin,
89 TaskHasClinicianMixin,
90 Task,
91):
92 """
93 Server implementation of the DEMQOL task.
94 """
96 __tablename__ = "demqol"
97 shortname = "DEMQOL"
98 provides_trackers = True
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 )
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 )
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
159 COMPLETENESS_FIELDS = strseq("q", 1, NQUESTIONS)
161 @staticmethod
162 def longname(req: "CamcopsRequest") -> str:
163 _ = req.gettext
164 return _("Dementia Quality of Life measure, self-report version")
166 def is_complete(self) -> bool:
167 return (
168 self.all_fields_not_none(self.COMPLETENESS_FIELDS)
169 and self.field_contents_valid()
170 )
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 ]
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 ]
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 ]
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 )
216 def total_score(self) -> float:
217 (total, extrapolated) = self.totalscore_extrapolated()
218 return total
220 def get_q(self, req: CamcopsRequest, n: int) -> str:
221 nstr = str(n)
222 return "Q" + nstr + ". " + self.wxstring(req, "proxy_q" + nstr)
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
283# =============================================================================
284# DEMQOL-Proxy
285# =============================================================================
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"
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 )
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 )
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
361 COMPLETENESS_FIELDS = strseq("q", 1, NQUESTIONS)
363 @staticmethod
364 def longname(req: "CamcopsRequest") -> str:
365 _ = req.gettext
366 return _("Dementia Quality of Life measure, proxy version")
368 def is_complete(self) -> bool:
369 return (
370 self.all_fields_not_none(self.COMPLETENESS_FIELDS)
371 and self.field_contents_valid()
372 )
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 ]
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 ]
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 ]
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 )
418 def total_score(self) -> float:
419 (total, extrapolated) = self.totalscore_extrapolated()
420 return total
422 def get_q(self, req: CamcopsRequest, n: int) -> str:
423 nstr = str(n)
424 return "Q" + nstr + ". " + self.wxstring(req, "proxy_q" + nstr)
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
483# =============================================================================
484# Common scoring function
485# =============================================================================
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