Coverage for tasks/bmi.py: 39%

107 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2022-11-08 23:14 +0000

1#!/usr/bin/env python 

2 

3""" 

4camcops_server/tasks/bmi.py 

5 

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

7 

8 Copyright (C) 2012, University of Cambridge, Department of Psychiatry. 

9 Created by Rudolf Cardinal (rnc1001@cam.ac.uk). 

10 

11 This file is part of CamCOPS. 

12 

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. 

17 

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. 

22 

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

25 

26=============================================================================== 

27 

28""" 

29 

30from typing import Dict, List, Optional, TYPE_CHECKING 

31 

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 

38 

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) 

60 

61if TYPE_CHECKING: 

62 from camcops_server.cc_modules.cc_exportrecipient import ExportRecipient 

63 

64 

65# ============================================================================= 

66# BMI 

67# ============================================================================= 

68 

69BMI_DP = 2 

70KG_DP = 2 

71M_DP = 3 

72CM_DP = 1 

73 

74 

75class Bmi(TaskHasPatientMixin, Task): 

76 """ 

77 Server implementation of the BMI task. 

78 """ 

79 

80 __tablename__ = "bmi" 

81 shortname = "BMI" 

82 provides_trackers = True 

83 

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

103 

104 @staticmethod 

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

106 _ = req.gettext 

107 return _("Body mass index") 

108 

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 ) 

115 

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 ] 

169 

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 ] 

186 

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 ] 

196 

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) 

201 

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

228 

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

249 

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

264 

265 Sources: 

266 <ul> 

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

268 PMID 8594834) defined ranges as: 

269 

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

277 

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

279 

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

281 epidemic”) use the 

282 categories 

283 

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

285 [30, 35) obese class I, 

286 [35, 40) obese class II, 

287 ≥40 obese class III 

288 

289 (p9).</li> 

290 

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

292 reference use: 

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

300 

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

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

303 use 

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

308 

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

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

311 use &lt;17.5.</li> 

312 

313 <li>In anorexia nervosa: 

314 

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

316 &lt;15 severe anorexia; 

317 13–15 medium risk, 

318 &lt;13 high risk (of death) 

319 

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

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

322 </ul> 

323 </div> 

324 """ 

325 

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 

374 

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] 

382 

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 ) 

419 

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 ) 

456 

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 ) 

491 

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 ) 

528 

529 return bundle_entries