Coverage for tasks/pbq.py: 65%
104 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/pbq.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"""
28from typing import Any, cast, List, Type
30from cardinal_pythonlib.classes import classproperty
31from cardinal_pythonlib.stringfunc import strnumlist, strseq
32from sqlalchemy.sql.sqltypes import Integer
34from camcops_server.cc_modules.cc_constants import CssClass
35from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo
36from camcops_server.cc_modules.cc_html import answer, tr
37from camcops_server.cc_modules.cc_report import (
38 AverageScoreReport,
39 ScoreDetails,
40)
41from camcops_server.cc_modules.cc_request import CamcopsRequest
42from camcops_server.cc_modules.cc_sqla_coltypes import (
43 camcops_column,
44 PermittedValueChecker,
45)
46from camcops_server.cc_modules.cc_summaryelement import SummaryElement
47from camcops_server.cc_modules.cc_task import Task, TaskHasPatientMixin
48from camcops_server.cc_modules.cc_trackerhelpers import TrackerInfo
51# =============================================================================
52# PBQ
53# =============================================================================
56class Pbq( # type: ignore[misc]
57 TaskHasPatientMixin,
58 Task,
59):
60 """
61 Server implementation of the PBQ task.
62 """
64 __tablename__ = "pbq"
65 shortname = "PBQ"
66 provides_trackers = True
68 MIN_PER_Q = 0
69 MAX_PER_Q = 5
70 NQUESTIONS = 25
72 @classmethod
73 def extend_columns(cls: Type["Pbq"], **kwargs: Any) -> None:
74 comment_strings = [
75 # This is the Brockington 2006 order; see XML for notes.
76 # 1-5
77 "I feel close to my baby",
78 "I wish the old days when I had no baby would come back",
79 "I feel distant from my baby",
80 "I love to cuddle my baby",
81 "I regret having this baby",
82 # 6-10
83 "The baby does not seem to be mine",
84 "My baby winds me up",
85 "I love my baby to bits",
86 "I feel happy when my baby smiles or laughs",
87 "My baby irritates me",
88 # 11-15
89 "I enjoy playing with my baby",
90 "My baby cries too much",
91 "I feel trapped as a mother",
92 "I feel angry with my baby",
93 "I resent my baby",
94 # 16-20
95 "My baby is the most beautiful baby in the world",
96 "I wish my baby would somehow go away",
97 "I have done harmful things to my baby",
98 "My baby makes me anxious",
99 "I am afraid of my baby",
100 # 21-25
101 "My baby annoys me",
102 "I feel confident when changing my baby",
103 "I feel the only solution is for someone else to look after my baby", # noqa
104 "I feel like hurting my baby",
105 "My baby is easily comforted",
106 ]
107 pvc = PermittedValueChecker(
108 minimum=cls.MIN_PER_Q, maximum=cls.MAX_PER_Q
109 )
110 for n in range(1, cls.NQUESTIONS + 1):
111 i = n - 1
112 colname = f"q{n}"
113 if n in cls.SCORED_A0N5_Q:
114 explan = "always 0 - never 5"
115 else:
116 explan = "always 5 - never 0"
117 comment = f"Q{n}, {comment_strings[i]} ({explan}, higher worse)"
118 setattr(
119 cls,
120 colname,
121 camcops_column(
122 colname,
123 Integer,
124 comment=comment,
125 permitted_value_checker=pvc,
126 ),
127 )
129 QUESTION_FIELDS = strseq("q", 1, NQUESTIONS)
130 MAX_TOTAL = MAX_PER_Q * NQUESTIONS
131 SCORED_A0N5_Q = [1, 4, 8, 9, 11, 16, 22, 25] # rest scored A5N0
132 FACTOR_1_Q = [
133 1,
134 2,
135 6,
136 7,
137 8,
138 9,
139 10,
140 12,
141 13,
142 15,
143 16,
144 17,
145 ] # 12 questions
146 FACTOR_2_Q = [3, 4, 5, 11, 14, 21, 23] # 7 questions
147 FACTOR_3_Q = [19, 20, 22, 25] # 4 questions
148 FACTOR_4_Q = [18, 24] # 2 questions
149 FACTOR_1_F = strnumlist("q", FACTOR_1_Q)
150 FACTOR_2_F = strnumlist("q", FACTOR_2_Q)
151 FACTOR_3_F = strnumlist("q", FACTOR_3_Q)
152 FACTOR_4_F = strnumlist("q", FACTOR_4_Q)
153 FACTOR_1_MAX = len(FACTOR_1_Q) * MAX_PER_Q
154 FACTOR_2_MAX = len(FACTOR_2_Q) * MAX_PER_Q
155 FACTOR_3_MAX = len(FACTOR_3_Q) * MAX_PER_Q
156 FACTOR_4_MAX = len(FACTOR_4_Q) * MAX_PER_Q
158 @staticmethod
159 def longname(req: "CamcopsRequest") -> str:
160 _ = req.gettext
161 return _("Postpartum Bonding Questionnaire")
163 def get_trackers(self, req: CamcopsRequest) -> List[TrackerInfo]:
164 return [
165 TrackerInfo(
166 value=self.total_score(),
167 plot_label="PBQ total score (lower is better)",
168 axis_label=f"Total score (out of {self.MAX_TOTAL})",
169 axis_min=-0.5,
170 axis_max=self.MAX_TOTAL + 0.5,
171 )
172 ]
174 def get_summaries(self, req: CamcopsRequest) -> List[SummaryElement]:
175 return self.standard_task_summary_fields() + [
176 SummaryElement(
177 name="total_score",
178 coltype=Integer(),
179 value=self.total_score(),
180 comment=f"Total score (/ {self.MAX_TOTAL})",
181 ),
182 SummaryElement(
183 name="factor_1_score",
184 coltype=Integer(),
185 value=self.factor_1_score(),
186 comment=f"Factor 1 score (/ {self.FACTOR_1_MAX})",
187 ),
188 SummaryElement(
189 name="factor_2_score",
190 coltype=Integer(),
191 value=self.factor_2_score(),
192 comment=f"Factor 2 score (/ {self.FACTOR_2_MAX})",
193 ),
194 SummaryElement(
195 name="factor_3_score",
196 coltype=Integer(),
197 value=self.factor_3_score(),
198 comment=f"Factor 3 score (/ {self.FACTOR_3_MAX})",
199 ),
200 SummaryElement(
201 name="factor_4_score",
202 coltype=Integer(),
203 value=self.factor_4_score(),
204 comment=f"Factor 4 score (/ {self.FACTOR_4_MAX})",
205 ),
206 ]
208 def get_clinical_text(self, req: CamcopsRequest) -> List[CtvInfo]:
209 if not self.is_complete():
210 return CTV_INCOMPLETE
211 return [
212 CtvInfo(
213 content=(
214 f"PBQ total score "
215 f"{self.total_score()}/{self.MAX_TOTAL}. "
216 f"Factor 1 score "
217 f"{self.factor_1_score()}/{self.FACTOR_1_MAX}. "
218 f"Factor 2 score "
219 f"{self.factor_2_score()}/{self.FACTOR_2_MAX}. "
220 f"Factor 3 score "
221 f"{self.factor_3_score()}/{self.FACTOR_3_MAX}. "
222 f"Factor 4 score "
223 f"{self.factor_4_score()}/{self.FACTOR_4_MAX}."
224 )
225 )
226 ]
228 def total_score(self) -> int:
229 return cast(int, self.sum_fields(self.QUESTION_FIELDS))
231 def factor_1_score(self) -> int:
232 return cast(int, self.sum_fields(self.FACTOR_1_F))
234 def factor_2_score(self) -> int:
235 return cast(int, self.sum_fields(self.FACTOR_2_F))
237 def factor_3_score(self) -> int:
238 return cast(int, self.sum_fields(self.FACTOR_3_F))
240 def factor_4_score(self) -> int:
241 return cast(int, self.sum_fields(self.FACTOR_4_F))
243 def is_complete(self) -> bool:
244 return self.field_contents_valid() and self.all_fields_not_none(
245 self.QUESTION_FIELDS
246 )
248 def get_task_html(self, req: CamcopsRequest) -> str:
249 always = self.xstring(req, "always")
250 very_often = self.xstring(req, "very_often")
251 quite_often = self.xstring(req, "quite_often")
252 sometimes = self.xstring(req, "sometimes")
253 rarely = self.xstring(req, "rarely")
254 never = self.xstring(req, "never")
255 a0n5 = {
256 0: always,
257 1: very_often,
258 2: quite_often,
259 3: sometimes,
260 4: rarely,
261 5: never,
262 }
263 a5n0 = {
264 5: always,
265 4: very_often,
266 3: quite_often,
267 2: sometimes,
268 1: rarely,
269 0: never,
270 }
271 h = f"""
272 <div class="{CssClass.SUMMARY}">
273 <table class="{CssClass.SUMMARY}">
274 {self.get_is_complete_tr(req)}
275 <tr>
276 <td>Total score</td>
277 <td>{answer(self.total_score())} / {self.MAX_TOTAL}</td>
278 </td>
279 <tr>
280 <td>Factor 1 score <sup>[1]</sup></td>
281 <td>{answer(self.factor_1_score())} / {self.FACTOR_1_MAX}</td>
282 </td>
283 <tr>
284 <td>Factor 2 score <sup>[2]</sup></td>
285 <td>{answer(self.factor_2_score())} / {self.FACTOR_2_MAX}</td>
286 </td>
287 <tr>
288 <td>Factor 3 score <sup>[3]</sup></td>
289 <td>{answer(self.factor_3_score())} / {self.FACTOR_3_MAX}</td>
290 </td>
291 <tr>
292 <td>Factor 4 score <sup>[4]</sup></td>
293 <td>{answer(self.factor_4_score())} / {self.FACTOR_4_MAX}</td>
294 </td>
295 </table>
296 </div>
297 <table class="{CssClass.TASKDETAIL}">
298 <tr>
299 <th width="60%">Question</th>
300 <th width="40%">Answer ({self.MIN_PER_Q}–{self.MAX_PER_Q})</th>
301 </tr>
302 """ # noqa
303 for q in range(1, self.NQUESTIONS + 1):
304 qtext = f"{q}. " + self.wxstring(req, f"q{q}")
305 a = getattr(self, f"q{q}")
306 option = a0n5.get(a) if q in self.SCORED_A0N5_Q else a5n0.get(a)
307 atext = f"{a}: {option}"
308 h += tr(qtext, answer(atext))
309 h += f"""
310 </table>
311 <div class="{CssClass.FOOTNOTES}">
312 Factors and cut-off scores are from Brockington et al. (2006,
313 PMID 16673041), as follows.
314 [1] General factor; ≤11 normal, ≥12 high; based on questions
315 {", ".join(str(x) for x in self.FACTOR_1_Q)}.
316 [2] Factor examining severe mother–infant relationship
317 disorders; ≤12 normal, ≥13 high (cf. original 2001 study
318 with ≤16 normal, ≥17 high); based on questions
319 {", ".join(str(x) for x in self.FACTOR_2_Q)}.
320 [3] Factor relating to infant-focused anxiety; ≤9 normal, ≥10
321 high; based on questions
322 {", ".join(str(x) for x in self.FACTOR_3_Q)}.
323 [4] Factor relating to thoughts of harm to infant; ≤1 normal,
324 ≥2 high (cf. original 2001 study with ≤2 normal, ≥3 high);
325 known low sensitivity; based on questions
326 {", ".join(str(x) for x in self.FACTOR_4_Q)}.
327 </div>
328 """
329 return h
331 # No SNOMED codes for the PBQ (checked 2019-04-01).
334class PBQReport(AverageScoreReport):
335 # noinspection PyMethodParameters
336 @classproperty
337 def report_id(cls) -> str:
338 return "PBQ"
340 @classmethod
341 def title(cls, req: "CamcopsRequest") -> str:
342 _ = req.gettext
343 return _("PBQ — Average scores")
345 # noinspection PyMethodParameters
346 @classproperty
347 def task_class(cls) -> Type[Task]:
348 return Pbq
350 @classmethod
351 def scoretypes(cls, req: "CamcopsRequest") -> List[ScoreDetails]:
352 _ = req.gettext
353 return [
354 ScoreDetails(
355 name=_("Total score"),
356 scorefunc=Pbq.total_score, # type: ignore[arg-type]
357 minimum=0,
358 maximum=Pbq.MAX_TOTAL,
359 higher_score_is_better=False,
360 ),
361 ScoreDetails(
362 name=_("Factor 1 score"),
363 scorefunc=Pbq.factor_1_score, # type: ignore[arg-type]
364 minimum=0,
365 maximum=Pbq.FACTOR_1_MAX,
366 higher_score_is_better=False,
367 ),
368 ScoreDetails(
369 name=_("Factor 2 score"),
370 scorefunc=Pbq.factor_2_score, # type: ignore[arg-type]
371 minimum=0,
372 maximum=Pbq.FACTOR_2_MAX,
373 higher_score_is_better=False,
374 ),
375 ScoreDetails(
376 name=_("Factor 3 score"),
377 scorefunc=Pbq.factor_3_score, # type: ignore[arg-type]
378 minimum=0,
379 maximum=Pbq.FACTOR_3_MAX,
380 higher_score_is_better=False,
381 ),
382 ScoreDetails(
383 name=_("Factor 4 score"),
384 scorefunc=Pbq.factor_4_score, # type: ignore[arg-type]
385 minimum=0,
386 maximum=Pbq.FACTOR_4_MAX,
387 higher_score_is_better=False,
388 ),
389 ]