Coverage for tasks/demqol.py : 51%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1#!/usr/bin/env python
3"""
4camcops_server/tasks/demqol.py
6===============================================================================
8 Copyright (C) 2012-2020 Rudolf Cardinal (rudolf@pobox.com).
10 This file is part of CamCOPS.
12 CamCOPS is free software: you can redistribute it and/or modify
13 it under the terms of the GNU General Public License as published by
14 the Free Software Foundation, either version 3 of the License, or
15 (at your option) any later version.
17 CamCOPS is distributed in the hope that it will be useful,
18 but WITHOUT ANY WARRANTY; without even the implied warranty of
19 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
20 GNU General Public License for more details.
22 You should have received a copy of the GNU General Public License
23 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>.
25===============================================================================
27"""
29from typing import Any, Dict, List, Optional, Tuple, Type, Union
31from cardinal_pythonlib.stringfunc import strseq
32import cardinal_pythonlib.rnc_web as ws
33from sqlalchemy.ext.declarative import DeclarativeMeta
34from sqlalchemy.sql.sqltypes import Float, Integer
36from camcops_server.cc_modules.cc_constants import CssClass
37from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo
38from camcops_server.cc_modules.cc_db import add_multiple_columns
39from camcops_server.cc_modules.cc_html import (
40 answer,
41 get_yes_no,
42 subheading_spanning_two_columns,
43 tr_qa,
44)
45from camcops_server.cc_modules.cc_request import CamcopsRequest
46from camcops_server.cc_modules.cc_sqla_coltypes import (
47 CamcopsColumn,
48 PermittedValueChecker,
49)
50from camcops_server.cc_modules.cc_summaryelement import SummaryElement
51from camcops_server.cc_modules.cc_task import (
52 get_from_dict,
53 Task,
54 TaskHasClinicianMixin,
55 TaskHasPatientMixin,
56 TaskHasRespondentMixin,
57)
58from 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# =============================================================================
86class DemqolMetaclass(DeclarativeMeta):
87 # noinspection PyInitNewSignature
88 def __init__(cls: Type['Demqol'],
89 name: str,
90 bases: Tuple[Type, ...],
91 classdict: Dict[str, Any]) -> None:
92 add_multiple_columns(
93 cls, "q", 1, cls.N_SCORED_QUESTIONS,
94 pv=PERMITTED_VALUES,
95 comment_fmt="Q{n}. {s} (1 a lot - 4 not at all; -99 no response)",
96 comment_strings=[
97 # 1-13
98 "cheerful", "worried/anxious", "enjoying life", "frustrated",
99 "confident", "full of energy", "sad", "lonely", "distressed",
100 "lively", "irritable", "fed up", "couldn't do things",
101 # 14-19
102 "worried: forget recent", "worried: forget people",
103 "worried: forget day", "worried: muddled",
104 "worried: difficulty making decisions",
105 "worried: poor concentration",
106 # 20-28
107 "worried: not enough company",
108 "worried: get on with people close",
109 "worried: affection", "worried: people not listening",
110 "worried: making self understood", "worried: getting help",
111 "worried: toilet", "worried: feel in self",
112 "worried: health overall",
113 ]
114 )
115 super().__init__(name, bases, classdict)
118class Demqol(TaskHasPatientMixin, TaskHasClinicianMixin, Task,
119 metaclass=DemqolMetaclass):
120 """
121 Server implementation of the DEMQOL task.
122 """
123 __tablename__ = "demqol"
124 shortname = "DEMQOL"
125 provides_trackers = True
127 q29 = CamcopsColumn(
128 "q29", Integer,
129 permitted_value_checker=PermittedValueChecker(
130 permitted_values=PERMITTED_VALUES),
131 comment="Q29. Overall quality of life (1 very good - 4 poor; "
132 "-99 no response)."
133 )
135 NQUESTIONS = 29
136 N_SCORED_QUESTIONS = 28
137 MINIMUM_N_FOR_TOTAL_SCORE = 14
138 REVERSE_SCORE = [1, 3, 5, 6, 10, 29] # questions scored backwards
139 MIN_SCORE = N_SCORED_QUESTIONS
140 MAX_SCORE = MIN_SCORE * 4
142 COMPLETENESS_FIELDS = strseq("q", 1, NQUESTIONS)
144 @staticmethod
145 def longname(req: "CamcopsRequest") -> str:
146 _ = req.gettext
147 return _("Dementia Quality of Life measure, self-report version")
149 def is_complete(self) -> bool:
150 return (
151 self.all_fields_not_none(self.COMPLETENESS_FIELDS) and
152 self.field_contents_valid()
153 )
155 def get_trackers(self, req: CamcopsRequest) -> List[TrackerInfo]:
156 return [TrackerInfo(
157 value=self.total_score(),
158 plot_label="DEMQOL total score",
159 axis_label=(
160 f"Total score (range {self.MIN_SCORE}–{self.MAX_SCORE}, "
161 f"higher better)"
162 ),
163 axis_min=self.MIN_SCORE - 0.5,
164 axis_max=self.MAX_SCORE + 0.5
165 )]
167 def get_clinical_text(self, req: CamcopsRequest) -> List[CtvInfo]:
168 if not self.is_complete():
169 return CTV_INCOMPLETE
170 return [CtvInfo(content=(
171 f"Total score {ws.number_to_dp(self.total_score(), DP)} "
172 f"(range {self.MIN_SCORE}–{self.MAX_SCORE}, higher better)"
173 ))]
175 def get_summaries(self, req: CamcopsRequest) -> List[SummaryElement]:
176 return self.standard_task_summary_fields() + [
177 SummaryElement(
178 name="total",
179 coltype=Float(),
180 value=self.total_score(),
181 comment=f"Total score ({self.MIN_SCORE}-{self.MAX_SCORE})"),
182 ]
184 def totalscore_extrapolated(self) -> Tuple[float, bool]:
185 return calc_total_score(
186 obj=self,
187 n_scored_questions=self.N_SCORED_QUESTIONS,
188 reverse_score_qs=self.REVERSE_SCORE,
189 minimum_n_for_total_score=self.MINIMUM_N_FOR_TOTAL_SCORE
190 )
192 def total_score(self) -> float:
193 (total, extrapolated) = self.totalscore_extrapolated()
194 return total
196 def get_q(self, req: CamcopsRequest, n: int) -> str:
197 nstr = str(n)
198 return "Q" + nstr + ". " + self.wxstring(req, "proxy_q" + nstr)
200 def get_task_html(self, req: CamcopsRequest) -> str:
201 (total, extrapolated) = self.totalscore_extrapolated()
202 main_dict = {
203 None: None,
204 1: "1 — " + self.wxstring(req, "a1"),
205 2: "2 — " + self.wxstring(req, "a2"),
206 3: "3 — " + self.wxstring(req, "a3"),
207 4: "4 — " + self.wxstring(req, "a4"),
208 MISSING_VALUE: self.wxstring(req, "no_response")
209 }
210 last_q_dict = {
211 None: None,
212 1: "1 — " + self.wxstring(req, "q29_a1"),
213 2: "2 — " + self.wxstring(req, "q29_a2"),
214 3: "3 — " + self.wxstring(req, "q29_a3"),
215 4: "4 — " + self.wxstring(req, "q29_a4"),
216 MISSING_VALUE: self.wxstring(req, "no_response")
217 }
218 instruction_dict = {
219 1: self.wxstring(req, "instruction11"),
220 14: self.wxstring(req, "instruction12"),
221 20: self.wxstring(req, "instruction13"),
222 29: self.wxstring(req, "instruction14"),
223 }
224 # https://docs.python.org/2/library/stdtypes.html#mapping-types-dict
225 # http://paltman.com/try-except-performance-in-python-a-simple-test/
226 h = f"""
227 <div class="{CssClass.SUMMARY}">
228 <table class="{CssClass.SUMMARY}">
229 {self.get_is_complete_tr(req)}
230 <tr>
231 <td>Total score ({self.MIN_SCORE}–{self.MAX_SCORE}),
232 higher better</td>
233 <td>{answer(ws.number_to_dp(total, DP))}</td>
234 </tr>
235 <tr>
236 <td>Total score extrapolated using incomplete
237 responses? <sup>[1]</sup></td>
238 <td>{answer(get_yes_no(req, extrapolated))}</td>
239 </tr>
240 </table>
241 </div>
242 <table class="{CssClass.TASKDETAIL}">
243 <tr>
244 <th width="50%">Question</th>
245 <th width="50%">Answer</th>
246 </tr>
247 """
248 for n in range(1, self.NQUESTIONS + 1):
249 if n in instruction_dict:
250 h += subheading_spanning_two_columns(instruction_dict.get(n))
251 d = main_dict if n <= self.N_SCORED_QUESTIONS else last_q_dict
252 q = self.get_q(req, n)
253 a = get_from_dict(d, getattr(self, "q" + str(n)))
254 h += tr_qa(q, a)
255 h += END_DIV + COPYRIGHT_DIV
256 return h
259# =============================================================================
260# DEMQOL-Proxy
261# =============================================================================
263class DemqolProxyMetaclass(DeclarativeMeta):
264 # noinspection PyInitNewSignature
265 def __init__(cls: Type['DemqolProxy'],
266 name: str,
267 bases: Tuple[Type, ...],
268 classdict: Dict[str, Any]) -> None:
269 add_multiple_columns(
270 cls, "q", 1, cls.N_SCORED_QUESTIONS,
271 pv=PERMITTED_VALUES,
272 comment_fmt="Q{n}. {s} (1 a lot - 4 not at all; -99 no response)",
273 comment_strings=[
274 # 1-11
275 "cheerful", "worried/anxious", "frustrated", "full of energy",
276 "sad", "content", "distressed", "lively", "irritable",
277 "fed up", "things to look forward to",
278 # 12-20
279 "worried: memory in general", "worried: forget distant",
280 "worried: forget recent", "worried: forget people",
281 "worried: forget place", "worried: forget day",
282 "worried: muddled",
283 "worried: difficulty making decisions",
284 "worried: making self understood",
285 # 21-31
286 "worried: keeping clean", "worried: keeping self looking nice",
287 "worried: shopping", "worried: using money to pay",
288 "worried: looking after finances", "worried: taking longer",
289 "worried: getting in touch with people",
290 "worried: not enough company",
291 "worried: not being able to help others",
292 "worried: not playing a useful part",
293 "worried: physical health",
294 ]
295 )
296 super().__init__(name, bases, classdict)
299class DemqolProxy(TaskHasPatientMixin, TaskHasRespondentMixin,
300 TaskHasClinicianMixin, Task,
301 metaclass=DemqolProxyMetaclass):
302 __tablename__ = "demqolproxy"
303 shortname = "DEMQOL-Proxy"
304 extrastring_taskname = "demqol"
306 q32 = CamcopsColumn(
307 "q32", Integer,
308 permitted_value_checker=PermittedValueChecker(
309 permitted_values=PERMITTED_VALUES),
310 comment="Q32. Overall quality of life (1 very good - 4 poor; "
311 "-99 no response)."
312 )
314 NQUESTIONS = 32
315 N_SCORED_QUESTIONS = 31
316 MINIMUM_N_FOR_TOTAL_SCORE = 16
317 REVERSE_SCORE = [1, 4, 6, 8, 11, 32] # questions scored backwards
318 MIN_SCORE = N_SCORED_QUESTIONS
319 MAX_SCORE = MIN_SCORE * 4
321 COMPLETENESS_FIELDS = strseq("q", 1, NQUESTIONS)
323 @staticmethod
324 def longname(req: "CamcopsRequest") -> str:
325 _ = req.gettext
326 return _("Dementia Quality of Life measure, proxy version")
328 def is_complete(self) -> bool:
329 return (
330 self.all_fields_not_none(self.COMPLETENESS_FIELDS) and
331 self.field_contents_valid()
332 )
334 def get_trackers(self, req: CamcopsRequest) -> List[TrackerInfo]:
335 return [TrackerInfo(
336 value=self.total_score(),
337 plot_label="DEMQOL-Proxy total score",
338 axis_label=(
339 f"Total score (range {self.MIN_SCORE}–{self.MAX_SCORE},"
340 f" higher better)"
341 ),
342 axis_min=self.MIN_SCORE - 0.5,
343 axis_max=self.MAX_SCORE + 0.5
344 )]
346 def get_clinical_text(self, req: CamcopsRequest) -> List[CtvInfo]:
347 if not self.is_complete():
348 return CTV_INCOMPLETE
349 return [CtvInfo(content=(
350 f"Total score {ws.number_to_dp(self.total_score(), DP)} "
351 f"(range {self.MIN_SCORE}–{self.MAX_SCORE}, higher better)"
352 ))]
354 def get_summaries(self, req: CamcopsRequest) -> List[SummaryElement]:
355 return self.standard_task_summary_fields() + [
356 SummaryElement(
357 name="total",
358 coltype=Float(),
359 value=self.total_score(),
360 comment=f"Total score ({self.MIN_SCORE}-{self.MAX_SCORE})"),
361 ]
363 def totalscore_extrapolated(self) -> Tuple[float, bool]:
364 return calc_total_score(
365 obj=self,
366 n_scored_questions=self.N_SCORED_QUESTIONS,
367 reverse_score_qs=self.REVERSE_SCORE,
368 minimum_n_for_total_score=self.MINIMUM_N_FOR_TOTAL_SCORE
369 )
371 def total_score(self) -> float:
372 (total, extrapolated) = self.totalscore_extrapolated()
373 return total
375 def get_q(self, req: CamcopsRequest, n: int) -> str:
376 nstr = str(n)
377 return "Q" + nstr + ". " + self.wxstring(req, "proxy_q" + nstr)
379 def get_task_html(self, req: CamcopsRequest) -> str:
380 (total, extrapolated) = self.totalscore_extrapolated()
381 main_dict = {
382 None: None,
383 1: "1 — " + self.wxstring(req, "a1"),
384 2: "2 — " + self.wxstring(req, "a2"),
385 3: "3 — " + self.wxstring(req, "a3"),
386 4: "4 — " + self.wxstring(req, "a4"),
387 MISSING_VALUE: self.wxstring(req, "no_response")
388 }
389 last_q_dict = {
390 None: None,
391 1: "1 — " + self.wxstring(req, "q29_a1"),
392 2: "2 — " + self.wxstring(req, "q29_a2"),
393 3: "3 — " + self.wxstring(req, "q29_a3"),
394 4: "4 — " + self.wxstring(req, "q29_a4"),
395 MISSING_VALUE: self.wxstring(req, "no_response")
396 }
397 instruction_dict = {
398 1: self.wxstring(req, "proxy_instruction11"),
399 12: self.wxstring(req, "proxy_instruction12"),
400 21: self.wxstring(req, "proxy_instruction13"),
401 32: self.wxstring(req, "proxy_instruction14"),
402 }
403 h = f"""
404 <div class="{CssClass.SUMMARY}">
405 <table class="{CssClass.SUMMARY}">
406 {self.get_is_complete_tr(req)}
407 <tr>
408 <td>Total score ({self.MIN_SCORE}–{self.MAX_SCORE}),
409 higher better</td>
410 <td>{answer(ws.number_to_dp(total, DP))}</td>
411 </tr>
412 <tr>
413 <td>Total score extrapolated using incomplete
414 responses? <sup>[1]</sup></td>
415 <td>{answer(get_yes_no(req, extrapolated))}</td>
416 </tr>
417 </table>
418 </div>
419 <table class="{CssClass.TASKDETAIL}">
420 <tr>
421 <th width="50%">Question</th>
422 <th width="50%">Answer</th>
423 </tr>
424 """
425 for n in range(1, self.NQUESTIONS + 1):
426 if n in instruction_dict:
427 h += subheading_spanning_two_columns(instruction_dict.get(n))
428 d = main_dict if n <= self.N_SCORED_QUESTIONS else last_q_dict
429 q = self.get_q(req, n)
430 a = get_from_dict(d, getattr(self, "q" + str(n)))
431 h += tr_qa(q, a)
432 h += END_DIV + COPYRIGHT_DIV
433 return h
436# =============================================================================
437# Common scoring function
438# =============================================================================
440def calc_total_score(obj: Union[Demqol, DemqolProxy],
441 n_scored_questions: int,
442 reverse_score_qs: List[int],
443 minimum_n_for_total_score: int) \
444 -> Tuple[Optional[float], bool]:
445 """Returns (total, extrapolated?)."""
446 n = 0
447 total = 0
448 for q in range(1, n_scored_questions + 1):
449 x = getattr(obj, "q" + str(q))
450 if x is None or x == MISSING_VALUE:
451 continue
452 if q in reverse_score_qs:
453 x = 5 - x
454 n += 1
455 total += x
456 if n < minimum_n_for_total_score:
457 return None, False
458 if n < n_scored_questions:
459 return n_scored_questions * total / n, True
460 return total, False