Coverage for src/bluetooth_sig/gatt/characteristics/body_composition_measurement.py: 83%
286 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-30 00:10 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-30 00:10 +0000
1"""Body Composition Measurement characteristic implementation."""
3from __future__ import annotations
5from datetime import datetime
6from enum import IntFlag
8import msgspec
10from bluetooth_sig.types.units import MeasurementSystem, WeightUnit
12from ..constants import PERCENTAGE_MAX
13from ..context import CharacteristicContext
14from .base import BaseCharacteristic
15from .body_composition_feature import BodyCompositionFeatureCharacteristic, BodyCompositionFeatureData
16from .utils import DataParser, IEEE11073Parser
18BODY_FAT_PERCENTAGE_RESOLUTION = 0.1 # 0.1% resolution
19MUSCLE_PERCENTAGE_RESOLUTION = 0.1 # 0.1% resolution
20IMPEDANCE_RESOLUTION = 0.1 # 0.1 ohm resolution
21MASS_RESOLUTION_KG = 0.005 # 0.005 kg resolution
22MASS_RESOLUTION_LB = 0.01 # 0.01 lb resolution
23HEIGHT_RESOLUTION_METRIC = 0.001 # 0.001 m resolution
24HEIGHT_RESOLUTION_IMPERIAL = 0.1 # 0.1 inch resolution
27class FlagsAndBodyFat(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods
28 """Flags and body fat percentage with parsing offset."""
30 flags: BodyCompositionFlags
31 body_fat_percentage: float
32 offset: int
35class BasicOptionalFields(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods
36 """Basic optional fields: timestamp, user ID, and basal metabolism."""
38 timestamp: datetime | None
39 user_id: int | None
40 basal_metabolism: int | None
41 offset: int
44class MassFields(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods
45 """Mass-related optional fields."""
47 muscle_mass: float | None
48 muscle_mass_unit: WeightUnit | None
49 muscle_percentage: float | None
50 fat_free_mass: float | None
51 soft_lean_mass: float | None
52 body_water_mass: float | None
53 offset: int
56class OtherMeasurements(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods
57 """Impedance, weight, and height measurements."""
59 impedance: float | None
60 weight: float | None
61 height: float | None
64class MassValue(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods
65 """Single mass field with unit."""
67 value: float
68 unit: WeightUnit
71class BodyCompositionFlags(IntFlag):
72 """Body Composition Measurement flags as per Bluetooth SIG specification."""
74 IMPERIAL_UNITS = 0x001
75 TIMESTAMP_PRESENT = 0x002
76 USER_ID_PRESENT = 0x004
77 BASAL_METABOLISM_PRESENT = 0x008
78 MUSCLE_MASS_PRESENT = 0x010
79 MUSCLE_PERCENTAGE_PRESENT = 0x020
80 FAT_FREE_MASS_PRESENT = 0x040
81 SOFT_LEAN_MASS_PRESENT = 0x080
82 BODY_WATER_MASS_PRESENT = 0x100
83 IMPEDANCE_PRESENT = 0x200
84 WEIGHT_PRESENT = 0x400
85 HEIGHT_PRESENT = 0x800
88class BodyCompositionMeasurementData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods,too-many-instance-attributes
89 """Parsed data from Body Composition Measurement characteristic."""
91 body_fat_percentage: float
92 flags: BodyCompositionFlags
93 measurement_units: MeasurementSystem
94 timestamp: datetime | None = None
95 user_id: int | None = None
96 basal_metabolism: int | None = None
97 muscle_mass: float | None = None
98 muscle_mass_unit: WeightUnit | None = None
99 muscle_percentage: float | None = None
100 fat_free_mass: float | None = None
101 soft_lean_mass: float | None = None
102 body_water_mass: float | None = None
103 impedance: float | None = None
104 weight: float | None = None
105 height: float | None = None
107 def __post_init__(self) -> None: # pylint: disable=too-many-branches
108 """Validate body composition measurement data."""
109 if not 0.0 <= self.body_fat_percentage <= PERCENTAGE_MAX:
110 raise ValueError("Body fat percentage must be between 0-100%")
111 if not 0 <= self.flags <= 0xFFFF:
112 raise ValueError("Flags must be a 16-bit value")
114 # Validate measurement_units
115 if not isinstance(self.measurement_units, MeasurementSystem):
116 raise ValueError(f"Invalid measurement_units: {self.measurement_units!r}")
118 # Validate mass fields units and ranges
119 mass_fields = [
120 ("muscle_mass", self.muscle_mass),
121 ("fat_free_mass", self.fat_free_mass),
122 ("soft_lean_mass", self.soft_lean_mass),
123 ("body_water_mass", self.body_water_mass),
124 ("weight", self.weight),
125 ]
126 for field_name, value in mass_fields:
127 if value is not None:
128 if not 0 <= value:
129 raise ValueError(f"{field_name} must be non-negative")
131 # Validate muscle_mass_unit consistency
132 if self.muscle_mass is not None:
133 expected_unit = WeightUnit.KG if self.measurement_units == MeasurementSystem.METRIC else WeightUnit.LB
134 if self.muscle_mass_unit != expected_unit:
135 raise ValueError(f"muscle_mass_unit must be {expected_unit!r}, got {self.muscle_mass_unit!r}")
137 # Validate muscle_percentage
138 if self.muscle_percentage is not None:
139 if not 0 <= self.muscle_percentage:
140 raise ValueError("Muscle percentage must be non-negative")
142 # Validate impedance
143 if self.impedance is not None:
144 if not 0 <= self.impedance:
145 raise ValueError("Impedance must be non-negative")
147 # Validate height
148 if self.height is not None:
149 if not 0 <= self.height:
150 raise ValueError("Height must be non-negative")
152 # Validate basal_metabolism
153 if self.basal_metabolism is not None:
154 if not 0 <= self.basal_metabolism:
155 raise ValueError("Basal metabolism must be non-negative")
157 # Validate user_id
158 if self.user_id is not None:
159 if not 0 <= self.user_id <= 255:
160 raise ValueError(f"User ID must be 0-255, got {self.user_id}")
163class BodyCompositionMeasurementCharacteristic(BaseCharacteristic):
164 """Body Composition Measurement characteristic (0x2A9C).
166 Used to transmit body composition measurement data including body
167 fat percentage, muscle mass, bone mass, water percentage, and other
168 body metrics.
169 """
171 _manual_unit: str = "various" # Multiple units in measurement
173 min_length: int = 4 # Flags(2) + BodyFat(2) minimum
174 max_length: int = 50 # + Timestamp(7) + UserID(1) + Multiple measurements maximum
175 allow_variable_length: bool = True # Variable optional fields
177 def decode_value(self, data: bytearray, ctx: CharacteristicContext | None = None) -> BodyCompositionMeasurementData:
178 """Parse body composition measurement data according to Bluetooth specification.
180 Format: Flags(2) + Body Fat %(2) + [Timestamp(7)] + [User ID(1)] +
181 [Basal Metabolism(2)] + [Muscle Mass(2)] + [etc...]
183 Args:
184 data: Raw bytearray from BLE characteristic.
185 ctx: Optional CharacteristicContext providing surrounding context (may be None).
187 Returns:
188 BodyCompositionMeasurementData containing parsed body composition data.
190 Raises:
191 ValueError: If data format is invalid.
193 """
194 if len(data) < 4:
195 raise ValueError("Body Composition Measurement data must be at least 4 bytes")
197 # Parse flags and required body fat percentage
198 header = self._parse_flags_and_body_fat(data)
199 flags_enum = BodyCompositionFlags(header.flags)
200 measurement_units = (
201 MeasurementSystem.IMPERIAL
202 if BodyCompositionFlags.IMPERIAL_UNITS in flags_enum
203 else MeasurementSystem.METRIC
204 )
206 # Parse optional fields based on flags
207 basic = self._parse_basic_optional_fields(data, flags_enum, header.offset)
208 mass = self._parse_mass_fields(data, flags_enum, basic.offset)
209 other = self._parse_other_measurements(data, flags_enum, mass.offset)
211 # Validate against Body Composition Feature if context is available
212 if ctx is not None:
213 feature_char = self.get_context_characteristic(ctx, BodyCompositionFeatureCharacteristic)
214 if feature_char and feature_char.parse_success and feature_char.value:
215 self._validate_against_feature_characteristic(basic, mass, other, feature_char.value)
217 # Create struct with all parsed values
218 return BodyCompositionMeasurementData(
219 body_fat_percentage=header.body_fat_percentage,
220 flags=flags_enum,
221 measurement_units=measurement_units,
222 timestamp=basic.timestamp,
223 user_id=basic.user_id,
224 basal_metabolism=basic.basal_metabolism,
225 muscle_mass=mass.muscle_mass,
226 muscle_mass_unit=mass.muscle_mass_unit,
227 muscle_percentage=mass.muscle_percentage,
228 fat_free_mass=mass.fat_free_mass,
229 soft_lean_mass=mass.soft_lean_mass,
230 body_water_mass=mass.body_water_mass,
231 impedance=other.impedance,
232 weight=other.weight,
233 height=other.height,
234 )
236 def encode_value(self, data: BodyCompositionMeasurementData) -> bytearray:
237 """Encode body composition measurement value back to bytes.
239 Args:
240 data: BodyCompositionMeasurementData containing body composition measurement data
242 Returns:
243 Encoded bytes representing the measurement
245 """
246 result = bytearray()
248 # Encode flags and body fat percentage
249 self._encode_flags_and_body_fat(result, data)
251 # Encode optional fields based on flags
252 self._encode_optional_fields(result, data)
254 return result
256 def _encode_flags_and_body_fat(self, result: bytearray, data: BodyCompositionMeasurementData) -> None:
257 """Encode flags and body fat percentage."""
258 # Encode flags (16-bit)
259 flags = int(data.flags)
260 result.extend(DataParser.encode_int16(flags, signed=False))
262 # Encode body fat percentage (uint16 with 0.1% resolution)
263 body_fat_raw = round(data.body_fat_percentage / BODY_FAT_PERCENTAGE_RESOLUTION)
264 if not 0 <= body_fat_raw <= 0xFFFF:
265 raise ValueError(f"Body fat percentage {body_fat_raw} exceeds uint16 range")
266 result.extend(DataParser.encode_int16(body_fat_raw, signed=False))
268 def _encode_optional_fields(self, result: bytearray, data: BodyCompositionMeasurementData) -> None:
269 """Encode optional fields based on measurement data."""
270 # Encode optional timestamp if present
271 if data.timestamp is not None:
272 result.extend(IEEE11073Parser.encode_timestamp(data.timestamp))
274 # Encode optional user ID if present
275 if data.user_id is not None:
276 if not 0 <= data.user_id <= 0xFF:
277 raise ValueError(f"User ID {data.user_id} exceeds uint8 range")
278 result.append(data.user_id)
280 # Encode optional basal metabolism if present
281 if data.basal_metabolism is not None:
282 if not 0 <= data.basal_metabolism <= 0xFFFF:
283 raise ValueError(f"Basal metabolism {data.basal_metabolism} exceeds uint16 range")
284 result.extend(DataParser.encode_int16(data.basal_metabolism, signed=False))
286 # Encode mass-related fields
287 self._encode_mass_fields(result, data)
289 # Encode other measurements
290 self._encode_other_measurements(result, data)
292 def _encode_mass_fields(self, result: bytearray, data: BodyCompositionMeasurementData) -> None:
293 """Encode mass-related optional fields."""
294 # Encode optional muscle mass if present
295 if data.muscle_mass is not None:
296 mass_raw = round(data.muscle_mass / MASS_RESOLUTION_KG)
297 if not 0 <= mass_raw <= 0xFFFF:
298 raise ValueError(f"Muscle mass raw value {mass_raw} exceeds uint16 range")
299 result.extend(DataParser.encode_int16(mass_raw, signed=False))
301 # Encode optional muscle percentage if present
302 if data.muscle_percentage is not None:
303 muscle_pct_raw = round(data.muscle_percentage / MUSCLE_PERCENTAGE_RESOLUTION)
304 if not 0 <= muscle_pct_raw <= 0xFFFF:
305 raise ValueError(f"Muscle percentage raw value {muscle_pct_raw} exceeds uint16 range")
306 result.extend(DataParser.encode_int16(muscle_pct_raw, signed=False))
308 # Encode optional fat free mass if present
309 if data.fat_free_mass is not None:
310 mass_raw = round(data.fat_free_mass / MASS_RESOLUTION_KG)
311 if not 0 <= mass_raw <= 0xFFFF:
312 raise ValueError(f"Fat free mass raw value {mass_raw} exceeds uint16 range")
313 result.extend(DataParser.encode_int16(mass_raw, signed=False))
315 # Encode optional soft lean mass if present
316 if data.soft_lean_mass is not None:
317 mass_raw = round(data.soft_lean_mass / MASS_RESOLUTION_KG)
318 if not 0 <= mass_raw <= 0xFFFF:
319 raise ValueError(f"Soft lean mass raw value {mass_raw} exceeds uint16 range")
320 result.extend(DataParser.encode_int16(mass_raw, signed=False))
322 # Encode optional body water mass if present
323 if data.body_water_mass is not None:
324 mass_raw = round(data.body_water_mass / MASS_RESOLUTION_KG)
325 if not 0 <= mass_raw <= 0xFFFF:
326 raise ValueError(f"Body water mass raw value {mass_raw} exceeds uint16 range")
327 result.extend(DataParser.encode_int16(mass_raw, signed=False))
329 def _encode_other_measurements(self, result: bytearray, data: BodyCompositionMeasurementData) -> None:
330 """Encode impedance, weight, and height measurements."""
331 # Encode optional impedance if present
332 if data.impedance is not None:
333 impedance_raw = round(data.impedance / IMPEDANCE_RESOLUTION)
334 if not 0 <= impedance_raw <= 0xFFFF:
335 raise ValueError(f"Impedance raw value {impedance_raw} exceeds uint16 range")
336 result.extend(DataParser.encode_int16(impedance_raw, signed=False))
338 # Encode optional weight if present
339 if data.weight is not None:
340 mass_raw = round(data.weight / MASS_RESOLUTION_KG)
341 if not 0 <= mass_raw <= 0xFFFF:
342 raise ValueError(f"Weight raw value {mass_raw} exceeds uint16 range")
343 result.extend(DataParser.encode_int16(mass_raw, signed=False))
345 # Encode optional height if present
346 if data.height is not None:
347 if data.measurement_units == MeasurementSystem.IMPERIAL:
348 height_raw = round(data.height / HEIGHT_RESOLUTION_IMPERIAL) # 0.1 inch resolution
349 else:
350 height_raw = round(data.height / HEIGHT_RESOLUTION_METRIC) # 0.001 m resolution
351 if not 0 <= height_raw <= 0xFFFF:
352 raise ValueError(f"Height raw value {height_raw} exceeds uint16 range")
353 result.extend(DataParser.encode_int16(height_raw, signed=False))
355 def _validate_against_feature_characteristic(
356 self,
357 basic: BasicOptionalFields,
358 mass: MassFields,
359 other: OtherMeasurements,
360 feature_data: BodyCompositionFeatureData,
361 ) -> None:
362 """Validate measurement data against Body Composition Feature characteristic.
364 Args:
365 basic: Basic optional fields (timestamp, user_id, basal_metabolism)
366 mass: Mass-related fields
367 other: Other measurements (impedance, weight, height)
368 feature_data: BodyCompositionFeatureData from feature characteristic
370 Raises:
371 ValueError: If measurement reports unsupported features
373 """
374 # Check that reported measurements are supported by device features
375 if basic.timestamp is not None and not feature_data.timestamp_supported:
376 raise ValueError("Timestamp reported but not supported by device features")
378 if basic.user_id is not None and not feature_data.multiple_users_supported:
379 raise ValueError("User ID reported but not supported by device features")
381 if basic.basal_metabolism is not None and not feature_data.basal_metabolism_supported:
382 raise ValueError("Basal metabolism reported but not supported by device features")
384 if mass.muscle_mass is not None and not feature_data.muscle_mass_supported:
385 raise ValueError("Muscle mass reported but not supported by device features")
387 if mass.muscle_percentage is not None and not feature_data.muscle_percentage_supported:
388 raise ValueError("Muscle percentage reported but not supported by device features")
390 if mass.fat_free_mass is not None and not feature_data.fat_free_mass_supported:
391 raise ValueError("Fat free mass reported but not supported by device features")
393 if mass.soft_lean_mass is not None and not feature_data.soft_lean_mass_supported:
394 raise ValueError("Soft lean mass reported but not supported by device features")
396 if mass.body_water_mass is not None and not feature_data.body_water_mass_supported:
397 raise ValueError("Body water mass reported but not supported by device features")
399 if other.impedance is not None and not feature_data.impedance_supported:
400 raise ValueError("Impedance reported but not supported by device features")
402 if other.weight is not None and not feature_data.weight_supported:
403 raise ValueError("Weight reported but not supported by device features")
405 if other.height is not None and not feature_data.height_supported:
406 raise ValueError("Height reported but not supported by device features")
408 def _parse_flags_and_body_fat(self, data: bytearray) -> FlagsAndBodyFat:
409 """Parse flags and body fat percentage from data.
411 Returns:
412 FlagsAndBodyFat containing flags, body fat percentage, and offset
414 """
415 # Parse flags (2 bytes)
416 flags = BodyCompositionFlags(DataParser.parse_int16(data, 0, signed=False))
418 # Validate and parse body fat percentage data
419 if len(data) < 4:
420 raise ValueError("Insufficient data for body fat percentage")
422 body_fat_raw = DataParser.parse_int16(data, 2, signed=False)
423 body_fat_percentage = float(body_fat_raw) * BODY_FAT_PERCENTAGE_RESOLUTION # 0.1% resolution
425 return FlagsAndBodyFat(flags=flags, body_fat_percentage=body_fat_percentage, offset=4)
427 def _parse_basic_optional_fields(
428 self,
429 data: bytearray,
430 flags: BodyCompositionFlags,
431 offset: int,
432 ) -> BasicOptionalFields:
433 """Parse basic optional fields (timestamp, user ID, basal metabolism).
435 Args:
436 data: Raw bytearray
437 flags: Parsed flags indicating which fields are present
438 offset: Current offset in data
440 Returns:
441 BasicOptionalFields containing timestamp, user_id, basal_metabolism, and updated offset
443 """
444 timestamp: datetime | None = None
445 user_id: int | None = None
446 basal_metabolism: int | None = None
448 # Parse optional timestamp (7 bytes) if present
449 if BodyCompositionFlags.TIMESTAMP_PRESENT in flags and len(data) >= offset + 7:
450 timestamp = IEEE11073Parser.parse_timestamp(data, offset)
451 offset += 7
453 # Parse optional user ID (1 byte) if present
454 if BodyCompositionFlags.USER_ID_PRESENT in flags and len(data) >= offset + 1:
455 user_id = int(data[offset])
456 offset += 1
458 # Parse optional basal metabolism (uint16) if present
459 if BodyCompositionFlags.BASAL_METABOLISM_PRESENT in flags and len(data) >= offset + 2:
460 basal_metabolism_raw = DataParser.parse_int16(data, offset, signed=False)
461 basal_metabolism = basal_metabolism_raw # in kJ
462 offset += 2
464 return BasicOptionalFields(
465 timestamp=timestamp, user_id=user_id, basal_metabolism=basal_metabolism, offset=offset
466 )
468 def _parse_mass_fields(
469 self,
470 data: bytearray,
471 flags: BodyCompositionFlags,
472 offset: int,
473 ) -> MassFields:
474 """Parse mass-related optional fields.
476 Args:
477 data: Raw bytearray
478 flags: Parsed flags
479 offset: Current offset
481 Returns:
482 MassFields containing muscle_mass, muscle_mass_unit, muscle_percentage,
483 fat_free_mass, soft_lean_mass, body_water_mass, and updated offset
485 """
486 muscle_mass: float | None = None
487 muscle_mass_unit: WeightUnit | None = None
488 muscle_percentage: float | None = None
489 fat_free_mass: float | None = None
490 soft_lean_mass: float | None = None
491 body_water_mass: float | None = None
493 # Parse optional muscle mass
494 if BodyCompositionFlags.MUSCLE_MASS_PRESENT in flags and len(data) >= offset + 2:
495 mass_value = self._parse_mass_field(data, flags, offset)
496 muscle_mass = mass_value.value
497 muscle_mass_unit = mass_value.unit
498 offset += 2
500 # Parse optional muscle percentage
501 if BodyCompositionFlags.MUSCLE_PERCENTAGE_PRESENT in flags and len(data) >= offset + 2:
502 muscle_percentage_raw = DataParser.parse_int16(data, offset, signed=False)
503 muscle_percentage = muscle_percentage_raw * MUSCLE_PERCENTAGE_RESOLUTION
504 offset += 2
506 # Parse optional fat free mass
507 if BodyCompositionFlags.FAT_FREE_MASS_PRESENT in flags and len(data) >= offset + 2:
508 fat_free_mass = self._parse_mass_field(data, flags, offset).value
509 offset += 2
511 # Parse optional soft lean mass
512 if BodyCompositionFlags.SOFT_LEAN_MASS_PRESENT in flags and len(data) >= offset + 2:
513 soft_lean_mass = self._parse_mass_field(data, flags, offset).value
514 offset += 2
516 # Parse optional body water mass
517 if BodyCompositionFlags.BODY_WATER_MASS_PRESENT in flags and len(data) >= offset + 2:
518 body_water_mass = self._parse_mass_field(data, flags, offset).value
519 offset += 2
521 return MassFields(
522 muscle_mass=muscle_mass,
523 muscle_mass_unit=muscle_mass_unit,
524 muscle_percentage=muscle_percentage,
525 fat_free_mass=fat_free_mass,
526 soft_lean_mass=soft_lean_mass,
527 body_water_mass=body_water_mass,
528 offset=offset,
529 )
531 def _parse_other_measurements(
532 self,
533 data: bytearray,
534 flags: BodyCompositionFlags,
535 offset: int,
536 ) -> OtherMeasurements:
537 """Parse impedance, weight, and height measurements.
539 Args:
540 data: Raw bytearray
541 flags: Parsed flags
542 offset: Current offset
544 Returns:
545 OtherMeasurements containing impedance, weight, and height
547 """
548 impedance: float | None = None
549 weight: float | None = None
550 height: float | None = None
552 # Parse optional impedance
553 if BodyCompositionFlags.IMPEDANCE_PRESENT in flags and len(data) >= offset + 2:
554 impedance_raw = DataParser.parse_int16(data, offset, signed=False)
555 impedance = impedance_raw * IMPEDANCE_RESOLUTION
556 offset += 2
558 # Parse optional weight
559 if BodyCompositionFlags.WEIGHT_PRESENT in flags and len(data) >= offset + 2:
560 weight = self._parse_mass_field(data, flags, offset).value
561 offset += 2
563 # Parse optional height
564 if BodyCompositionFlags.HEIGHT_PRESENT in flags and len(data) >= offset + 2:
565 height_raw = DataParser.parse_int16(data, offset, signed=False)
566 if BodyCompositionFlags.IMPERIAL_UNITS in flags: # Imperial units
567 height = height_raw * HEIGHT_RESOLUTION_IMPERIAL # 0.1 inch resolution
568 else: # SI units
569 height = height_raw * HEIGHT_RESOLUTION_METRIC # 0.001 m resolution
570 offset += 2
572 return OtherMeasurements(impedance=impedance, weight=weight, height=height)
574 def _parse_mass_field(self, data: bytearray, flags: BodyCompositionFlags, offset: int) -> MassValue:
575 """Parse a mass field with unit conversion.
577 Args:
578 data: Raw bytearray
579 flags: Parsed flags for unit determination
580 offset: Current offset
582 Returns:
583 MassValue containing mass value and unit string
585 """
586 mass_raw = DataParser.parse_int16(data, offset, signed=False)
587 if BodyCompositionFlags.IMPERIAL_UNITS in flags: # Imperial units
588 mass = mass_raw * MASS_RESOLUTION_LB # 0.01 lb resolution
589 mass_unit = WeightUnit.LB
590 else: # SI units
591 mass = mass_raw * MASS_RESOLUTION_KG # 0.005 kg resolution
592 mass_unit = WeightUnit.KG
593 return MassValue(value=mass, unit=mass_unit)