Coverage for tasks/icd10manic.py: 37%
175 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/icd10manic.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"""
28import datetime
29from typing import List, Optional
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
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
65# =============================================================================
66# Icd10Manic
67# =============================================================================
70class Icd10Manic(TaskHasClinicianMixin, TaskHasPatientMixin, Task): # type: ignore[misc] # noqa: E501
71 """
72 Server implementation of the ICD10-MANIC task.
73 """
75 __tablename__ = "icd10manic"
76 shortname = "ICD10-MANIC"
77 info_filename_stem = "icd"
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 )
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 )
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 )
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 )
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 )
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 )
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 ]
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 )
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
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 ]
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
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
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
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
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
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
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
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)
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 )
428 def text_row(self, req: CamcopsRequest, wstringname: str) -> str:
429 return heading_spanning_two_columns(self.wxstring(req, wstringname))
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 )
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 )
480 h += self.text_row(req, "core")
481 for x in self.CORE_NAMES:
482 h += self.row_true_false(req, x)
484 h += self.text_row(req, "hypomania_mania")
485 for x in self.HYPOMANIA_MANIA_NAMES:
486 h += self.row_true_false(req, x)
488 h += self.text_row(req, "other_mania")
489 for x in self.MANIA_NAMES:
490 h += self.row_true_false(req, x)
492 h += self.text_row(req, "other_criteria")
493 for x in self.OTHER_CRITERIA_NAMES:
494 h += self.row_true_false(req, x)
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)
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