Coverage for tasks/frs.py: 48%

116 statements  

« prev     ^ index     » next       coverage.py v7.9.2, created at 2025-07-15 14:23 +0100

1""" 

2camcops_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 

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

29 

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 

35 

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 

53 

54 

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

56# FRS 

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

58 

59SCORING_NOTES = """ 

60 

61SCORING 

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

63 

64LOGIT 

65 

66Quick R definitions: 

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

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

69 

70See comparison file published_calculated_FRS_scoring.ods 

71and correspondence with Eneida 2015-01-20. 

72 

73""" 

74 

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 

117 

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) 

154 

155 

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" 

170 

171 

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] 

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 

190class 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