Coverage for tasks/frs.py: 50%
119 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/frs.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
32from cardinal_pythonlib.betweendict import BetweenDict
33from cardinal_pythonlib.stringfunc import strseq
34import cardinal_pythonlib.rnc_web as ws
35from sqlalchemy.ext.declarative import DeclarativeMeta
36from sqlalchemy.sql.schema import Column
37from sqlalchemy.sql.sqltypes import Float, Integer, UnicodeText
39from camcops_server.cc_modules.cc_constants import CssClass
40from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo
41from camcops_server.cc_modules.cc_html import tr_qa
42from camcops_server.cc_modules.cc_request import CamcopsRequest
43from camcops_server.cc_modules.cc_sqla_coltypes import (
44 CamcopsColumn,
45 PermittedValueChecker,
46 SummaryCategoryColType,
47)
48from camcops_server.cc_modules.cc_summaryelement import SummaryElement
49from camcops_server.cc_modules.cc_task import (
50 Task,
51 TaskHasClinicianMixin,
52 TaskHasPatientMixin,
53 TaskHasRespondentMixin,
54)
55from camcops_server.cc_modules.cc_text import SS
58# =============================================================================
59# FRS
60# =============================================================================
62SCORING_NOTES = """
64SCORING
65Confirmed by Eneida Mioshi 2015-01-20; "sometimes" and "always" score the same.
67LOGIT
69Quick R definitions:
70 logit <- function(x) log(x / (1 - x))
71 invlogit <- function(x) exp(x) / (exp(x) + 1)
73See comparison file published_calculated_FRS_scoring.ods
74and correspondence with Eneida 2015-01-20.
76"""
78NEVER = 0
79SOMETIMES = 1
80ALWAYS = 2
81NA = -99
82NA_QUESTIONS = [9, 10, 11, 13, 14, 15, 17, 18, 19, 20, 21, 27]
83SPECIAL_NA_TEXT_QUESTIONS = [27]
84NO_SOMETIMES_QUESTIONS = [30]
85SCORE = {NEVER: 1, SOMETIMES: 0, ALWAYS: 0}
86NQUESTIONS = 30
87QUESTION_SNIPPETS = [
88 "behaviour / lacks interest", # 1
89 "behaviour / lacks affection",
90 "behaviour / uncooperative",
91 "behaviour / confused/muddled in unusual surroundings",
92 "behaviour / restless", # 5
93 "behaviour / impulsive",
94 "behaviour / forgets day",
95 "outings / transportation",
96 "outings / shopping",
97 "household / lacks interest/motivation", # 10
98 "household / difficulty completing chores",
99 "household / telephoning",
100 "finances / lacks interest",
101 "finances / problems organizing finances",
102 "finances / problems organizing correspondence", # 15
103 "finances / difficulty with cash",
104 "medication / problems taking medication at correct time",
105 "medication / problems taking medication as prescribed",
106 "mealprep / lacks interest/motivation",
107 "mealprep / difficulty organizing meal prep", # 20
108 "mealprep / problems preparing meal on own",
109 "mealprep / lacks initiative to eat",
110 "mealprep / difficulty choosing utensils/seasoning",
111 "mealprep / problems eating",
112 "mealprep / wants to eat same foods repeatedly", # 25
113 "mealprep / prefers sweet foods more",
114 "selfcare / problems choosing appropriate clothing",
115 "selfcare / incontinent",
116 "selfcare / cannot be left at home safely",
117 "selfcare / bedbound", # 30
118]
119DP = 3
121TABULAR_LOGIT_BETWEENDICT = BetweenDict(
122 {
123 # tests a <= x < b
124 (100, float("inf")): 5.39, # from Python 3.5, can use math.inf
125 (97, 100): 4.12,
126 (93, 97): 3.35,
127 (90, 93): 2.86,
128 (87, 90): 2.49,
129 (83, 87): 2.19,
130 (80, 83): 1.92,
131 (77, 80): 1.68,
132 (73, 77): 1.47,
133 (70, 73): 1.26,
134 (67, 70): 1.07,
135 (63, 67): 0.88,
136 (60, 63): 0.7,
137 (57, 60): 0.52,
138 (53, 57): 0.34,
139 (50, 53): 0.16,
140 (47, 50): -0.02,
141 (43, 47): -0.2,
142 (40, 43): -0.4,
143 (37, 40): -0.59,
144 (33, 37): -0.8,
145 (30, 33): -1.03,
146 (27, 30): -1.27,
147 (23, 27): -1.54,
148 (20, 23): -1.84,
149 (17, 20): -2.18,
150 (13, 17): -2.58,
151 (10, 13): -3.09,
152 (6, 10): -3.8,
153 (3, 6): -4.99,
154 (0, 3): -6.66,
155 }
156)
159def get_severity(logit: float) -> str:
160 # p1593 of Mioshi et al. (2010)
161 # Copes with Infinity comparisons
162 if logit >= 4.12:
163 return "very mild"
164 if logit >= 1.92:
165 return "mild"
166 if logit >= -0.40:
167 return "moderate"
168 if logit >= -2.58:
169 return "severe"
170 if logit >= -4.99:
171 return "very severe"
172 return "profound"
175def get_tabular_logit(score: float) -> float:
176 """
177 Implements the scoring table accompanying Mioshi et al. (2010).
178 Converts a score (in the table, a percentage; here, a number in the
179 range 0-1) to a logit score of some description, whose true basis (in
180 a Rasch analysis) is a bit obscure.
181 """
182 pct_score = 100 * score
183 return TABULAR_LOGIT_BETWEENDICT[pct_score]
186# for x in range(100, 0 - 1, -1):
187# score = x / 100
188# logit = get_tabular_logit(score)
189# severity = get_severity(logit)
190# print(",".join(str(q) for q in (x, logit, severity)))
193class FrsMetaclass(DeclarativeMeta):
194 # noinspection PyInitNewSignature
195 def __init__(
196 cls: Type["Frs"],
197 name: str,
198 bases: Tuple[Type, ...],
199 classdict: Dict[str, Any],
200 ) -> None:
201 for n in range(1, NQUESTIONS + 1):
202 pv = [NEVER, ALWAYS]
203 pc = [f"{NEVER} = never", f"{ALWAYS} = always"]
204 if n not in NO_SOMETIMES_QUESTIONS:
205 pv.append(SOMETIMES)
206 pc.append(f"{SOMETIMES} = sometimes")
207 if n in NA_QUESTIONS:
208 pv.append(NA)
209 pc.append(f"{NA} = N/A")
210 comment = f"Q{n}, {QUESTION_SNIPPETS[n - 1]} ({', '.join(pc)})"
211 colname = f"q{n}"
212 setattr(
213 cls,
214 colname,
215 CamcopsColumn(
216 colname,
217 Integer,
218 permitted_value_checker=PermittedValueChecker(
219 permitted_values=pv
220 ),
221 comment=comment,
222 ),
223 )
224 super().__init__(name, bases, classdict)
227class Frs(
228 TaskHasPatientMixin,
229 TaskHasRespondentMixin,
230 TaskHasClinicianMixin,
231 Task,
232 metaclass=FrsMetaclass,
233):
234 """
235 Server implementation of the FRS task.
236 """
238 __tablename__ = "frs"
239 shortname = "FRS"
241 comments = Column("comments", UnicodeText, comment="Clinician's comments")
243 TASK_FIELDS = strseq("q", 1, NQUESTIONS)
245 @staticmethod
246 def longname(req: "CamcopsRequest") -> str:
247 _ = req.gettext
248 return _("Frontotemporal Dementia Rating Scale")
250 def get_summaries(self, req: CamcopsRequest) -> List[SummaryElement]:
251 scoredict = self.get_score()
252 return self.standard_task_summary_fields() + [
253 SummaryElement(
254 name="total",
255 coltype=Integer(),
256 value=scoredict["total"],
257 comment="Total (0-n, higher better)",
258 ),
259 SummaryElement(
260 name="n",
261 coltype=Integer(),
262 value=scoredict["n"],
263 comment="Number of applicable questions",
264 ),
265 SummaryElement(
266 name="score",
267 coltype=Float(),
268 value=scoredict["score"],
269 comment="tcore / n",
270 ),
271 SummaryElement(
272 name="logit",
273 coltype=Float(),
274 value=scoredict["logit"],
275 comment="log(score / (1 - score))",
276 ),
277 SummaryElement(
278 name="severity",
279 coltype=SummaryCategoryColType,
280 value=scoredict["severity"],
281 comment="Severity",
282 ),
283 ]
285 def get_clinical_text(self, req: CamcopsRequest) -> List[CtvInfo]:
286 if not self.is_complete():
287 return CTV_INCOMPLETE
288 scoredict = self.get_score()
289 return [
290 CtvInfo(
291 content=(
292 "Total {total}/n, n = {n}, score = {score}, "
293 "logit score = {logit}, severity = {severity}".format(
294 total=scoredict["total"],
295 n=scoredict["n"],
296 score=ws.number_to_dp(scoredict["score"], DP),
297 logit=ws.number_to_dp(scoredict["logit"], DP),
298 severity=scoredict["severity"],
299 )
300 )
301 )
302 ]
304 def get_score(self) -> Dict:
305 total = 0
306 n = 0
307 for q in range(1, NQUESTIONS + 1):
308 value = getattr(self, "q" + str(q))
309 if value is not None and value != NA:
310 n += 1
311 total += SCORE.get(value, 0)
312 if n > 0:
313 score = total / n
314 # logit = safe_logit(score)
315 logit = get_tabular_logit(score)
316 severity = get_severity(logit)
317 else:
318 score = None
319 logit = None
320 severity = ""
321 return dict(
322 total=total, n=n, score=score, logit=logit, severity=severity
323 )
325 def is_complete(self) -> bool:
326 return (
327 self.field_contents_valid()
328 and self.is_respondent_complete()
329 and self.all_fields_not_none(self.TASK_FIELDS)
330 )
332 def get_answer(self, req: CamcopsRequest, q: int) -> Optional[str]:
333 qstr = str(q)
334 value = getattr(self, "q" + qstr)
335 if value is None:
336 return None
337 prefix = "q" + qstr + "_a_"
338 if value == ALWAYS:
339 return self.wxstring(req, prefix + "always")
340 if value == SOMETIMES:
341 return self.wxstring(req, prefix + "sometimes")
342 if value == NEVER:
343 return self.wxstring(req, prefix + "never")
344 if value == NA:
345 if q in SPECIAL_NA_TEXT_QUESTIONS:
346 return self.wxstring(req, prefix + "na")
347 return req.sstring(SS.NA)
348 return None
350 def get_task_html(self, req: CamcopsRequest) -> str:
351 scoredict = self.get_score()
352 q_a = ""
353 for q in range(1, NQUESTIONS + 1):
354 qtext = self.wxstring(req, "q" + str(q) + "_q")
355 atext = self.get_answer(req, q)
356 q_a += tr_qa(qtext, atext)
357 return f"""
358 <div class="{CssClass.SUMMARY}">
359 <table class="{CssClass.SUMMARY}">
360 {self.get_is_complete_tr(req)}
361 <tr>
362 <td>Total (0–n, higher better) <sup>1</sup></td>
363 <td>{scoredict['total']}</td>
364 </td>
365 <tr>
366 <td>n (applicable questions)</td>
367 <td>{scoredict['n']}</td>
368 </td>
369 <tr>
370 <td>Score (total / n; 0–1)</td>
371 <td>{ws.number_to_dp(scoredict['score'], DP)}</td>
372 </td>
373 <tr>
374 <td>logit score <sup>2</sup></td>
375 <td>{ws.number_to_dp(scoredict['logit'], DP)}</td>
376 </td>
377 <tr>
378 <td>Severity <sup>3</sup></td>
379 <td>{scoredict['severity']}</td>
380 </td>
381 </table>
382 </div>
383 <table class="{CssClass.TASKDETAIL}">
384 <tr>
385 <th width="50%">Question</th>
386 <th width="50%">Answer</th>
387 </tr>
388 {q_a}
389 </table>
390 <div class="{CssClass.FOOTNOTES}">
391 [1] ‘Never’ scores 1 and ‘sometimes’/‘always’ both score 0,
392 i.e. there is no scoring difference between ‘sometimes’ and
393 ‘always’.
394 [2] This is not the simple logit, log(score/[1 – score]).
395 Instead, it is determined by a lookup table, as per
396 <a href="http://www.ftdrg.org/wp-content/uploads/FRS-Score-conversion.pdf">http://www.ftdrg.org/wp-content/uploads/FRS-Score-conversion.pdf</a>.
397 The logit score that is looked up is very close to the logit
398 of the raw score (on a 0–1 scale); however, it differs in that
399 firstly it is banded rather than continuous, and secondly it
400 is subtly different near the lower scores and at the extremes.
401 The original is based on a Rasch analysis but the raw method of
402 converting the score to the tabulated logit is not given.
403 [3] Where <i>x</i> is the logit score, severity is determined
404 as follows (after Mioshi et al. 2010, Neurology 74: 1591, PMID
405 20479357, with sharp cutoffs).
406 <i>Very mild:</i> <i>x</i> ≥ 4.12.
407 <i>Mild:</i> 1.92 ≤ <i>x</i> < 4.12.
408 <i>Moderate:</i> –0.40 ≤ <i>x</i> < 1.92.
409 <i>Severe:</i> –2.58 ≤ <i>x</i> < –0.40.
410 <i>Very severe:</i> –4.99 ≤ <i>x</i> < –2.58.
411 <i>Profound:</i> <i>x</i> < –4.99.
412 </div>
413 """ # noqa