Coverage for tasks/demqol.py: 52%
143 statements
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-08 23:14 +0000
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-08 23:14 +0000
1#!/usr/bin/env python
3"""
4camcops_server/tasks/demqol.py
6===============================================================================
8 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
9 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
11 This file is part of CamCOPS.
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.
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.
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/>.
26===============================================================================
28"""
30from typing import Any, Dict, List, Optional, Tuple, Type, Union
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
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
62# =============================================================================
63# Constants
64# =============================================================================
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"""
84# =============================================================================
85# DEMQOL
86# =============================================================================
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)
141class Demqol(
142 TaskHasPatientMixin, TaskHasClinicianMixin, Task, metaclass=DemqolMetaclass
143):
144 """
145 Server implementation of the DEMQOL task.
146 """
148 __tablename__ = "demqol"
149 shortname = "DEMQOL"
150 provides_trackers = True
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 )
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
169 COMPLETENESS_FIELDS = strseq("q", 1, NQUESTIONS)
171 @staticmethod
172 def longname(req: "CamcopsRequest") -> str:
173 _ = req.gettext
174 return _("Dementia Quality of Life measure, self-report version")
176 def is_complete(self) -> bool:
177 return (
178 self.all_fields_not_none(self.COMPLETENESS_FIELDS)
179 and self.field_contents_valid()
180 )
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 ]
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 ]
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 ]
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 )
226 def total_score(self) -> float:
227 (total, extrapolated) = self.totalscore_extrapolated()
228 return total
230 def get_q(self, req: CamcopsRequest, n: int) -> str:
231 nstr = str(n)
232 return "Q" + nstr + ". " + self.wxstring(req, "proxy_q" + nstr)
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
293# =============================================================================
294# DEMQOL-Proxy
295# =============================================================================
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)
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"
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 )
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
382 COMPLETENESS_FIELDS = strseq("q", 1, NQUESTIONS)
384 @staticmethod
385 def longname(req: "CamcopsRequest") -> str:
386 _ = req.gettext
387 return _("Dementia Quality of Life measure, proxy version")
389 def is_complete(self) -> bool:
390 return (
391 self.all_fields_not_none(self.COMPLETENESS_FIELDS)
392 and self.field_contents_valid()
393 )
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 ]
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 ]
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 ]
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 )
439 def total_score(self) -> float:
440 (total, extrapolated) = self.totalscore_extrapolated()
441 return total
443 def get_q(self, req: CamcopsRequest, n: int) -> str:
444 nstr = str(n)
445 return "Q" + nstr + ". " + self.wxstring(req, "proxy_q" + nstr)
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
504# =============================================================================
505# Common scoring function
506# =============================================================================
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