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