Coverage for tasks/icd10manic.py: 37%

175 statements  

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

1""" 

2camcops_server/tasks/icd10manic.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 

28import datetime 

29from typing import List, Optional 

30 

31from cardinal_pythonlib.datetimefunc import format_datetime 

32from cardinal_pythonlib.typetests import is_false 

33import cardinal_pythonlib.rnc_web as ws 

34from sqlalchemy.orm import Mapped, mapped_column 

35from sqlalchemy.sql.sqltypes import Boolean, UnicodeText 

36 

37from camcops_server.cc_modules.cc_constants import ( 

38 CssClass, 

39 DateFormat, 

40 ICD10_COPYRIGHT_DIV, 

41) 

42from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo 

43from camcops_server.cc_modules.cc_html import ( 

44 get_present_absent_none, 

45 heading_spanning_two_columns, 

46 subheading_spanning_two_columns, 

47 tr_qa, 

48) 

49from camcops_server.cc_modules.cc_request import CamcopsRequest 

50from camcops_server.cc_modules.cc_sqla_coltypes import ( 

51 BIT_CHECKER, 

52 mapped_camcops_column, 

53 SummaryCategoryColType, 

54) 

55from camcops_server.cc_modules.cc_string import AS 

56from camcops_server.cc_modules.cc_summaryelement import SummaryElement 

57from camcops_server.cc_modules.cc_task import ( 

58 Task, 

59 TaskHasClinicianMixin, 

60 TaskHasPatientMixin, 

61) 

62from camcops_server.cc_modules.cc_text import SS 

63 

64 

65# ============================================================================= 

66# Icd10Manic 

67# ============================================================================= 

68 

69 

70class Icd10Manic(TaskHasClinicianMixin, TaskHasPatientMixin, Task): # type: ignore[misc] # noqa: E501 

71 """ 

72 Server implementation of the ICD10-MANIC task. 

