When working with units of measurement it's often useful to apply a prefix to capture the order of magnitude. The SI unit system has a standard set of prefixes based on powers of 10, e.g. kilo: 10³ or mega: 10⁶. For quantities of information like bits and bytes, it\'s however often useful to have prefixes based on powers of 2 like MeBi: 2²⁰. These are also called binary prefixes.

The tec.uom.se library introduced eralier in this series of blog-posts has a class BinaryPrefix, but it only offers methods for unit conversion. Let\'s build a short helper class and build a nice fluent API in Kotlin so that we can write e.g. 10.mebi.byte for 10 MiB.

object QuantityFormatting {
  private val unitFormatter = UcumFormatWithBinaryPrefixSupport()

  private val numberFormatter = NumberFormat.getInstance(Locale.ROOT)
  private val separator = " "

  object QuantityToStringConverter : Converter<Quantity<*>, String> {
    override fun convert(source: Quantity<*>): String {
      val s = source.toWildcard()
      val formattedUnit = unitFormatter.format(s.unit)
      val formattedValue = numberFormatter.format(s.value)

      return "$formattedValue$separator$formattedUnit"
    }
  }

  object StringToQuantityConverter : Converter<String, Quantity<*>> {
    override fun convert(source: String): Quantity<*> {
      val (formattedValue, formattedUnit) = source.split(separator, limit = 2)

      val parsedUnit: Unit = unitFormatter.parse(formattedUnit).toWildcard()
      val parsedValue = numberFormatter.parse(formattedValue)

      return Quantities.getQuantity(parsedValue, parsedUnit)
    }
  }

  class UcumFormatWithBinaryPrefixSupport(val base: UCUMFormat = UCUMFormat.getInstance(UCUMFormat.Variant.CASE_SENSITIVE))
    : UnitFormat by base {

    override fun format(unit: Unit<*>): String {
      val baseResult = base.format(unit)
      val split = baseResult.split(".")

      if (split.size == 1) {
        return baseResult
      }

      val (symbol, converter) = split
      return when (converter) {
        BinaryPrefix.YOBI.converterFormat -> "${BinaryPrefix.YOBI.symbol}$symbol"
        BinaryPrefix.ZEBI.converterFormat -> "${BinaryPrefix.ZEBI.symbol}$symbol"
        BinaryPrefix.EXBI.converterFormat -> "${BinaryPrefix.EXBI.symbol}$symbol"
        BinaryPrefix.PEBI.converterFormat -> "${BinaryPrefix.PEBI.symbol}$symbol"
        BinaryPrefix.TEBI.converterFormat -> "${BinaryPrefix.TEBI.symbol}$symbol"
        BinaryPrefix.GIBI.converterFormat -> "${BinaryPrefix.GIBI.symbol}$symbol"
        BinaryPrefix.MEBI.converterFormat -> "${BinaryPrefix.MEBI.symbol}$symbol"
        BinaryPrefix.KIBI.converterFormat -> "${BinaryPrefix.KIBI.symbol}$symbol"
        else -> baseResult
      }
    }

    override fun format(unit: Unit<*>, appendable: Appendable): Appendable {
      return appendable.append(format(unit))
    }

    override fun parse(csq: CharSequence): Unit<*> {
// note: all binary prefixes have two chars
      val prefixLength = 2
      val (prefix, originalUnit) = when {
        csq.length >= prefixLength -> Pair(csq.substring(0, prefixLength), csq.substring(prefixLength))
        else -> Pair(null, csq)
      }

      return when (prefix) {
        BinaryPrefix.YOBI.symbol -> base.parse(originalUnit).transform(BinaryPrefix.YOBI.converter)
        BinaryPrefix.ZEBI.symbol -> base.parse(originalUnit).transform(BinaryPrefix.ZEBI.converter)
        BinaryPrefix.EXBI.symbol -> base.parse(originalUnit).transform(BinaryPrefix.EXBI.converter)
        BinaryPrefix.PEBI.symbol -> base.parse(originalUnit).transform(BinaryPrefix.PEBI.converter)
        BinaryPrefix.TEBI.symbol -> base.parse(originalUnit).transform(BinaryPrefix.TEBI.converter)
        BinaryPrefix.GIBI.symbol -> base.parse(originalUnit).transform(BinaryPrefix.GIBI.converter)
        BinaryPrefix.MEBI.symbol -> base.parse(originalUnit).transform(BinaryPrefix.MEBI.converter)
        BinaryPrefix.KIBI.symbol -> base.parse(originalUnit).transform(BinaryPrefix.KIBI.converter)
        else -> base.parse(csq)
      }
    }
  }
}

