Coverage for tasks/bmi.py : 40%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1#!/usr/bin/env python
3"""
4camcops_server/tasks/bmi.py
6===============================================================================
8 Copyright (C) 2012-2020 Rudolf Cardinal (rudolf@pobox.com).
10 This file is part of CamCOPS.
12 CamCOPS is free software: you can redistribute it and/or modify
13 it under the terms of the GNU General Public License as published by
14 the Free Software Foundation, either version 3 of the License, or
15 (at your option) any later version.
17 CamCOPS is distributed in the hope that it will be useful,
18 but WITHOUT ANY WARRANTY; without even the implied warranty of
19 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
20 GNU General Public License for more details.
22 You should have received a copy of the GNU General Public License
23 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>.
25===============================================================================
27"""
29from typing import List, Optional
31import cardinal_pythonlib.rnc_web as ws
32from sqlalchemy.sql.schema import Column
33from sqlalchemy.sql.sqltypes import Float, UnicodeText
35from camcops_server.cc_modules.cc_constants import CssClass
36from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo
37from camcops_server.cc_modules.cc_html import tr_qa
38from camcops_server.cc_modules.cc_request import CamcopsRequest
39from camcops_server.cc_modules.cc_snomed import (
40 SnomedAttributeGroup,
41 SnomedExpression,
42 SnomedLookup,
43)
44from camcops_server.cc_modules.cc_summaryelement import SummaryElement
45from camcops_server.cc_modules.cc_sqla_coltypes import (
46 CamcopsColumn,
47 PermittedValueChecker,
48)
49from camcops_server.cc_modules.cc_task import Task, TaskHasPatientMixin
50from camcops_server.cc_modules.cc_trackerhelpers import (
51 LabelAlignment,
52 TrackerInfo,
53 TrackerLabel,
54) # noqa
57# =============================================================================
58# BMI
59# =============================================================================
61BMI_DP = 2
62KG_DP = 2
63M_DP = 3
64CM_DP = 1
67class Bmi(TaskHasPatientMixin, Task):
68 """
69 Server implementation of the BMI task.
70 """
71 __tablename__ = "bmi"
72 shortname = "BMI"
73 provides_trackers = True
75 height_m = CamcopsColumn(
76 "height_m", Float,
77 permitted_value_checker=PermittedValueChecker(minimum=0),
78 comment="height (m)"
79 )
80 mass_kg = CamcopsColumn(
81 "mass_kg", Float,
82 permitted_value_checker=PermittedValueChecker(minimum=0),
83 comment="mass (kg)"
84 )
85 waist_cm = CamcopsColumn(
86 "waist_cm", Float,
87 permitted_value_checker=PermittedValueChecker(minimum=0),
88 comment="waist circumference (cm)"
89 )
90 comment = Column(
91 "comment", UnicodeText,
92 comment="Clinician's comment"
93 )
95 @staticmethod
96 def longname(req: "CamcopsRequest") -> str:
97 _ = req.gettext
98 return _("Body mass index")
100 def is_complete(self) -> bool:
101 return (
102 self.height_m is not None and
103 self.mass_kg is not None and
104 self.field_contents_valid()
105 )
107 def get_trackers(self, req: CamcopsRequest) -> List[TrackerInfo]:
108 # $ signs enable TEX mode for matplotlib, e.g. "$BMI (kg/m^2)$"
109 return [
110 TrackerInfo(
111 value=self.bmi(),
112 plot_label="Body mass index",
113 axis_label="BMI (kg/m^2)",
114 axis_min=10,
115 axis_max=42,
116 horizontal_lines=[
117 13,
118 15,
119 16,
120 17,
121 17.5,
122 18.5,
123 25,
124 30,
125 35,
126 40
127 ],
128 horizontal_labels=[
129 # positioned near the mid-range for some:
130 TrackerLabel(12.5,
131 self.wxstring(req, "underweight_under_13"),
132 LabelAlignment.top),
133 TrackerLabel(14, self.wxstring(req, "underweight_13_15")),
134 TrackerLabel(15.5,
135 self.wxstring(req, "underweight_15_16")),
136 TrackerLabel(16.5,
137 self.wxstring(req, "underweight_16_17")),
138 TrackerLabel(17.25,
139 self.wxstring(req, "underweight_17_17.5")),
140 TrackerLabel(18,
141 self.wxstring(req, "underweight_17.5_18.5")),
142 TrackerLabel(21.75, self.wxstring(req, "normal")),
143 TrackerLabel(27.5, self.wxstring(req, "overweight")),
144 TrackerLabel(32.5, self.wxstring(req, "obese_1")),
145 TrackerLabel(37.6, self.wxstring(req, "obese_2")),
146 TrackerLabel(40.5, self.wxstring(req, "obese_3"),
147 LabelAlignment.bottom),
148 ],
149 aspect_ratio=1.0,
150 ),
151 TrackerInfo(
152 value=self.mass_kg,
153 plot_label="Mass (kg)",
154 axis_label="Mass (kg)"
155 ),
156 TrackerInfo(
157 value=self.waist_cm,
158 plot_label="Waist circumference (cm)",
159 axis_label="Waist circumference (cm)"
160 ),
161 ]
163 def get_clinical_text(self, req: CamcopsRequest) -> List[CtvInfo]:
164 if not self.is_complete():
165 return CTV_INCOMPLETE
166 return [CtvInfo(
167 content=(
168 f"BMI: {ws.number_to_dp(self.bmi(), BMI_DP)} kg⋅m<sup>–2</sup>"
169 f" [{self.category(req)}]."
170 f" Mass: {ws.number_to_dp(self.mass_kg, KG_DP)} kg. "
171 f" Height: {ws.number_to_dp(self.height_m, M_DP)} m."
172 f" Waist circumference:"
173 f" {ws.number_to_dp(self.waist_cm, CM_DP)} cm."
174 )
175 )]
177 def get_summaries(self, req: CamcopsRequest) -> List[SummaryElement]:
178 return self.standard_task_summary_fields() + [
179 SummaryElement(name="bmi", coltype=Float(),
180 value=self.bmi(), comment="BMI (kg/m^2)"),
181 ]
183 def bmi(self) -> Optional[float]:
184 if not self.is_complete():
185 return None
186 return self.mass_kg / (self.height_m * self.height_m)
188 def category(self, req: CamcopsRequest) -> str:
189 bmi = self.bmi()
190 if bmi is None:
191 return "?"
192 elif bmi >= 40:
193 return self.wxstring(req, "obese_3")
194 elif bmi >= 35:
195 return self.wxstring(req, "obese_2")
196 elif bmi >= 30:
197 return self.wxstring(req, "obese_1")
198 elif bmi >= 25:
199 return self.wxstring(req, "overweight")
200 elif bmi >= 18.5:
201 return self.wxstring(req, "normal")
202 elif bmi >= 17.5:
203 return self.wxstring(req, "underweight_17.5_18.5")
204 elif bmi >= 17:
205 return self.wxstring(req, "underweight_17_17.5")
206 elif bmi >= 16:
207 return self.wxstring(req, "underweight_16_17")
208 elif bmi >= 15:
209 return self.wxstring(req, "underweight_15_16")
210 elif bmi >= 13:
211 return self.wxstring(req, "underweight_13_15")
212 else:
213 return self.wxstring(req, "underweight_under_13")
215 def get_task_html(self, req: CamcopsRequest) -> str:
216 return f"""
217 <div class="{CssClass.SUMMARY}">
218 <table class="{CssClass.SUMMARY}">
219 {self.get_is_complete_tr(req)}
220 {tr_qa("BMI (kg/m<sup>2</sup>)",
221 ws.number_to_dp(self.bmi(), BMI_DP))}
222 {tr_qa("Category <sup>[1]</sup>", self.category(req))}
223 </table>
224 </div>
225 <table class="{CssClass.TASKDETAIL}">
226 {tr_qa("Mass (kg)", ws.number_to_dp(self.mass_kg, KG_DP))}
227 {tr_qa("Height (m)", ws.number_to_dp(self.height_m, M_DP))}
228 {tr_qa("Waist circumference (cm)",
229 ws.number_to_dp(self.waist_cm, CM_DP))}
230 {tr_qa("Comment", ws.webify(self.comment))}
231 </table>
232 <div class="{CssClass.FOOTNOTES}">
233 [1] Categorization <b>for adults</b> (square brackets
234 inclusive, parentheses exclusive; AN anorexia nervosa):
236 <13 very severely underweight (WHO grade 3; RCPsych severe
237 AN, high risk);
238 [13, 15] very severely underweight (WHO grade 3; RCPsych severe
239 AN, medium risk);
240 [15, 16) severely underweight (WHO grade 3; AN);
241 [16, 17) underweight (WHO grade 2; AN);
242 [17, 17.5) underweight (WHO grade 1; below ICD-10/RCPsych AN
243 cutoff);
244 [17.5, 18.5) underweight (WHO grade 1);
245 [18.5, 25) normal (healthy weight);
246 [25, 30) overweight;
247 [30, 35) obese class I (moderately obese);
248 [35, 40) obese class II (severely obese);
249 ≥40 obese class III (very severely obese).
251 Sources:
252 <ul>
253 <li>WHO Expert Committee on Physical Status (1995,
254 PMID 8594834) defined ranges as:
256 <16 grade 3 thinness,
257 [16, 17) grade 2 thinness,
258 [17, 18.5) grade 1 thinness,
259 [18.5, 25) normal,
260 [25, 30) grade 1 overweight,
261 [30, 40) grade 2 overweight,
262 ≥40 grade 3 overweight
264 (sections 7.2.1 and 8.7.1 and p452).</li>
266 <li>WHO (1998 “Obesity: preventing and managing the global
267 epidemic”) use the
268 categories
270 [25, 30) “pre-obese”,
271 [30, 35) obese class I,
272 [35, 40) obese class II,
273 ≥40 obese class III
275 (p9).</li>
277 <li>A large number of web sources that don’t cite a primary
278 reference use:
279 <15 very severely underweight;
280 [15, 16) severely underweight;
281 [16, 18.5) underweight;
282 [18.5, 25] normal (healthy weight);
283 [25, 30) obese class I (moderately obese);
284 [35, 40) obese class II (severely obese);
285 ≥40 obese class III (very severely obese);
287 <li>The WHO (2010 “Nutrition Landscape Information System
288 (NILS) country profile indicators: interpretation guide”)
289 use
290 <16 “severe thinness” (previously grade 3 thinness),
291 (16, 17] “moderate thinness” (previously grade 2 thinness),
292 [17, 18.5) “underweight” (previously grade 1 thinness).
293 (p3).</li>
295 <li>ICD-10 BMI threshold for anorexia nervosa is ≤17.5
296 (WHO, 1992). Subsequent references (e.g. RCPsych, below)
297 use <17.5.</li>
299 <li>In anorexia nervosa:
301 <17.5 anorexia (threshold for diagnosis),
302 <15 severe anorexia;
303 13–15 medium risk,
304 <13 high risk (of death)
306 (Royal College of Psychiatrists, 2010, report CR162,
307 pp. 11, 15, 20, 56).</li>
308 </ul>
309 </div>
310 """
312 def get_snomed_codes(self, req: CamcopsRequest) -> List[SnomedExpression]:
313 expressions = [] # type: List[SnomedExpression]
314 procedure_bmi = req.snomed(SnomedLookup.BMI_PROCEDURE_MEASUREMENT)
315 unit = req.snomed(SnomedLookup.UNIT_OF_MEASURE)
316 if self.is_complete():
317 kg = req.snomed(SnomedLookup.KILOGRAM)
318 m = req.snomed(SnomedLookup.METRE)
319 kg_per_sq_m = req.snomed(SnomedLookup.KG_PER_SQ_M)
320 qty_bmi = req.snomed(SnomedLookup.BMI_OBSERVABLE)
321 qty_height = req.snomed(SnomedLookup.BODY_HEIGHT_OBSERVABLE)
322 qty_weight = req.snomed(SnomedLookup.BODY_WEIGHT_OBSERVABLE)
323 expressions.append(SnomedExpression(procedure_bmi, [
324 SnomedAttributeGroup({
325 qty_bmi: self.bmi(),
326 unit: kg_per_sq_m,
327 }),
328 SnomedAttributeGroup({
329 qty_weight: self.mass_kg,
330 unit: kg,
331 }),
332 SnomedAttributeGroup({
333 qty_height: self.height_m,
334 unit: m,
335 }),
336 ]))
337 else:
338 expressions.append(SnomedExpression(procedure_bmi))
339 if self.waist_cm is not None:
340 procedure_waist = req.snomed(
341 SnomedLookup.WAIST_CIRCUMFERENCE_PROCEDURE_MEASUREMENT)
342 cm = req.snomed(SnomedLookup.CENTIMETRE)
343 qty_waist_circum = req.snomed(
344 SnomedLookup.WAIST_CIRCUMFERENCE_OBSERVABLE)
345 expressions.append(SnomedExpression(procedure_waist, [
346 SnomedAttributeGroup({
347 qty_waist_circum: self.waist_cm,
348 unit: cm,
349 }),
350 ]))
351 return expressions