73 """ 

74 

75 __tablename__ = "icd10manic" 

76 shortname = "ICD10-MANIC" 

77 info_filename_stem = "icd" 

78 

79 mood_elevated: Mapped[Optional[bool]] = mapped_camcops_column( 

80 permitted_value_checker=BIT_CHECKER, 

81 comment="The mood is 'elevated' [hypomania] or 'predominantly " 

82 "elevated [or] expansive' [mania] to a degree that is " 

83 "definitely abnormal for the individual concerned.", 

84 ) 

85 mood_irritable: Mapped[Optional[bool]] = mapped_camcops_column( 

86 permitted_value_checker=BIT_CHECKER, 

87 comment="The mood is 'irritable' [hypomania] or 'predominantly " 

88 "irritable' [mania] to a degree that is definitely abnormal " 

89 "for the individual concerned.", 

90 ) 

91 

92 distractible: Mapped[Optional[bool]] = mapped_camcops_column( 

93 permitted_value_checker=BIT_CHECKER, 

94 comment="Difficulty in concentration or distractibility [from " 

95 "the criteria for hypomania]; distractibility or constant " 

96 "changes in activity or plans [from the criteria for mania].", 

97 ) 

98 activity: Mapped[Optional[bool]] = mapped_camcops_column( 

99 permitted_value_checker=BIT_CHECKER, 

100 comment="Increased activity or physical restlessness.", 

101 ) 

102 sleep: Mapped[Optional[bool]] = mapped_camcops_column( 

103 permitted_value_checker=BIT_CHECKER, 

104 comment="Decreased need for sleep.", 

105 ) 

106 talkativeness: Mapped[Optional[bool]] = mapped_camcops_column( 

107 permitted_value_checker=BIT_CHECKER, 

108 comment="Increased talkativeness (pressure of speech).", 

109 ) 

110 recklessness: Mapped[Optional[bool]] = mapped_camcops_column( 

111 permitted_value_checker=BIT_CHECKER, 

112 comment="Mild spending sprees, or other types of reckless or " 

113 "irresponsible behaviour [hypomania]; behaviour which is " 

114 "foolhardy or reckless and whose risks the subject does not " 

115 "recognize e.g. spending sprees, foolish enterprises, " 

116 "reckless driving [mania].", 

117 ) 

118 social_disinhibition: Mapped[Optional[bool]] = mapped_camcops_column( 

119 permitted_value_checker=BIT_CHECKER, 

120 comment="Increased sociability or over-familiarity [hypomania]; " 

121 "loss of normal social inhibitions resulting in behaviour " 

122 "which is inappropriate to the circumstances [mania].", 

123 ) 

124 sexual: Mapped[Optional[bool]] = mapped_camcops_column( 

125 permitted_value_checker=BIT_CHECKER, 

126 comment="Increased sexual energy [hypomania]; marked sexual " 

127 "energy or sexual indiscretions [mania].", 

128 ) 

129 

130 grandiosity: Mapped[Optional[bool]] = mapped_camcops_column( 

131 permitted_value_checker=BIT_CHECKER, 

132 comment="Inflated self-esteem or grandiosity.", 

133 ) 

134 flight_of_ideas: Mapped[Optional[bool]] = mapped_camcops_column( 

135 permitted_value_checker=BIT_CHECKER, 

136 comment="Flight of ideas or the subjective experience of " 

137 "thoughts racing.", 

138 ) 

139 

140 sustained4days: Mapped[Optional[bool]] = mapped_camcops_column( 

141 permitted_value_checker=BIT_CHECKER, 

142 comment="Elevated/irritable mood sustained for at least 4 days.", 

143 ) 

144 sustained7days: Mapped[Optional[bool]] = mapped_camcops_column( 

145 permitted_value_checker=BIT_CHECKER, 

146 comment="Elevated/irritable mood sustained for at least 7 days.", 

147 ) 

148 admission_required: Mapped[Optional[bool]] = mapped_camcops_column( 

149 permitted_value_checker=BIT_CHECKER, 

150 comment="Elevated/irritable mood severe enough to require " 

151 "hospital admission.", 

152 ) 

153 some_interference_functioning: Mapped[Optional[bool]] = ( 

154 mapped_camcops_column( 

155 permitted_value_checker=BIT_CHECKER, 

156 comment="Some interference with personal functioning " 

157 "in daily living.", 

158 ) 

159 ) 

160 severe_interference_functioning: Mapped[Optional[bool]] = ( 

161 mapped_camcops_column( 

162 permitted_value_checker=BIT_CHECKER, 

163 comment="Severe interference with personal " 

164 "functioning in daily living.", 

165 ) 

166 ) 

167 

168 perceptual_alterations: Mapped[Optional[bool]] = mapped_camcops_column( 

169 permitted_value_checker=BIT_CHECKER, 

170 comment="Perceptual alterations (e.g. subjective hyperacusis, " 

171 "appreciation of colours as specially vivid, etc.).", 

172 ) # ... not psychotic 

173 hallucinations_schizophrenic: Mapped[Optional[bool]] = ( 

174 mapped_camcops_column( 

175 permitted_value_checker=BIT_CHECKER, 

176 comment="Hallucinations that are 'typically schizophrenic' " 

177 "(hallucinatory voices giving a running commentary on the " 

178 "patient's behaviour, or discussing him between themselves, " 

179 "or other types of hallucinatory voices coming from some part " 

180 "of the body).", 

181 ) 

182 ) 

183 hallucinations_other: Mapped[Optional[bool]] = mapped_camcops_column( 

184 permitted_value_checker=BIT_CHECKER, 

185 comment="Hallucinations (of any other kind).", 

186 ) 

187 delusions_schizophrenic: Mapped[Optional[bool]] = mapped_camcops_column( 

188 permitted_value_checker=BIT_CHECKER, 

189 comment="Delusions that are 'typically schizophrenic' (delusions " 

190 "of control, influence or passivity, clearly referred to body " 

191 "or limb movements or specific thoughts, actions, or " 

192 "sensations; delusional perception; persistent delusions of " 

193 "other kinds that are culturally inappropriate and completely " 

194 "impossible).", 

195 ) 

196 delusions_other: Mapped[Optional[bool]] = mapped_camcops_column( 

197 permitted_value_checker=BIT_CHECKER, 

198 comment="Delusions (of any other kind).", 

199 ) 

200 

201 date_pertains_to: Mapped[Optional[datetime.date]] = mapped_column( 

202 comment="Date the assessment pertains to" 

203 ) 

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

205 UnicodeText, comment="Clinician's comments" 

206 ) 

207 

208 CORE_NAMES = ["mood_elevated", "mood_irritable"] 

209 HYPOMANIA_MANIA_NAMES = [ 

210 "distractible", 

211 "activity", 

212 "sleep", 

213 "talkativeness", 

214 "recklessness", 

215 "social_disinhibition", 

216 "sexual", 

217 ] 

218 MANIA_NAMES = ["grandiosity", "flight_of_ideas"] 

219 OTHER_CRITERIA_NAMES = [ 

220 "sustained4days", 

221 "sustained7days", 

222 "admission_required", 

223 "some_interference_functioning", 

224 "severe_interference_functioning", 

225 ] 

226 PSYCHOSIS_NAMES = [ 

227 "perceptual_alterations", # not psychotic 

228 "hallucinations_schizophrenic", 

229 "hallucinations_other", 

230 "delusions_schizophrenic", 

231 "delusions_other", 

232 ] 

233 

234 @staticmethod 

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

236 _ = req.gettext 

237 return _( 

238 "ICD-10 symptomatic criteria for a manic/hypomanic episode " 

239 "(as in e.g. F06.3, F25, F30, F31)" 

240 ) 

241 

242 def get_clinical_text(self, req: CamcopsRequest) -> List[CtvInfo]: 

243 if not self.is_complete(): 

244 return CTV_INCOMPLETE 

245 infolist = [ 

246 CtvInfo( 

247 content="Pertains to: {}. Category: {}.".format( 

248 format_datetime( 

249 self.date_pertains_to, DateFormat.LONG_DATE 

250 ), 

251 self.get_description(req), 

252 ) 

253 ) 

254 ] 

255 if self.comments: 

256 infolist.append(CtvInfo(content=ws.webify(self.comments))) 

257 return infolist 

258 

259 def get_summaries(self, req: CamcopsRequest) -> List[SummaryElement]: 

260 return self.standard_task_summary_fields() + [ 

261 SummaryElement( 

262 name="category", 

263 coltype=SummaryCategoryColType, 

264 value=self.get_description(req), 

265 comment="Diagnostic category", 

266 ), 

267 SummaryElement( 

268 name="psychotic_symptoms", 

269 coltype=Boolean(), 

270 value=self.psychosis_present(), 

271 comment="Psychotic symptoms present?", 

272 ), 

273 ] 

274 

275 # Meets criteria? These also return null for unknown. 

276 def meets_criteria_mania_psychotic_schizophrenic(self) -> Optional[bool]: 

277 x = self.meets_criteria_mania_ignoring_psychosis() 

278 if not x: 

279 return x 

280 if self.hallucinations_other or self.delusions_other: 

281 return False # that counts as manic psychosis 

282 if self.hallucinations_other is None or self.delusions_other is None: 

283 return None # might be manic psychosis 

284 if self.hallucinations_schizophrenic or self.delusions_schizophrenic: 

285 return True 

286 if ( 

287 self.hallucinations_schizophrenic is None 

288 or self.delusions_schizophrenic is None 

289 ): 

290 return None 

291 return False 

292 

293 def meets_criteria_mania_psychotic_icd(self) -> Optional[bool]: 

294 x = self.meets_criteria_mania_ignoring_psychosis() 

295 if not x: 

296 return x 

297 if self.hallucinations_other or self.delusions_other: 

298 return True 

299 if self.hallucinations_other is None or self.delusions_other is None: 

300 return None 

301 return False 

302 

303 def meets_criteria_mania_nonpsychotic(self) -> Optional[bool]: 

304 x = self.meets_criteria_mania_ignoring_psychosis() 

305 if not x: 

306 return x 

307 if ( 

308 self.hallucinations_schizophrenic is None 

309 or self.delusions_schizophrenic is None 

310 or self.hallucinations_other is None 

311 or self.delusions_other is None 

312 ): 

313 return None 

314 if ( 

315 self.hallucinations_schizophrenic 

316 or self.delusions_schizophrenic 

317 or self.hallucinations_other 

318 or self.delusions_other 

319 ): 

320 return False 

321 return True 

322 

323 def meets_criteria_mania_ignoring_psychosis(self) -> Optional[bool]: 

324 # When can we say "definitely not"? 

325 if is_false(self.mood_elevated) and is_false(self.mood_irritable): 

326 return False 

327 if is_false(self.sustained7days) and is_false(self.admission_required): 

328 return False 

329 t = self.count_booleans( 

330 self.HYPOMANIA_MANIA_NAMES 

331 ) + self.count_booleans(self.MANIA_NAMES) 

332 u = self.n_fields_none( 

333 self.HYPOMANIA_MANIA_NAMES 

334 ) + self.n_fields_none(self.MANIA_NAMES) 

335 if self.mood_elevated and (t + u < 3): 

336 # With elevated mood, need at least 3 symptoms 

337 return False 

338 if is_false(self.mood_elevated) and (t + u < 4): 

339 # With only irritable mood, need at least 4 symptoms 

340 return False 

341 if is_false(self.severe_interference_functioning): 

342 return False 

343 # OK. When can we say "yes"? 

344 if ( 

345 (self.mood_elevated or self.mood_irritable) 

346 and (self.sustained7days or self.admission_required) 

347 and ( 

348 (self.mood_elevated and t >= 3) 

349 or (self.mood_irritable and t >= 4) 

350 ) 

351 and self.severe_interference_functioning 

352 ): 

353 return True 

354 return None 

355 

356 def meets_criteria_hypomania(self) -> Optional[bool]: 

357 # When can we say "definitely not"? 

358 if self.meets_criteria_mania_ignoring_psychosis(): 

359 return False # silly to call it hypomania if it's mania 

360 if is_false(self.mood_elevated) and is_false(self.mood_irritable): 

361 return False 

362 if is_false(self.sustained4days): 

363 return False 

364 t = self.count_booleans(self.HYPOMANIA_MANIA_NAMES) 

365 u = self.n_fields_none(self.HYPOMANIA_MANIA_NAMES) 

366 if t + u < 3: 

367 # Need at least 3 symptoms 

368 return False 

369 if is_false(self.some_interference_functioning): 

370 return False 

371 # OK. When can we say "yes"? 

372 if ( 

373 (self.mood_elevated or self.mood_irritable) 

374 and self.sustained4days 

375 and t >= 3 

376 and self.some_interference_functioning 

377 ): 

378 return True 

379 return None 

380 

381 def meets_criteria_none(self) -> Optional[bool]: 

382 h = self.meets_criteria_hypomania() 

383 m = self.meets_criteria_mania_ignoring_psychosis() 

384 if h or m: 

385 return False 

386 if is_false(h) and is_false(m): 

387 return True 

388 return None 

389 

390 def psychosis_present(self) -> Optional[bool]: 

391 if ( 

392 self.hallucinations_other 

393 or self.hallucinations_schizophrenic 

394 or self.delusions_other 

395 or self.delusions_schizophrenic 

396 ): 

397 return True 

398 if ( 

399 self.hallucinations_other is None 

400 or self.hallucinations_schizophrenic is None 

401 or self.delusions_other is None 

402 or self.delusions_schizophrenic is None 

403 ): 

404 return None 

405 return False 

406 

407 def get_description(self, req: CamcopsRequest) -> str: 

408 if self.meets_criteria_mania_psychotic_schizophrenic(): 

409 return self.wxstring(req, "category_manic_psychotic_schizophrenic") 

410 elif self.meets_criteria_mania_psychotic_icd(): 

411 return self.wxstring(req, "category_manic_psychotic") 

412 elif self.meets_criteria_mania_nonpsychotic(): 

413 return self.wxstring(req, "category_manic_nonpsychotic") 

414 elif self.meets_criteria_hypomania(): 

415 return self.wxstring(req, "category_hypomanic") 

416 elif self.meets_criteria_none(): 

417 return self.wxstring(req, "category_none") 

418 else: 

419 return req.sstring(SS.UNKNOWN) 

420 

421 def is_complete(self) -> bool: 

422 return ( 

423 self.date_pertains_to is not None 

424 and self.meets_criteria_none() is not None 

425 and self.field_contents_valid() 

426 ) 

427 

428 def text_row(self, req: CamcopsRequest, wstringname: str) -> str: 

429 return heading_spanning_two_columns(self.wxstring(req, wstringname)) 

430 

431 def row_true_false(self, req: CamcopsRequest, fieldname: str) -> str: 

432 return self.get_twocol_bool_row_true_false( 

433 req, fieldname, self.wxstring(req, "" + fieldname) 

434 ) 

435 

436 def get_task_html(self, req: CamcopsRequest) -> str: 

437 h = """ 

