Coverage for tasks/icd10depressive.py: 36%
213 statements
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-08 23:14 +0000
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-08 23:14 +0000
1#!/usr/bin/env python
3"""
4camcops_server/tasks/icd10depressive.py
6===============================================================================
8 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
9 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
11 This file is part of CamCOPS.
13 CamCOPS is free software: you can redistribute it and/or modify
14 it under the terms of the GNU General Public License as published by
15 the Free Software Foundation, either version 3 of the License, or
16 (at your option) any later version.
18 CamCOPS is distributed in the hope that it will be useful,
19 but WITHOUT ANY WARRANTY; without even the implied warranty of
20 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
21 GNU General Public License for more details.
23 You should have received a copy of the GNU General Public License
24 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>.
26===============================================================================
28"""
30from typing import List, Optional
32from cardinal_pythonlib.datetimefunc import format_datetime
33import cardinal_pythonlib.rnc_web as ws
34from sqlalchemy.sql.schema import Column
35from sqlalchemy.sql.sqltypes import Boolean, Date, Integer, 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 answer,
45 get_present_absent_none,
46 heading_spanning_two_columns,
47 tr,
48 tr_qa,
49)
50from camcops_server.cc_modules.cc_request import CamcopsRequest
51from camcops_server.cc_modules.cc_sqla_coltypes import (
52 BIT_CHECKER,
53 CamcopsColumn,
54 SummaryCategoryColType,
55)
56from camcops_server.cc_modules.cc_string import AS
57from camcops_server.cc_modules.cc_summaryelement import SummaryElement
58from camcops_server.cc_modules.cc_task import (
59 Task,
60 TaskHasClinicianMixin,
61 TaskHasPatientMixin,
62)
63from camcops_server.cc_modules.cc_text import SS
66# =============================================================================
67# Icd10Depressive
68# =============================================================================
71class Icd10Depressive(TaskHasClinicianMixin, TaskHasPatientMixin, Task):
72 """
73 Server implementation of the ICD10-DEPR task.
74 """
76 __tablename__ = "icd10depressive"
77 shortname = "ICD10-DEPR"
78 info_filename_stem = "icd"
80 mood = CamcopsColumn(
81 "mood",
82 Boolean,
83 permitted_value_checker=BIT_CHECKER,
84 comment="Depressed mood to a degree that is definitely abnormal "
85 "for the individual, present for most of the day and almost "
86 "every day, largely uninfluenced by circumstances, and "
87 "sustained for at least 2 weeks.",
88 )
89 anhedonia = CamcopsColumn(
90 "anhedonia",
91 Boolean,
92 permitted_value_checker=BIT_CHECKER,
93 comment="Loss of interest or pleasure in activities that are "
94 "normally pleasurable.",
95 )
96 energy = CamcopsColumn(
97 "energy",
98 Boolean,
99 permitted_value_checker=BIT_CHECKER,
100 comment="Decreased energy or increased fatiguability.",
101 )
103 sleep = CamcopsColumn(
104 "sleep",
105 Boolean,
106 permitted_value_checker=BIT_CHECKER,
107 comment="Sleep disturbance of any type.",
108 )
109 worth = CamcopsColumn(
110 "worth",
111 Boolean,
112 permitted_value_checker=BIT_CHECKER,
113 comment="Loss of confidence and self-esteem.",
114 )
115 appetite = CamcopsColumn(
116 "appetite",
117 Boolean,
118 permitted_value_checker=BIT_CHECKER,
119 comment="Change in appetite (decrease or increase) with "
120 "corresponding weight change.",
121 )
122 guilt = CamcopsColumn(
123 "guilt",
124 Boolean,
125 permitted_value_checker=BIT_CHECKER,
126 comment="Unreasonable feelings of self-reproach or excessive and "
127 "inappropriate guilt.",
128 )
129 concentration = CamcopsColumn(
130 "concentration",
131 Boolean,
132 permitted_value_checker=BIT_CHECKER,
133 comment="Complaints or evidence of diminished ability to think "
134 "or concentrate, such as indecisiveness or vacillation.",
135 )
136 activity = CamcopsColumn(
137 "activity",
138 Boolean,
139 permitted_value_checker=BIT_CHECKER,
140 comment="Change in psychomotor activity, with agitation or "
141 "retardation (either subjective or objective).",
142 )
143 death = CamcopsColumn(
144 "death",
145 Boolean,
146 permitted_value_checker=BIT_CHECKER,
147 comment="Recurrent thoughts of death or suicide, or any "
148 "suicidal behaviour.",
149 )
151 somatic_anhedonia = CamcopsColumn(
152 "somatic_anhedonia",
153 Boolean,
154 permitted_value_checker=BIT_CHECKER,
155 comment="Marked loss of interest or pleasure in activities that "
156 "are normally pleasurable",
157 )
158 somatic_emotional_unreactivity = CamcopsColumn(
159 "somatic_emotional_unreactivity",
160 Boolean,
161 permitted_value_checker=BIT_CHECKER,
162 comment="Lack of emotional reactions to events or "
163 "activities that normally produce an emotional response",
164 )
165 somatic_early_morning_waking = CamcopsColumn(
166 "somatic_early_morning_waking",
167 Boolean,
168 permitted_value_checker=BIT_CHECKER,
169 comment="Waking in the morning 2 hours or more before "
170 "the usual time",
171 )
172 somatic_mood_worse_morning = CamcopsColumn(
173 "somatic_mood_worse_morning",
174 Boolean,
175 permitted_value_checker=BIT_CHECKER,
176 comment="Depression worse in the morning",
177 )
178 somatic_psychomotor = CamcopsColumn(
179 "somatic_psychomotor",
180 Boolean,
181 permitted_value_checker=BIT_CHECKER,
182 comment="Objective evidence of marked psychomotor retardation or "
183 "agitation (remarked on or reported by other people)",
184 )
185 somatic_appetite = CamcopsColumn(
186 "somatic_appetite",
187 Boolean,
188 permitted_value_checker=BIT_CHECKER,
189 comment="Marked loss of appetite",
190 )
191 somatic_weight = CamcopsColumn(
192 "somatic_weight",
193 Boolean,
194 permitted_value_checker=BIT_CHECKER,
195 comment="Weight loss (5 percent or more of body weight in the past "
196 "month)"
197 # 2017-08-24: AVOID A PERCENT SYMBOL (%) FOR NOW; SEE THIS BUG:
198 # https://bitbucket.org/zzzeek/sqlalchemy/issues/4052/comment-attribute-causes-crash-during # noqa
199 )
200 somatic_libido = CamcopsColumn(
201 "somatic_libido",
202 Boolean,
203 permitted_value_checker=BIT_CHECKER,
204 comment="Marked loss of libido",
205 )
207 hallucinations_schizophrenic = CamcopsColumn(
208 "hallucinations_schizophrenic",
209 Boolean,
210 permitted_value_checker=BIT_CHECKER,
211 comment="Hallucinations that are 'typically schizophrenic' "
212 "(hallucinatory voices giving a running commentary on the "
213 "patient's behaviour, or discussing him between themselves, "
214 "or other types of hallucinatory voices coming from some part "
215 "of the body).",
216 )
217 hallucinations_other = CamcopsColumn(
218 "hallucinations_other",
219 Boolean,
220 permitted_value_checker=BIT_CHECKER,
221 comment="Hallucinations (of any other kind).",
222 )
223 delusions_schizophrenic = CamcopsColumn(
224 "delusions_schizophrenic",
225 Boolean,
226 permitted_value_checker=BIT_CHECKER,
227 comment="Delusions that are 'typically schizophrenic' (delusions "
228 "of control, influence or passivity, clearly referred to body "
229 "or limb movements or specific thoughts, actions, or "
230 "sensations; delusional perception; persistent delusions of "
231 "other kinds that are culturally inappropriate and completely "
232 "impossible).",
233 )
234 delusions_other = CamcopsColumn(
235 "delusions_other",
236 Boolean,
237 permitted_value_checker=BIT_CHECKER,
238 comment="Delusions (of any other kind).",
239 )
240 stupor = CamcopsColumn(
241 "stupor",
242 Boolean,
243 permitted_value_checker=BIT_CHECKER,
244 comment="Depressive stupor.",
245 )
247 date_pertains_to = CamcopsColumn(
248 "date_pertains_to", Date, comment="Date the assessment pertains to"
249 )
250 comments = Column("comments", UnicodeText, comment="Clinician's comments")
251 duration_at_least_2_weeks = CamcopsColumn(
252 "duration_at_least_2_weeks",
253 Boolean,
254 permitted_value_checker=BIT_CHECKER,
255 comment="Depressive episode lasts at least 2 weeks?",
256 )
257 severe_clinically = CamcopsColumn(
258 "severe_clinically",
259 Boolean,
260 permitted_value_checker=BIT_CHECKER,
261 comment="Clinical impression of severe depression, in a "
262 "patient unwilling or unable to describe many symptoms in "
263 "detail",
264 )
266 CORE_NAMES = ["mood", "anhedonia", "energy"]
267 ADDITIONAL_NAMES = [
268 "sleep",
269 "worth",
270 "appetite",
271 "guilt",
272 "concentration",
273 "activity",
274 "death",
275 ]
276 SOMATIC_NAMES = [
277 "somatic_anhedonia",
278 "somatic_emotional_unreactivity",
279 "somatic_early_morning_waking",
280 "somatic_mood_worse_morning",
281 "somatic_psychomotor",
282 "somatic_appetite",
283 "somatic_weight",
284 "somatic_libido",
285 ]
286 PSYCHOSIS_NAMES = [
287 "hallucinations_schizophrenic",
288 "hallucinations_other",
289 "delusions_schizophrenic",
290 "delusions_other",
291 "stupor",
292 ]
294 @staticmethod
295 def longname(req: "CamcopsRequest") -> str:
296 _ = req.gettext
297 return _(
298 "ICD-10 symptomatic criteria for a depressive episode "
299 "(as in e.g. F06.3, F25, F31, F32, F33)"
300 )
302 def get_clinical_text(self, req: CamcopsRequest) -> List[CtvInfo]:
303 if not self.is_complete():
304 return CTV_INCOMPLETE
305 infolist = [
306 CtvInfo(
307 content="Pertains to: {}. Category: {}.".format(
308 format_datetime(
309 self.date_pertains_to, DateFormat.LONG_DATE
310 ),
311 self.get_full_description(req),
312 )
313 )
314 ]
315 if self.comments:
316 infolist.append(CtvInfo(content=ws.webify(self.comments)))
317 return infolist
319 def get_summaries(self, req: CamcopsRequest) -> List[SummaryElement]:
320 return self.standard_task_summary_fields() + [
321 SummaryElement(
322 name="n_core",
323 coltype=Integer(),
324 value=self.n_core(),
325 comment="Number of core diagnostic symptoms (/3)",
326 ),
327 SummaryElement(
328 name="n_additional",
329 coltype=Integer(),
330 value=self.n_additional(),
331 comment="Number of additional diagnostic symptoms (/7)",
332 ),
333 SummaryElement(
334 name="n_total",
335 coltype=Integer(),
336 value=self.n_total(),
337 comment="Total number of diagnostic symptoms (/10)",
338 ),
339 SummaryElement(
340 name="n_somatic",
341 coltype=Integer(),
342 value=self.n_somatic(),
343 comment="Number of somatic syndrome symptoms (/8)",
344 ),
345 SummaryElement(
346 name="category",
347 coltype=SummaryCategoryColType,
348 value=self.get_full_description(req),
349 comment="Diagnostic category",
350 ),
351 SummaryElement(
352 name="psychosis_or_stupor",
353 coltype=Boolean(),
354 value=self.is_psychotic_or_stupor(),
355 comment="Psychotic symptoms or stupor present?",
356 ),
357 ]
359 # Scoring
360 def n_core(self) -> int:
361 return self.count_booleans(self.CORE_NAMES)
363 def n_additional(self) -> int:
364 return self.count_booleans(self.ADDITIONAL_NAMES)
366 def n_total(self) -> int:
367 return self.n_core() + self.n_additional()
369 def n_somatic(self) -> int:
370 return self.count_booleans(self.SOMATIC_NAMES)
372 def main_complete(self) -> bool:
373 return (
374 self.duration_at_least_2_weeks is not None
375 and self.all_fields_not_none(self.CORE_NAMES)
376 and self.all_fields_not_none(self.ADDITIONAL_NAMES)
377 ) or bool(self.severe_clinically)
379 # Meets criteria? These also return null for unknown.
380 def meets_criteria_severe_psychotic_schizophrenic(self) -> Optional[bool]:
381 x = self.meets_criteria_severe_ignoring_psychosis()
382 if not x:
383 return x
384 if self.stupor or self.hallucinations_other or self.delusions_other:
385 return False # that counts as F32.3
386 if (
387 self.stupor is None
388 or self.hallucinations_other is None
389 or self.delusions_other is None
390 ):
391 return None # might be F32.3
392 if self.hallucinations_schizophrenic or self.delusions_schizophrenic:
393 return True
394 if (
395 self.hallucinations_schizophrenic is None
396 or self.delusions_schizophrenic is None
397 ):
398 return None
399 return False
401 def meets_criteria_severe_psychotic_icd(self) -> Optional[bool]:
402 x = self.meets_criteria_severe_ignoring_psychosis()
403 if not x:
404 return x
405 if self.stupor or self.hallucinations_other or self.delusions_other:
406 return True
407 if (
408 self.stupor is None
409 or self.hallucinations_other is None
410 or self.delusions_other is None
411 ):
412 return None
413 return False
415 def meets_criteria_severe_nonpsychotic(self) -> Optional[bool]:
416 x = self.meets_criteria_severe_ignoring_psychosis()
417 if not x:
418 return x
419 if self.any_fields_none(self.PSYCHOSIS_NAMES):
420 return None
421 return self.count_booleans(self.PSYCHOSIS_NAMES) == 0
423 def meets_criteria_severe_ignoring_psychosis(self) -> Optional[bool]:
424 if self.severe_clinically:
425 return True
426 if (
427 self.duration_at_least_2_weeks is not None
428 and not self.duration_at_least_2_weeks
429 ):
430 return False # too short
431 if self.n_core() >= 3 and self.n_total() >= 8:
432 return True
433 if not self.main_complete():
434 return None # addition of more information might increase severity
435 return False
437 def meets_criteria_moderate(self) -> Optional[bool]:
438 if self.severe_clinically:
439 return False # too severe
440 if (
441 self.duration_at_least_2_weeks is not None
442 and not self.duration_at_least_2_weeks
443 ):
444 return False # too short
445 if self.n_core() >= 3 and self.n_total() >= 8:
446 return False # too severe; that's severe
447 if not self.main_complete():
448 return None # addition of more information might increase severity
449 if self.n_core() >= 2 and self.n_total() >= 6:
450 return True
451 return False
453 def meets_criteria_mild(self) -> Optional[bool]:
454 if self.severe_clinically:
455 return False # too severe
456 if (
457 self.duration_at_least_2_weeks is not None
458 and not self.duration_at_least_2_weeks
459 ):
460 return False # too short
461 if self.n_core() >= 2 and self.n_total() >= 6:
462 return False # too severe; that's moderate
463 if not self.main_complete():
464 return None # addition of more information might increase severity
465 if self.n_core() >= 2 and self.n_total() >= 4:
466 return True
467 return False
469 def meets_criteria_none(self) -> Optional[bool]:
470 if self.severe_clinically:
471 return False # too severe
472 if (
473 self.duration_at_least_2_weeks is not None
474 and not self.duration_at_least_2_weeks
475 ):
476 return True # too short for depression
477 if self.n_core() >= 2 and self.n_total() >= 4:
478 return False # too severe
479 if not self.main_complete():
480 return None # addition of more information might increase severity
481 return True
483 def meets_criteria_somatic(self) -> Optional[bool]:
484 t = self.n_somatic()
485 u = self.n_fields_none(self.SOMATIC_NAMES)
486 if t >= 4:
487 return True
488 elif t + u < 4:
489 return False
490 else:
491 return None
493 def get_somatic_description(self, req: CamcopsRequest) -> str:
494 s = self.meets_criteria_somatic()
495 if s is None:
496 return self.wxstring(req, "category_somatic_unknown")
497 elif s:
498 return self.wxstring(req, "category_with_somatic")
499 else:
500 return self.wxstring(req, "category_without_somatic")
502 def get_main_description(self, req: CamcopsRequest) -> str:
503 if self.meets_criteria_severe_psychotic_schizophrenic():
504 return self.wxstring(
505 req, "category_severe_psychotic_schizophrenic"
506 )
508 elif self.meets_criteria_severe_psychotic_icd():
509 return self.wxstring(req, "category_severe_psychotic")
511 elif self.meets_criteria_severe_nonpsychotic():
512 return self.wxstring(req, "category_severe_nonpsychotic")
514 elif self.meets_criteria_moderate():
515 return self.wxstring(req, "category_moderate")
517 elif self.meets_criteria_mild():
518 return self.wxstring(req, "category_mild")
520 elif self.meets_criteria_none():
521 return self.wxstring(req, "category_none")
523 else:
524 return req.sstring(SS.UNKNOWN)
526 def get_full_description(self, req: CamcopsRequest) -> str:
527 skip_somatic = self.main_complete() and self.meets_criteria_none()
528 return self.get_main_description(req) + (
529 "" if skip_somatic else " " + self.get_somatic_description(req)
530 )
532 def is_psychotic_or_stupor(self) -> Optional[bool]:
533 if self.count_booleans(self.PSYCHOSIS_NAMES) > 0:
534 return True
535 elif self.all_fields_not_none(self.PSYCHOSIS_NAMES) > 0:
536 return False
537 else:
538 return None
540 def is_complete(self) -> bool:
541 return (
542 self.date_pertains_to is not None
543 and self.main_complete()
544 and self.field_contents_valid()
545 )
547 def text_row(self, req: CamcopsRequest, wstringname: str) -> str:
548 return heading_spanning_two_columns(self.wxstring(req, wstringname))
550 def row_true_false(self, req: CamcopsRequest, fieldname: str) -> str:
551 return self.get_twocol_bool_row_true_false(
552 req, fieldname, self.wxstring(req, "" + fieldname)
553 )
555 def row_present_absent(self, req: CamcopsRequest, fieldname: str) -> str:
556 return self.get_twocol_bool_row_present_absent(
557 req, fieldname, self.wxstring(req, "" + fieldname)
558 )
560 def get_task_html(self, req: CamcopsRequest) -> str:
561 h = """
562 {clinician_comments}
563 <div class="{CssClass.SUMMARY}">
564 <table class="{CssClass.SUMMARY}">
565 {tr_is_complete}
566 {date_pertains_to}
567 {category}
568 {n_core}
569 {n_total}
570 {n_somatic}
571 {psychotic_symptoms_or_stupor}
572 </table>
573 </div>
574 <div class="{CssClass.EXPLANATION}">
575 {icd10_symptomatic_disclaimer}
576 </div>
577 <table class="{CssClass.TASKDETAIL}">
578 <tr>
579 <th width="80%">Question</th>
580 <th width="20%">Answer</th>
581 </tr>
582 """.format(
583 clinician_comments=self.get_standard_clinician_comments_block(
584 req, self.comments
585 ),
586 CssClass=CssClass,
587 tr_is_complete=self.get_is_complete_tr(req),
588 date_pertains_to=tr_qa(
589 req.wappstring(AS.DATE_PERTAINS_TO),
590 format_datetime(
591 self.date_pertains_to, DateFormat.LONG_DATE, default=None
592 ),
593 ),
594 category=tr_qa(
595 req.sstring(SS.CATEGORY) + " <sup>[1,2]</sup>",
596 self.get_full_description(req),
597 ),
598 n_core=tr(
599 self.wxstring(req, "n_core"), answer(self.n_core()) + " / 3"
600 ),
601 n_total=tr(
602 self.wxstring(req, "n_total"), answer(self.n_total()) + " / 10"
603 ),
604 n_somatic=tr(
605 self.wxstring(req, "n_somatic"),
606 answer(self.n_somatic()) + " / 8",
607 ),
608 psychotic_symptoms_or_stupor=tr(
609 self.wxstring(req, "psychotic_symptoms_or_stupor")
610 + " <sup>[2]</sup>",
611 answer(
612 get_present_absent_none(req, self.is_psychotic_or_stupor())
613 ),
614 ),
615 icd10_symptomatic_disclaimer=req.wappstring(
616 AS.ICD10_SYMPTOMATIC_DISCLAIMER
617 ),
618 )
620 h += self.text_row(req, "duration_text")
621 h += self.row_true_false(req, "duration_at_least_2_weeks")
623 h += self.text_row(req, "core")
624 for x in self.CORE_NAMES:
625 h += self.row_present_absent(req, x)
627 h += self.text_row(req, "additional")
628 for x in self.ADDITIONAL_NAMES:
629 h += self.row_present_absent(req, x)
631 h += self.text_row(req, "clinical_text")
632 h += self.row_true_false(req, "severe_clinically")
634 h += self.text_row(req, "somatic")
635 for x in self.SOMATIC_NAMES:
636 h += self.row_present_absent(req, x)
638 h += self.text_row(req, "psychotic")
639 for x in self.PSYCHOSIS_NAMES:
640 h += self.row_present_absent(req, x)
642 extradetail = [
643 f"n_core() = {self.n_core()}",
644 f"n_additional() = {self.n_additional()}",
645 f"n_total() = {self.n_total()}",
646 f"n_somatic() = {self.n_somatic()}",
647 f"main_complete() = {self.main_complete()}",
648 f"meets_criteria_severe_psychotic_schizophrenic() = {self.meets_criteria_severe_psychotic_schizophrenic()}", # noqa
649 f"meets_criteria_severe_psychotic_icd() = {self.meets_criteria_severe_psychotic_icd()}", # noqa
650 f"meets_criteria_severe_nonpsychotic() = {self.meets_criteria_severe_nonpsychotic()}", # noqa
651 f"meets_criteria_severe_ignoring_psychosis() = {self.meets_criteria_severe_ignoring_psychosis()}", # noqa
652 f"meets_criteria_moderate() = {self.meets_criteria_moderate()}",
653 f"meets_criteria_mild() = {self.meets_criteria_mild()}",
654 f"meets_criteria_none() = {self.meets_criteria_none()}",
655 f"meets_criteria_somatic() = {self.meets_criteria_somatic()}",
656 ]
658 h += f"""
659 </table>
660 <div class="{CssClass.HEADING}">Working</div>
661 <div class="{CssClass.EXTRADETAIL2}">
662 <pre>{"<br>".join(ws.webify(f"‣ {x}") for x in extradetail)}</pre>
663 </div>
664 <div class="{CssClass.FOOTNOTES}">
665 [1] Mild depression requires ≥2 core symptoms and ≥4 total
666 diagnostic symptoms.
667 Moderate depression requires ≥2 core and ≥6 total.
668 Severe depression requires 3 core and ≥8 total.
669 All three require a duration of ≥2 weeks.
670 In addition, the diagnosis of severe depression is allowed with
671 a clinical impression of “severe” in a patient unable/unwilling
672 to describe symptoms in detail.
673 [2] ICD-10 nonpsychotic severe depression requires severe
674 depression without hallucinations/delusions/depressive stupor.
675 ICD-10 psychotic depression requires severe depression plus
676 hallucinations/delusions other than those that are “typically
677 schizophrenic”, or stupor.
678 ICD-10 does not clearly categorize severe depression with only
679 schizophreniform psychotic symptoms;
680 however, such symptoms can occur in severe depression with
681 psychosis (e.g. Tandon R & Greden JF, 1987, PMID 2884810).
682 Moreover, psychotic symptoms can occur in mild/moderate
683 depression (Maj M et al., 2007, PMID 17915981).
684 </div>
685 {ICD10_COPYRIGHT_DIV}
686 """ # noqa
687 return h