Coverage for tasks/frs.py: 48%

1"""

2 camcops_server/tasks/frs.py

3

4===============================================================================

5

6 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.

7 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).

8

9 This file is part of CamCOPS.

10

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.

15

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.

20

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/>.

23

24===============================================================================

25

26"""

27

28 from typing import Any, Dict, List, Optional, Type

29

30 from cardinal_pythonlib.betweendict import BetweenDict

31 from cardinal_pythonlib.stringfunc import strseq

32 import cardinal_pythonlib.rnc_web as ws

33 from sqlalchemy.orm import Mapped, mapped_column

34 from sqlalchemy.sql.sqltypes import Float, Integer, UnicodeText

35

36 from camcops_server.cc_modules.cc_constants import CssClass

37 from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo

38 from camcops_server.cc_modules.cc_html import tr_qa

39 from camcops_server.cc_modules.cc_request import CamcopsRequest

40 from camcops_server.cc_modules.cc_sqla_coltypes import (

41 camcops_column,

42 PermittedValueChecker,

43 SummaryCategoryColType,

44)

45 from camcops_server.cc_modules.cc_summaryelement import SummaryElement

46 from camcops_server.cc_modules.cc_task import (

47 Task,

48 TaskHasClinicianMixin,

49 TaskHasPatientMixin,

50 TaskHasRespondentMixin,

51)

52 from camcops_server.cc_modules.cc_text import SS

53

54

55# =============================================================================

56# FRS

57# =============================================================================

58

59 SCORING_NOTES = """

60

61 SCORING

62 Confirmed by Eneida Mioshi 2015-01-20; "sometimes" and "always" score the same.

63

64 LOGIT

65

66 Quick R definitions:

67 logit <- function(x) log(x / (1 - x))

68 invlogit <- function(x) exp(x) / (exp(x) + 1)

69

70 See comparison file published_calculated_FRS_scoring.ods

71 and correspondence with Eneida 2015-01-20.

72

73"""

74

75 NEVER = 0

76 SOMETIMES = 1

77 ALWAYS = 2

78 NA = -99

79 NA_QUESTIONS = [9, 10, 11, 13, 14, 15, 17, 18, 19, 20, 21, 27]

80 SPECIAL_NA_TEXT_QUESTIONS = [27]

81 NO_SOMETIMES_QUESTIONS = [30]

82 SCORE = {NEVER: 1, SOMETIMES: 0, ALWAYS: 0}

83 NQUESTIONS = 30

84 QUESTION_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]

116 DP = 3

117

118 TABULAR_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)

154

155

156 def 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"

170

171

172 def 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]

181

182

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)))

188

189

190 class Frs( # type: ignore[misc]

191 TaskHasPatientMixin,

192 TaskHasRespondentMixin,

193 TaskHasClinicianMixin,

194 Task,

195):

196 """

197 Server implementation of the FRS task.

198 """

199

200 __tablename__ = "frs"

201 shortname = "FRS"

202

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 )

228

229 comments: Mapped[Optional[str]] = mapped_column(

230 UnicodeText, comment="Clinician's comments"

231 )

232

233 TASK_FIELDS = strseq("q", 1, NQUESTIONS)

234

235 @staticmethod

236 def longname(req: "CamcopsRequest") -> str:

237 _ = req.gettext

238 return _("Frontotemporal Dementia Rating Scale")

239

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 ]

274

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 ]

293

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 )

314

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 )

321

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

339

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> &lt; 4.12.

398 <i>Moderate:</i> –0.40 ≤ <i>x</i> &lt; 1.92.

399 <i>Severe:</i> –2.58 ≤ <i>x</i> &lt; –0.40.

400 <i>Very severe:</i> –4.99 ≤ <i>x</i> &lt; –2.58.

401 <i>Profound:</i> <i>x</i> &lt; –4.99.

402 </div>

403 """ # noqa