438 {clinician_comments} 

439 <div class="{CssClass.SUMMARY}"> 

440 <table class="{CssClass.SUMMARY}"> 

441 {tr_is_complete} 

442 {date_pertains_to} 

443 {category} 

444 {psychotic_symptoms} 

445 </table> 

446 </div> 

447 <div class="{CssClass.EXPLANATION}"> 

448 {icd10_symptomatic_disclaimer} 

449 </div> 

450 <table class="{CssClass.TASKDETAIL}"> 

451 <tr> 

452 <th width="80%">Question</th> 

453 <th width="20%">Answer</th> 

454 </tr> 

455 """.format( 

456 clinician_comments=self.get_standard_clinician_comments_block( 

457 req, self.comments 

458 ), 

459 CssClass=CssClass, 

460 tr_is_complete=self.get_is_complete_tr(req), 

461 date_pertains_to=tr_qa( 

462 req.wappstring(AS.DATE_PERTAINS_TO), 

463 format_datetime( 

464 self.date_pertains_to, DateFormat.LONG_DATE, default=None 

465 ), 

466 ), 

467 category=tr_qa( 

468 req.sstring(SS.CATEGORY) + " <sup>[1,2]</sup>", 

469 self.get_description(req), 

470 ), 

471 psychotic_symptoms=tr_qa( 

472 self.wxstring(req, "psychotic_symptoms") + " <sup>[2]</sup>", 

473 get_present_absent_none(req, self.psychosis_present()), 

474 ), 

475 icd10_symptomatic_disclaimer=req.wappstring( 

476 AS.ICD10_SYMPTOMATIC_DISCLAIMER 

477 ), 

478 ) 

479 

480 h += self.text_row(req, "core") 

481 for x in self.CORE_NAMES: 

482 h += self.row_true_false(req, x) 

483 

484 h += self.text_row(req, "hypomania_mania") 

485 for x in self.HYPOMANIA_MANIA_NAMES: 

486 h += self.row_true_false(req, x) 

487 

488 h += self.text_row(req, "other_mania") 

489 for x in self.MANIA_NAMES: 

490 h += self.row_true_false(req, x) 

491 

492 h += self.text_row(req, "other_criteria") 

493 for x in self.OTHER_CRITERIA_NAMES: 

494 h += self.row_true_false(req, x) 

495 

496 h += subheading_spanning_two_columns(self.wxstring(req, "psychosis")) 

497 for x in self.PSYCHOSIS_NAMES: 

498 h += self.row_true_false(req, x) 

499 

500 h += f""" 

501 </table> 

502 <div class="{CssClass.FOOTNOTES}"> 

503 [1] Hypomania: 

504 elevated/irritable mood 

505 + sustained for ≥4 days 

506 + at least 3 of the “other hypomania” symptoms 

507 + some interference with functioning. 

508 Mania: 

509 elevated/irritable mood 

510 + sustained for ≥7 days or hospital admission required 

511 + at least 3 of the “other mania/hypomania” symptoms 

512 (4 if mood only irritable) 

513 + severe interference with functioning. 

514 [2] ICD-10 nonpsychotic mania requires mania without 

515 hallucinations/delusions. 

516 ICD-10 psychotic mania requires mania plus 

517 hallucinations/delusions other than those that are 

518 “typically schizophrenic”. 

519 ICD-10 does not clearly categorize mania with only 

520 schizophreniform psychotic symptoms; however, Schneiderian 

521 first-rank symptoms can occur in manic psychosis 

522 (e.g. Conus P et al., 2004, PMID 15337330.). 

523 </div> 

524 {ICD10_COPYRIGHT_DIV} 

525 """ 

526 return h