Coverage for tasks/bmi.py: 39%
107 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/bmi.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 Dict, List, Optional, TYPE_CHECKING
32import cardinal_pythonlib.rnc_web as ws
33from fhirclient.models.codeableconcept import CodeableConcept
34from fhirclient.models.coding import Coding
35from fhirclient.models.quantity import Quantity
36from sqlalchemy.sql.schema import Column
37from sqlalchemy.sql.sqltypes import Float, UnicodeText
39from camcops_server.cc_modules.cc_constants import CssClass, FHIRConst as Fc
40from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo
41from camcops_server.cc_modules.cc_fhir import make_fhir_bundle_entry
42from camcops_server.cc_modules.cc_html import tr_qa
43from camcops_server.cc_modules.cc_request import CamcopsRequest
44from camcops_server.cc_modules.cc_snomed import (
45 SnomedAttributeGroup,
46 SnomedExpression,
47 SnomedLookup,
48)
49from camcops_server.cc_modules.cc_summaryelement import SummaryElement
50from camcops_server.cc_modules.cc_sqla_coltypes import (
51 CamcopsColumn,
52 PermittedValueChecker,
53)
54from camcops_server.cc_modules.cc_task import Task, TaskHasPatientMixin
55from camcops_server.cc_modules.cc_trackerhelpers import (
56 LabelAlignment,
57 TrackerInfo,
58 TrackerLabel,
59)
61if TYPE_CHECKING:
62 from camcops_server.cc_modules.cc_exportrecipient import ExportRecipient
65# =============================================================================
66# BMI
67# =============================================================================
69BMI_DP = 2
70KG_DP = 2
71M_DP = 3
72CM_DP = 1
75class Bmi(TaskHasPatientMixin, Task):
76 """
77 Server implementation of the BMI task.
78 """
80 __tablename__ = "bmi"
81 shortname = "BMI"
82 provides_trackers = True
84 height_m = CamcopsColumn(
85 "height_m",
86 Float,
87 permitted_value_checker=PermittedValueChecker(minimum=0),
88 comment="height (m)",
89 )
90 mass_kg = CamcopsColumn(
91 "mass_kg",
92 Float,
93 permitted_value_checker=PermittedValueChecker(minimum=0),
94 comment="mass (kg)",
95 )
96 waist_cm = CamcopsColumn(
97 "waist_cm",
98 Float,
99 permitted_value_checker=PermittedValueChecker(minimum=0),
100 comment="waist circumference (cm)",
101 )
102 comment = Column("comment", UnicodeText, comment="Clinician's comment")
104 @staticmethod
105 def longname(req: "CamcopsRequest") -> str:
106 _ = req.gettext
107 return _("Body mass index")
109 def is_complete(self) -> bool:
110 return (
111 self.height_m is not None
112 and self.mass_kg is not None
113 and self.field_contents_valid()
114 )
116 def get_trackers(self, req: CamcopsRequest) -> List[TrackerInfo]:
117 # $ signs enable TEX mode for matplotlib, e.g. "$BMI (kg/m^2)$"
118 return [
119 TrackerInfo(
120 value=self.bmi(),
121 plot_label="Body mass index",
122 axis_label="BMI (kg/m^2)",
123 axis_min=10,
124 axis_max=42,
125 horizontal_lines=[13, 15, 16, 17, 17.5, 18.5, 25, 30, 35, 40],
126 horizontal_labels=[
127 # positioned near the mid-range for some:
128 TrackerLabel(
129 12.5,
130 self.wxstring(req, "underweight_under_13"),
131 LabelAlignment.top,
132 ),
133 TrackerLabel(14, self.wxstring(req, "underweight_13_15")),
134 TrackerLabel(
135 15.5, self.wxstring(req, "underweight_15_16")
136 ),
137 TrackerLabel(
138 16.5, self.wxstring(req, "underweight_16_17")
139 ),
140 TrackerLabel(
141 17.25, self.wxstring(req, "underweight_17_17.5")
142 ),
143 TrackerLabel(
144 18, self.wxstring(req, "underweight_17.5_18.5")
145 ),
146 TrackerLabel(21.75, self.wxstring(req, "normal")),
147 TrackerLabel(27.5, self.wxstring(req, "overweight")),
148 TrackerLabel(32.5, self.wxstring(req, "obese_1")),
149 TrackerLabel(37.6, self.wxstring(req, "obese_2")),
150 TrackerLabel(
151 40.5,
152 self.wxstring(req, "obese_3"),
153 LabelAlignment.bottom,
154 ),
155 ],
156 aspect_ratio=1.0,
157 ),
158 TrackerInfo(
159 value=self.mass_kg,
160 plot_label="Mass (kg)",
161 axis_label="Mass (kg)",
162 ),
163 TrackerInfo(
164 value=self.waist_cm,
165 plot_label="Waist circumference (cm)",
166 axis_label="Waist circumference (cm)",
167 ),
168 ]
170 def get_clinical_text(self, req: CamcopsRequest) -> List[CtvInfo]:
171 if not self.is_complete():
172 return CTV_INCOMPLETE
173 return [
174 CtvInfo(
175 content=(
176 f"BMI: {ws.number_to_dp(self.bmi(), BMI_DP)} "
177 f"kg⋅m<sup>–2</sup>"
178 f" [{self.category(req)}]."
179 f" Mass: {ws.number_to_dp(self.mass_kg, KG_DP)} kg. "
180 f" Height: {ws.number_to_dp(self.height_m, M_DP)} m."
181 f" Waist circumference:"
182 f" {ws.number_to_dp(self.waist_cm, CM_DP)} cm."
183 )
184 )
185 ]
187 def get_summaries(self, req: CamcopsRequest) -> List[SummaryElement]:
188 return self.standard_task_summary_fields() + [
189 SummaryElement(
190 name="bmi",
191 coltype=Float(),
192 value=self.bmi(),
193 comment="BMI (kg/m^2)",
194 )
195 ]
197 def bmi(self) -> Optional[float]:
198 if not self.is_complete():
199 return None
200 return self.mass_kg / (self.height_m * self.height_m)
202 def category(self, req: CamcopsRequest) -> str:
203 bmi = self.bmi()
204 if bmi is None:
205 return "?"
206 elif bmi >= 40:
207 return self.wxstring(req, "obese_3")
208 elif bmi >= 35:
209 return self.wxstring(req, "obese_2")
210 elif bmi >= 30:
211 return self.wxstring(req, "obese_1")
212 elif bmi >= 25:
213 return self.wxstring(req, "overweight")
214 elif bmi >= 18.5:
215 return self.wxstring(req, "normal")
216 elif bmi >= 17.5:
217 return self.wxstring(req, "underweight_17.5_18.5")
218 elif bmi >= 17:
219 return self.wxstring(req, "underweight_17_17.5")
220 elif bmi >= 16:
221 return self.wxstring(req, "underweight_16_17")
222 elif bmi >= 15:
223 return self.wxstring(req, "underweight_15_16")
224 elif bmi >= 13:
225 return self.wxstring(req, "underweight_13_15")
226 else:
227 return self.wxstring(req, "underweight_under_13")
229 def get_task_html(self, req: CamcopsRequest) -> str:
230 return f"""
231 <div class="{CssClass.SUMMARY}">
232 <table class="{CssClass.SUMMARY}">
233 {self.get_is_complete_tr(req)}
234 {tr_qa("BMI (kg/m<sup>2</sup>)",
235 ws.number_to_dp(self.bmi(), BMI_DP))}
236 {tr_qa("Category <sup>[1]</sup>", self.category(req))}
237 </table>
238 </div>
239 <table class="{CssClass.TASKDETAIL}">
240 {tr_qa("Mass (kg)", ws.number_to_dp(self.mass_kg, KG_DP))}
241 {tr_qa("Height (m)", ws.number_to_dp(self.height_m, M_DP))}
242 {tr_qa("Waist circumference (cm)",
243 ws.number_to_dp(self.waist_cm, CM_DP))}
244 {tr_qa("Comment", ws.webify(self.comment))}
245 </table>
246 <div class="{CssClass.FOOTNOTES}">
247 [1] Categorization <b>for adults</b> (square brackets
248 inclusive, parentheses exclusive; AN anorexia nervosa):
250 <13 very severely underweight (WHO grade 3; RCPsych severe
251 AN, high risk);
252 [13, 15] very severely underweight (WHO grade 3; RCPsych severe
253 AN, medium risk);
254 [15, 16) severely underweight (WHO grade 3; AN);
255 [16, 17) underweight (WHO grade 2; AN);
256 [17, 17.5) underweight (WHO grade 1; below ICD-10/RCPsych AN
257 cutoff);
258 [17.5, 18.5) underweight (WHO grade 1);
259 [18.5, 25) normal (healthy weight);
260 [25, 30) overweight;
261 [30, 35) obese class I (moderately obese);
262 [35, 40) obese class II (severely obese);
263 ≥40 obese class III (very severely obese).
265 Sources:
266 <ul>
267 <li>WHO Expert Committee on Physical Status (1995,
268 PMID 8594834) defined ranges as:
270 <16 grade 3 thinness,
271 [16, 17) grade 2 thinness,
272 [17, 18.5) grade 1 thinness,
273 [18.5, 25) normal,
274 [25, 30) grade 1 overweight,
275 [30, 40) grade 2 overweight,
276 ≥40 grade 3 overweight
278 (sections 7.2.1 and 8.7.1 and p452).</li>
280 <li>WHO (1998 “Obesity: preventing and managing the global
281 epidemic”) use the
282 categories
284 [25, 30) “pre-obese”,
285 [30, 35) obese class I,
286 [35, 40) obese class II,
287 ≥40 obese class III
289 (p9).</li>
291 <li>A large number of web sources that don’t cite a primary
292 reference use:
293 <15 very severely underweight;
294 [15, 16) severely underweight;
295 [16, 18.5) underweight;
296 [18.5, 25] normal (healthy weight);
297 [25, 30) obese class I (moderately obese);
298 [35, 40) obese class II (severely obese);
299 ≥40 obese class III (very severely obese);
301 <li>The WHO (2010 “Nutrition Landscape Information System
302 (NILS) country profile indicators: interpretation guide”)
303 use
304 <16 “severe thinness” (previously grade 3 thinness),
305 (16, 17] “moderate thinness” (previously grade 2 thinness),
306 [17, 18.5) “underweight” (previously grade 1 thinness).
307 (p3).</li>
309 <li>ICD-10 BMI threshold for anorexia nervosa is ≤17.5
310 (WHO, 1992). Subsequent references (e.g. RCPsych, below)
311 use <17.5.</li>
313 <li>In anorexia nervosa:
315 <17.5 anorexia (threshold for diagnosis),
316 <15 severe anorexia;
317 13–15 medium risk,
318 <13 high risk (of death)
320 (Royal College of Psychiatrists, 2010, report CR162,
321 pp. 11, 15, 20, 56).</li>
322 </ul>
323 </div>
324 """
326 def get_snomed_codes(self, req: CamcopsRequest) -> List[SnomedExpression]:
327 expressions = [] # type: List[SnomedExpression]
328 procedure_bmi = req.snomed(SnomedLookup.BMI_PROCEDURE_MEASUREMENT)
329 unit = req.snomed(SnomedLookup.UNIT_OF_MEASURE)
330 if self.is_complete():
331 kg = req.snomed(SnomedLookup.KILOGRAM)
332 m = req.snomed(SnomedLookup.METRE)
333 kg_per_sq_m = req.snomed(SnomedLookup.KG_PER_SQ_M)
334 qty_bmi = req.snomed(SnomedLookup.BMI_OBSERVABLE)
335 qty_height = req.snomed(SnomedLookup.BODY_HEIGHT_OBSERVABLE)
336 qty_weight = req.snomed(SnomedLookup.BODY_WEIGHT_OBSERVABLE)
337 expressions.append(
338 SnomedExpression(
339 procedure_bmi,
340 [
341 SnomedAttributeGroup(
342 {qty_bmi: self.bmi(), unit: kg_per_sq_m}
343 ),
344 SnomedAttributeGroup(
345 {qty_weight: self.mass_kg, unit: kg}
346 ),
347 SnomedAttributeGroup(
348 {qty_height: self.height_m, unit: m}
349 ),
350 ],
351 )
352 )
353 else:
354 expressions.append(SnomedExpression(procedure_bmi))
355 if self.waist_cm is not None:
356 procedure_waist = req.snomed(
357 SnomedLookup.WAIST_CIRCUMFERENCE_PROCEDURE_MEASUREMENT
358 )
359 cm = req.snomed(SnomedLookup.CENTIMETRE)
360 qty_waist_circum = req.snomed(
361 SnomedLookup.WAIST_CIRCUMFERENCE_OBSERVABLE
362 )
363 expressions.append(
364 SnomedExpression(
365 procedure_waist,
366 [
367 SnomedAttributeGroup(
368 {qty_waist_circum: self.waist_cm, unit: cm}
369 )
370 ],
371 )
372 )
373 return expressions
375 def get_fhir_extra_bundle_entries(
376 self, req: CamcopsRequest, recipient: "ExportRecipient"
377 ) -> List[Dict]:
378 """
379 See https://www.hl7.org/fhir/bmi.html
380 """
381 bundle_entries = [] # type: List[Dict]
383 # Height
384 if self.height_m:
385 bundle_entries.append(
386 make_fhir_bundle_entry(
387 resource_type_url=Fc.RESOURCE_TYPE_OBSERVATION,
388 identifier=self._get_fhir_observation_id(
389 req, name="height_m"
390 ),
391 resource=self._get_fhir_observation(
392 req,
393 recipient,
394 obs_dict={
395 Fc.CODE: CodeableConcept(
396 jsondict={
397 Fc.CODING: [
398 Coding(
399 jsondict={
400 Fc.SYSTEM: Fc.CODE_SYSTEM_LOINC, # noqa: E501
401 Fc.CODE: Fc.LOINC_HEIGHT_CODE,
402 Fc.DISPLAY: Fc.LOINC_HEIGHT_TEXT, # noqa: E501
403 }
404 ).as_json()
405 ]
406 }
407 ).as_json(),
408 Fc.VALUE_QUANTITY: Quantity(
409 jsondict={
410 Fc.SYSTEM: Fc.CODE_SYSTEM_UCUM,
411 Fc.CODE: Fc.UCUM_CODE_METRE,
412 Fc.VALUE: self.height_m,
413 }
414 ).as_json(),
415 },
416 ),
417 )
418 )
420 # Mass
421 if self.mass_kg:
422 bundle_entries.append(
423 make_fhir_bundle_entry(
424 resource_type_url=Fc.RESOURCE_TYPE_OBSERVATION,
425 identifier=self._get_fhir_observation_id(
426 req, name="mass_kg"
427 ),
428 resource=self._get_fhir_observation(
429 req,
430 recipient,
431 obs_dict={
432 Fc.CODE: CodeableConcept(
433 jsondict={
434 Fc.CODING: [
435 Coding(
436 jsondict={
437 Fc.SYSTEM: Fc.CODE_SYSTEM_LOINC, # noqa: E501
438 Fc.CODE: Fc.LOINC_BODY_WEIGHT_CODE, # noqa: E501
439 Fc.DISPLAY: Fc.LOINC_BODY_WEIGHT_TEXT, # noqa: E501
440 }
441 ).as_json()
442 ]
443 }
444 ).as_json(),
445 Fc.VALUE_QUANTITY: Quantity(
446 jsondict={
447 Fc.SYSTEM: Fc.CODE_SYSTEM_UCUM,
448 Fc.CODE: Fc.UCUM_CODE_KG,
449 Fc.VALUE: self.mass_kg,
450 }
451 ).as_json(),
452 },
453 ),
454 )
455 )
457 # BMI
458 if self.is_complete():
459 bundle_entries.append(
460 make_fhir_bundle_entry(
461 resource_type_url=Fc.RESOURCE_TYPE_OBSERVATION,
462 identifier=self._get_fhir_observation_id(req, name="bmi"),
463 resource=self._get_fhir_observation(
464 req,
465 recipient,
466 obs_dict={
467 Fc.CODE: CodeableConcept(
468 jsondict={
469 Fc.CODING: [
470 Coding(
471 jsondict={
472 Fc.SYSTEM: Fc.CODE_SYSTEM_LOINC, # noqa
473 Fc.CODE: Fc.LOINC_BMI_CODE,
474 Fc.DISPLAY: Fc.LOINC_BMI_TEXT,
475 }
476 ).as_json()
477 ]
478 }
479 ).as_json(),
480 Fc.VALUE_QUANTITY: Quantity(
481 jsondict={
482 Fc.SYSTEM: Fc.CODE_SYSTEM_UCUM,
483 Fc.CODE: Fc.UCUM_CODE_KG_PER_SQ_M,
484 Fc.VALUE: self.bmi(),
485 }
486 ).as_json(),
487 },
488 ),
489 )
490 )
492 # Waist circumference
493 if self.waist_cm:
494 bundle_entries.append(
495 make_fhir_bundle_entry(
496 resource_type_url=Fc.RESOURCE_TYPE_OBSERVATION,
497 identifier=self._get_fhir_observation_id(
498 req, name="waist_cm"
499 ),
500 resource=self._get_fhir_observation(
501 req,
502 recipient,
503 obs_dict={
504 Fc.CODE: CodeableConcept(
505 jsondict={
506 Fc.CODING: [
507 Coding(
508 jsondict={
509 Fc.SYSTEM: Fc.CODE_SYSTEM_LOINC, # noqa
510 Fc.CODE: Fc.LOINC_WAIST_CIRCUMFERENCE_CODE, # noqa
511 Fc.DISPLAY: Fc.LOINC_WAIST_CIRCUMFERENCE_TEXT, # noqa
512 }
513 ).as_json()
514 ]
515 }
516 ).as_json(),
517 Fc.VALUE_QUANTITY: Quantity(
518 jsondict={
519 Fc.SYSTEM: Fc.CODE_SYSTEM_UCUM,
520 Fc.CODE: Fc.UCUM_CODE_CENTIMETRE,
521 Fc.VALUE: self.waist_cm,
522 }
523 ).as_json(),
524 },
525 ),
526 )
527 )
529 return bundle_entries