Another missing feature is support for string serialization of binary-prefixed units. Unfortunately, the UCUMFormatter of the UOM library is not extensible, so we need to add a simple pre/post-processing step to our formatters from the last episode.


object QuantityFormatting {
  private val unitFormatter = UcumFormatWithBinaryPrefixSupport()

  private val numberFormatter = NumberFormat.getInstance(Locale.ROOT)
  private val separator = " "

  object QuantityToStringConverter : Converter<Quantity<*>, String> {
    override fun convert(source: Quantity<*>): String {
      val s = source.toWildcard()
      val formattedUnit = unitFormatter.format(s.unit)
      val formattedValue = numberFormatter.format(s.value)

      return "$formattedValue$separator$formattedUnit"
    }
  }

  object StringToQuantityConverter : Converter<String, Quantity<*>> {
    override fun convert(source: String): Quantity<*> {
      val (formattedValue, formattedUnit) = source.split(separator, limit = 2)

      val parsedUnit: Unit = unitFormatter.parse(formattedUnit).toWildcard()
      val parsedValue = numberFormatter.parse(formattedValue)

      return Quantities.getQuantity(parsedValue, parsedUnit)
    }
  }

  class UcumFormatWithBinaryPrefixSupport(val base: UCUMFormat = UCUMFormat.getInstance(UCUMFormat.Variant.CASE_SENSITIVE))
    : UnitFormat by base {

    override fun format(unit: Unit<*>): String {
      val baseResult = base.format(unit)
      val split = baseResult.split(".")

      if (split.size == 1) {
        return baseResult
      }

      val (symbol, converter) = split
      return when (converter) {
        BinaryPrefix.YOBI.converterFormat -> "${BinaryPrefix.YOBI.symbol}$symbol"
        BinaryPrefix.ZEBI.converterFormat -> "${BinaryPrefix.ZEBI.symbol}$symbol"
        BinaryPrefix.EXBI.converterFormat -> "${BinaryPrefix.EXBI.symbol}$symbol"
        BinaryPrefix.PEBI.converterFormat -> "${BinaryPrefix.PEBI.symbol}$symbol"
        BinaryPrefix.TEBI.converterFormat -> "${BinaryPrefix.TEBI.symbol}$symbol"
        BinaryPrefix.GIBI.converterFormat -> "${BinaryPrefix.GIBI.symbol}$symbol"
        BinaryPrefix.MEBI.converterFormat -> "${BinaryPrefix.MEBI.symbol}$symbol"
        BinaryPrefix.KIBI.converterFormat -> "${BinaryPrefix.KIBI.symbol}$symbol"
        else -> baseResult
      }
    }

    override fun format(unit: Unit<*>, appendable: Appendable): Appendable {
      return appendable.append(format(unit))
    }

    override fun parse(csq: CharSequence): Unit<*> {
// note: all binary prefixes have two chars
      val prefixLength = 2
      val (prefix, originalUnit) = when {
        csq.length >= prefixLength -> Pair(csq.substring(0, prefixLength), csq.substring(prefixLength))
        else -> Pair(null, csq)
      }

      return when (prefix) {
        BinaryPrefix.YOBI.symbol -> base.parse(originalUnit).transform(BinaryPrefix.YOBI.converter)
        BinaryPrefix.ZEBI.symbol -> base.parse(originalUnit).transform(BinaryPrefix.ZEBI.converter)
        BinaryPrefix.EXBI.symbol -> base.parse(originalUnit).transform(BinaryPrefix.EXBI.converter)
        BinaryPrefix.PEBI.symbol -> base.parse(originalUnit).transform(BinaryPrefix.PEBI.converter)
        BinaryPrefix.TEBI.symbol -> base.parse(originalUnit).transform(BinaryPrefix.TEBI.converter)
        BinaryPrefix.GIBI.symbol -> base.parse(originalUnit).transform(BinaryPrefix.GIBI.converter)
        BinaryPrefix.MEBI.symbol -> base.parse(originalUnit).transform(BinaryPrefix.MEBI.converter)
        BinaryPrefix.KIBI.symbol -> base.parse(originalUnit).transform(BinaryPrefix.KIBI.converter)
        else -> base.parse(csq)
      }
    }
  }
}

With that, we can make the following test pass:

@Test
fun serializingBinaryPrefixedUnits(){
  val sut = 1.mebi.byte
  val serialized = QuantityFormatting.QuantityToStringConverter.convert(sut)

  Assert.assertEquals("1 MiBy", serialized)

  val deserialized = QuantityFormatting.StringToQuantityConverter.convert(serialized)
  Assert.assertEquals(sut, deserialized)
}