kmath icon indicating copy to clipboard operation
kmath copied to clipboard

Type-safe Units

Open altavir opened this issue 4 years ago • 16 comments

Add basic API for type-sage units via inline classes.

altavir avatar Sep 25 '19 09:09 altavir

If you have some vision, how it should look like, please present it.

green-nick avatar May 11 '20 17:05 green-nick

I was thinking about something similar to https://github.com/mipt-npm/gdml.kt/blob/master/src/commonMain/kotlin/scientifik/gdml/units.kt, but with singleton objects instead of enum. Meaning that each each unit has a conversion value relative to some default unit. Also, since we have different quantities, we can inherit those objects from an interface so it could look like this:

interface TypeSafeUnit<U>{
  infix fun convertTo(units: U): TypeSafeValue<U>
}

interface Length: TypeSafeUnit{
  val value: Double
}

object Meter: Length{
  override val value = 1.0
}

The value could be resolved from type like this.

Then we can do things like this:

val meters = 1.2.m
val millilitre = meters convertTo Millilitre 

Also it should be possible to define composite units like velocity with appropriate conversion rules.

I am not sure it is the best solution. We should study how it is done in other languages.

@Shimuuar Your input is welcome.

altavir avatar May 11 '20 18:05 altavir

I think that main challenge (and main value) is to make things like length / time work and produce values of correct type. I know too little about operator overloading in kotlin to say anything meaningful about implementation

P.S. I think conversion of meters to milliliters should give compile error and only work with cubic meters

Shimuuar avatar May 11 '20 19:05 Shimuuar

The Kotlin type system does not allow compile-time type algebra (and it is not such a bad thing). We will be able to hard-code composite type transformations for some combinations so the <Meter>/<Second> would be <Velocity>:

inline class TypeSafeValue<U: TypeSafeUnit<U>>(val value: Double)

operator fun TypeSafeValue<Meter>.div(other: TypeSafeValue<Second>): TypeSafeValue<Velocity>

It is also possible to introduce composite unit types for thins like velocity, but it requires additional design effort.

altavir avatar May 11 '20 19:05 altavir

This approach means that every combination of dimensions should be written explicitly. That's ungodly amount of boilerplate. Take for example: F::ML/T^2 = m::M · a::L/T^2. And that's only beginning.

Shimuuar avatar May 12 '20 08:05 Shimuuar

What I've done in my POC is took Kotlin's Duration as example, and built one class for all Metric distances, instead of creating separate for each unit (Metre, Kilometre, Millimetre, etc.)

Besides that, I don't see any possibility (this doesn't mean it doesn't exist) to union different measure units under one abstraction (like TypeSafeUnit). I was thinking about base interfaces for each unit (length, mass, temperature, etc.) that are:

  • not related to each other;
  • requires some math operations;
  • has mapping to default system (metrical);

and their descendants representing different measure system in every specific cases (metrical, imperial, astronomical, etc.). The only items that can combine base units are derived: velocity, acceleration, force, pressure and so on.

Drafts I've created:

interface Length {

    companion object {
        val ZERO: MetricLength = MetricLength.ZERO
    }

    operator fun plus(other: Length): Length = toMetric() + other.toMetric()

    operator fun minus(other: Length): Length = toMetric() - other.toMetric()

    operator fun div(other: Length): Double

    operator fun times(scale: Int): Length
    operator fun times(scale: Long): Length
    operator fun times(scale: Double): Length

    operator fun div(scale: Int): Length
    operator fun div(scale: Long): Length
    operator fun div(scale: Double): Length
    
    fun abs(): Length

    val isNegative: Boolean
    val isPositive: Boolean

    fun toMetric(): MetricLength
}


inline class MetricLength internal constructor(val value: Double) : Length, Comparable<MetricLength> {

    companion object {
        val ZERO: MetricLength = MetricLength(0.0)

        val storageUnit = METRES
    }

    override fun compareTo(other: MetricLength): Int = value.compareTo(other.value)

    override fun toMetric(): MetricLength = this

    override operator fun plus(other: Length): MetricLength = MetricLength(value + other.toMetric().value)
    override operator fun minus(other: Length): MetricLength = MetricLength(value - other.toMetric().value)

    override operator fun times(scale: Int): MetricLength = MetricLength(value * scale)
    override operator fun times(scale: Long): MetricLength = MetricLength(value * scale)
    override operator fun times(scale: Double): MetricLength = MetricLength(value * scale)

    override operator fun div(scale: Int): MetricLength = MetricLength(value / scale)
    override operator fun div(scale: Long): MetricLength = MetricLength(value / scale)
    override operator fun div(scale: Double): MetricLength = MetricLength(value / scale)

    override operator fun div(other: Length): Double = this.value / other.toMetric().value

    operator fun unaryMinus(): MetricLength = MetricLength(-value)

    override val isNegative: Boolean get() = value < 0

    override val isPositive: Boolean get() = value > 0

    override fun abs(): MetricLength = if (isNegative) -this else this

    fun toDouble(unit: MetricLengthUnit): Double = convertMetricUnit(value, storageUnit, unit)

    fun toLong(unit: MetricLengthUnit): Long = toDouble(unit).toLong()

    fun toInt(unit: MetricLengthUnit): Int = toDouble(unit).toInt()

    val inMetres: Double get() = toDouble(METRES)

    val inKilometres: Double get() = toDouble(KILOMETRES)

    val inCentimetres: Double get() = toDouble(CENTIMETRES)

    val inMillimetres: Double get() = toDouble(MILLIMETRES)

    val inNanometres: Double get() = toDouble(NANOMETRES)

    fun toLongMetres(): Long = toLong(METRES)

    fun toLongKilometres(): Long = toLong(METRES)

    fun toLongMillimetres(): Long = toLong(MILLIMETRES)

    override fun toString(): String = when {
        value < 1e-9 -> "${value * 1e12}${PICOMETRES.shortName}"
        value < 1e-6 -> "${value * 1e9}${NANOMETRES.shortName}"
        value < 1e-3 -> "${value * 1e6}${MICROMETRES.shortName}"
        value < 1 -> "${value * 1e3}${MILLIMETRES.shortName}"
        value < 1e3 -> "$value${METRES.shortName}"
        else -> "${value * 1e-3}${KILOMETRES.shortName}"
    }
}


