Hide keyboard shortcuts

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 

2 

3""" 

4camcops_server/tasks/bmi.py 

5 

6=============================================================================== 

7 

8 Copyright (C) 2012-2020 Rudolf Cardinal (rudolf@pobox.com). 

9 

10 This file is part of CamCOPS. 

11 

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. 

16 

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. 

21 

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/>. 

24 

25=============================================================================== 

26 

27""" 

28 

29from typing import List, Optional 

30 

31import cardinal_pythonlib.rnc_web as ws 

32from sqlalchemy.sql.schema import Column 

33from sqlalchemy.sql.sqltypes import Float, UnicodeText 

34 

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 

55 

56 

57# ============================================================================= 

58# BMI 

59# ============================================================================= 

60 

61BMI_DP = 2 

62KG_DP = 2 

63M_DP = 3 

64CM_DP = 1 

65 

66 

67class Bmi(TaskHasPatientMixin, Task): 

68 """ 

69 Server implementation of the BMI task. 

70 """ 

71 __tablename__ = "bmi" 

72 shortname = "BMI" 

73 provides_trackers = True 

74 

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 ) 

94 

95 @staticmethod 

96 def longname(req: "CamcopsRequest") -> str: 

97 _ = req.gettext 

98 return _("Body mass index") 

99 

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 ) 

106 

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 ] 

162 

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 )] 

176 

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 ] 

182 

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) 

187 

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") 

214 

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): 

235 

236 &lt;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). 

250 

251 Sources: 

252 <ul> 

253 <li>WHO Expert Committee on Physical Status (1995, 

254 PMID 8594834) defined ranges as: 

255 

256 &lt;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 

263 

264 (sections 7.2.1 and 8.7.1 and p452).</li> 

265 

266 <li>WHO (1998 “Obesity: preventing and managing the global 

267 epidemic”) use the 

268 categories 

269 

270 [25, 30) “pre-obese”, 

271 [30, 35) obese class I, 

272 [35, 40) obese class II, 

273 ≥40 obese class III 

274 

275 (p9).</li> 

276 

277 <li>A large number of web sources that don’t cite a primary 

278 reference use: 

279 &lt;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); 

286 

287 <li>The WHO (2010 “Nutrition Landscape Information System 

288 (NILS) country profile indicators: interpretation guide”) 

289 use 

290 &lt;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> 

294 

295 <li>ICD-10 BMI threshold for anorexia nervosa is ≤17.5 

296 (WHO, 1992). Subsequent references (e.g. RCPsych, below) 

297 use &lt;17.5.</li> 

298 

299 <li>In anorexia nervosa: 

300 

301 &lt;17.5 anorexia (threshold for diagnosis), 

302 &lt;15 severe anorexia; 

303 13–15 medium risk, 

304 &lt;13 high risk (of death) 

305 

306 (Royal College of Psychiatrists, 2010, report CR162, 

307 pp. 11, 15, 20, 56).</li> 

308 </ul> 

309 </div> 

310 """ 

311 

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