enum class MetricLengthUnit(val power: Int) {
    PICOMETRES(-12),
    NANOMETRES(-9),
    MICROMETRES(-6),
    MILLIMETRES(-3),
    CENTIMETRES(-2),
    DECIMETRES(-1),
    METRES(0),
    KILOMETRES(3)
}

But I clearly see, that this approach produces a lot of boilerplate.

green-nick avatar May 12 '20 19:05 green-nick

I aslo created init-extension and prototype of Speed (similar to Length/MetricLength):

operator fun Int.times(length: MetricLength): MetricLength = length * this

fun Int.toMetricLength(unit: MetricLengthUnit): MetricLength =
    MetricLength(convertMetricUnit(this.toDouble(), unit, MetricLength.storageUnit))

val Int.m get() = toMetricLength(METRES)
val Int.km get() = toMetricLength(KILOMETRES)
val Int.dm get() = toMetricLength(DECIMETRES)
val Int.cm get() = toMetricLength(CENTIMETRES)
val Int.mm get() = toMetricLength(MILLIMETRES)
val Int.um get() = toMetricLength(MICROMETRES)
val Int.nm get() = toMetricLength(NANOMETRES)
val Int.pm get() = toMetricLength(PICOMETRES)

Length with Duration combiners:

operator fun MetricLength.div(duration: Duration): MetricSpeed = MetricSpeed(inMetres / duration.inSeconds)
infix fun MetricLength.per(duration: Duration): MetricSpeed = this / duration

val Int.mPerS: MetricSpeed get() = MetricSpeed(this.toDouble())
val Int.kmPerS: MetricSpeed get() = MetricSpeed((this * 1000).toDouble())
val Int.kmPerH: MetricSpeed get() = MetricSpeed((this * 1000).toDouble() / 3600)

After all, usage looks like:

val length: MetricLength = 238.m
val speed1: MetricSpeed = 60.km per 1.hours
val speed2: MetricSpeed = 100.m / 9.8.seconds
val speed3: MetricSpeed = 8.kmPerS
val distance: MetricLength = speed3 * 1.hours

But I still have not tried to introduce new measurment system to see, how extending easily is.

green-nick avatar May 12 '20 19:05 green-nick

I've just realised, that we can express some math operations as default in base interface through default measure system as interface has mapping to it and math will work for new systems out of the box (author still will have ability to override and add more of them).

green-nick avatar May 12 '20 19:05 green-nick

@altavir But I still has a question: why do you need this in math library? Isn't it more related to physic?

green-nick avatar May 12 '20 19:05 green-nick

@green-nick I do not see a motivation to make arithmetic operators members of the class. If we are sure that all values are just inline classes over Double (we can add another level of abstraction by providing, say, BigDecimal units for currency etc), the all we need is to expose this unsafe value and then add all operations as extensions on appropriate types. The only problem I see is that we can loose numeric precision when converting units too far away. I will need to write down correct recursive generics so it could be understood correctly.

altavir avatar May 12 '20 19:05 altavir

Our work is mostly about physics, so for us it makes sense to have it one place for now just to have a common place for all APIs. In future, it will be probably good idea to separate repositories. Also type safe units should look nice with geometry package which is now in development.

altavir avatar May 13 '20 06:05 altavir

Hello, did you consider javax.measure? I was having a look at it, there's a reference implementation already

pedroteixeira avatar Jun 11 '20 02:06 pedroteixeira

@pedroteixeira Thanks for the reference. I did not even know about its existence. I do not think it is a route we want to take since it is rather heavyweight and we would probably want to use kotlin extensions and inline classes, but it makes sense to see different ideas and compare them.

altavir avatar Jun 11 '20 05:06 altavir

https://github.com/nacular/measured implements something very close to javax.measure. I think it is possible to implement the idea about non-boxing inlines there and not bring the feature to kmath.

altavir avatar Jun 14 '20 06:06 altavir

I'd be happy to explore this and see what can be done around non-boxing.

pusolito avatar Jun 14 '20 07:06 pusolito

A proposal here: https://github.com/nacular/measured/issues/2

altavir avatar Jun 14 '20 08:06 altavir