coulomb Concepts

Unit Analysis

Unit analysis - aka dimensional analysis

Unit analysis performs a very similar role to a type system in programming languages such as Scala. Like data types, unit analysis provides us information about what operations may be allowed or disallowed. Just as Scala's type system informs us that the expression 7 + false is not a valid expression:

val bad = 7 + false
// error:
// None of the overloaded alternatives of method + in class Int with types
//  (x: Double): Double
//  (x: Float): Float
//  (x: Long): Long
//  (x: Int): Int
//  (x: Char): Int
//  (x: Short): Int
//  (x: Byte): Int
//  (x: String): String
// match arguments ((false : Boolean))
// val dyear = date2 - date1
//                  ^

unit analysis informs us that adding meters + seconds is not a valid computation.

As such, unit analysis is an excellent use case for representation in programming language type systems. The coulomb library implements unit analysis using Scala's powerful type system features.

Quantity

In order to do unit analysis, we have to keep track of unit information along with our computations. The coulomb library represents a value paired with a unit expression using the type Quantity[V, U]:

val time = 10.0.withUnit[Second]
// time: Quantity[Double, Second] = 10.0
val dist = 100.0.withUnit[Meter]
// dist: Quantity[Double, Meter] = 100.0

val v = dist / time
// v: Quantity[Double, /[Meter, Second]] = 10.0
v.show
// res1: String = "10.0 m/s"

Quantity[V, U] has two type arguments: the type V represents the value type such as Double, and U represents the unit type such as Meter or Meter / Second. As the example above shows, when you do operations such as division on a Quantity, coulomb automatically computes both the resulting value and its corresponding unit.

Unit Expressions

In the previous example we saw that a unit type U contains a unit expression. Unit expressions may be a named type such as Meter or Second, or more complex unit types can be constructed with the operator types *, / and ^:

val time = 60.0.withUnit[Second]
// time: Quantity[Double, Second] = 60.0

val speed = 100.0.withUnit[Meter / Second]
// speed: Quantity[Double, /[Meter, Second]] = 100.0

val force = 5.0.withUnit[Kilogram * Meter / (Second ^ 2)]
// force: Quantity[Double, /[*[Kilogram, Meter], ^[Second, 2]]] = 5.0

Unit expression types in coulomb can be composed to express units with arbitrary complexity.

import coulomb.units.mksa.{*, given}

// Electrical resistance expressed with SI base units
val resistance = 1.0.withUnit[(Kilogram * (Meter ^ 2)) / ((Second ^ 3) * (Ampere ^ 2))]
// resistance: Quantity[Double, /[*[Kilogram, ^[Meter, 2]], *[^[Second, 3], ^[Ampere, 2]]]] = 1.0

resistance.show
// res2: String = "1.0 (kg m^2)/(s^3 A^2)"

// a shorter but equivalent unit type
resistance.toUnit[Ohm].show
// res3: String = "1.0 Ω"

Unit Conversions

In unit analysis, some units are convertible (aka commensurable). The coulomb library performs unit conversions, and corresponding checks for convertability or inconvertability, using algorithmic unit analysis.

For example one may convert a quantity of kilometers to miles, or meter/second to miles/hour, or cubic meters to liters:

import coulomb.units.si.prefixes.{*, given}
import coulomb.units.us.{*, given}
import coulomb.units.accepted.{*, given}
import coulomb.units.time.{*, given}

val distance = 100.0.withUnit[Kilo * Meter]
// distance: Quantity[Double, *[Kilo, Meter]] = 100.0

distance.toUnit[Mile].show
// res4: String = "62.13711922373339 mi"

val speed = 10.0.withUnit[Meter / Second]
// speed: Quantity[Double, /[Meter, Second]] = 10.0

speed.toUnit[Mile / Hour].show
// res5: String = "22.369362920544024 mi/h"

val volume = 1.0.withUnit[Meter ^ 3]
// volume: Quantity[Double, ^[Meter, 3]] = 1.0

volume.toUnit[Liter].show
// res6: String = "1000.0 l"

Other quantities are not convertible (aka incommensurable). Attempting to convert such quantities in coulomb is a compile time type error.

// acre-feet is a unit of volume, and so will succeed:
volume.toUnit[Acre * Foot].show

// converting a volume to an area is a type error!
volume.toUnit[Acre].show
// error:
// unit type (Meter ^ 3) not convertable to Acre
// val q1 = 1d.withUnit[Liter]
//                           ^

Base Units and Derived Units

In unit analysis, base units are the axiomatic units. They typically express fundamental quantites, for example meters, seconds or kilograms in the SI unit system.

The coulomb-units package defines the SI base units but coulomb also makes it easy to define your own base units:

// a new base unit for spicy heat
object spicy:
    import coulomb.define.*

    final type Scoville
    given unit_Scoville: BaseUnit[Scoville, "scoville", "sco"] = BaseUnit()

import spicy.{*, given}

val jalapeno = 5000.withUnit[Scoville]
// jalapeno: Quantity[Int, Scoville] = 5000

val ghost = 1000000.withUnit[Scoville]
// ghost: Quantity[Int, Scoville] = 1000000

Other units may be defined as compositions of base units. These are referred to as derived units, or compound units.

object nautical:
    import coulomb.define.*

    export coulomb.units.si.{ Meter, ctx_unit_Meter }
    export coulomb.units.time.{ Hour, ctx_unit_Hour }

    final type NauticalMile
    given unit_NauticalMile: DerivedUnit[NauticalMile, 1852 * Meter, "nmile", "nmi"] =
        DerivedUnit()

    final type Knot
    given unit_Knot: DerivedUnit[Knot, NauticalMile / Hour, "knot", "kt"] =
        DerivedUnit()

import nautical.{*, given}

val dist = (1.0.withUnit[Knot] * 1.0.withUnit[Hour]).toUnit[Meter]
// dist: Quantity[Double, Meter] = 1852.0

coulomb permits any type to be treated as a unit. Types not explicitly defined as units are treated as base units.

class Apple

val apples = 1.withUnit[Apple] + 2.withUnit[Apple]
// apples: Quantity[Int, Apple] = 3

val s = apples.show
// s: String = "3 Apple"

Allowing arbitrary types to be manipulated as units introduces some interesting programming possibilities, which are discussed in this blog post.

Prefix Units

The standard SI unit system prefixes such as "kilo" to represent 1000, "micro" to represent 10^-6, etc, are provided by the coulomb-units package.

import coulomb.units.si.prefixes.{*, given}

val dist = 5.0.withUnit[Kilo * Meter]
// dist: Quantity[Double, *[Kilo, Meter]] = 5.0

dist.toUnit[Meter].show
// res8: String = "5000.0 m"

The standard binary prefixes which are frequently used in computing are also provided.

import coulomb.units.info.{*, given}
import coulomb.units.info.prefixes.{*, given}

val memory = 1.0.withUnit[Mebi * Byte]
// memory: Quantity[Double, *[Mebi, Byte]] = 1.0

memory.toUnit[Byte].show
// res9: String = "1048576.0 B"

Unit prefixes in coulomb take advantage of Scala literal types, and are defined as DerivedUnit for types that encode Rational numbers. The numeric literal types Int, Long, Float and Double can all be combined with the operator types *, / and ^ to express numeric constants. Numeric values are processed at compile-time as arbitrary precision rationals, and so you can easily express values with high precision and magnitudes.

object prefixes:
    import coulomb.define.*

    final type Dozen
    given unit_Dozen: DerivedUnit[Dozen, 12, "dozen", "doz"] = DerivedUnit()

    final type Googol
    given unit_Googol: DerivedUnit[Googol, 10 ^ 100, "googol", "gog"] = DerivedUnit()

    final type Percent
    given unit_Percent: DerivedUnit[Percent, 1 / 100, "percent", "%"] = DerivedUnit()

    final type Degree
    given unit_Degree: DerivedUnit[Degree, 3.14159265 / 180, "degree", "deg"] = DerivedUnit()

Value Types

Each coulomb quantity Quantity[V, U] pairs a value type V with a unit type U. Value types are often numeric, however coulomb places no restriction on value types. We have already seen that there are also no restrictions on unit type, and so the following are perfectly legitimate:

case class Wrapper[T](t: T)

val w = Wrapper(42f).withUnit[Meter]
// w: Quantity[Wrapper[Float], Meter] = Wrapper(t = 42.0F)

w.show
// res10: String = "Wrapper(42.0) m"

val w2 = Wrapper(37D).withUnit[Vector[Int]]
// w2: Quantity[Wrapper[Double], Vector[Int]] = Wrapper(t = 37.0)

w2.show
// res11: String = "Wrapper(37.0) Vector[Int]"

Value Types and Algebras

Although value and unit types are arbitrary for a Quantity, there is no free lunch. Operations on quantity objects will only work if they are defined. The following example will not compile because we have not defined what it means to add Wrapper objects:

// addition has not been defined for Wrapper types
val wsum = w + w
// error: 
// Addition not defined in scope for Quantity[Wrapper[Float], coulomb.units.si.Meter] and Quantity[Wrapper[Float], coulomb.units.si.Meter].
// I found:
// 
//     coulomb.policy.standard.ctx_add_1V1U[Wrapper[Float], coulomb.units.si.Meter,
//       Wrapper[Float], coulomb.units.si.Meter](<:<.refl[Wrapper[Float]],
//       <:<.refl[coulomb.units.si.Meter],
//       /* missing */summon[algebra.ring.AdditiveSemigroup[Wrapper[Float]]])
// 
// But no implicit values were found that match type algebra.ring.AdditiveSemigroup[Wrapper[Float]].

The compiler error above indicates that it was unable to find an algebra that tells coulomb's standard predefined rules how to add Wrapper objects. Providing such a definition allows our example to compile.

object wrapperalgebra:
    import algebra.ring.AdditiveSemigroup
    given add_Wrapper[V](using vadd: AdditiveSemigroup[V]): AdditiveSemigroup[Wrapper[V]] =
        new AdditiveSemigroup[Wrapper[V]]:
            def plus(x: Wrapper[V], y: Wrapper[V]): Wrapper[V] =
                Wrapper(vadd.plus(x.t, y.t))

// import Wrapper algebras into scope
import wrapperalgebra.given

// adding Wrapper objects works now that we have defined an algebra
val wsum = w + w
// wsum: Quantity[Wrapper[Float], Meter] = Wrapper(t = 84.0F)

Numeric Operations

coulomb supports the following numeric operations for any value type that defines the required algebras:

val v = 2.0.withUnit[Meter]
// v: Quantity[Double, Meter] = 2.0

// negation
-v
// res13: Quantity[Double, Meter] = -2.0

// addition
v + v
// res14: Quantity[Double, Meter] = 4.0

// subtraction
v - v
// res15: Quantity[Double, Meter] = 0.0

// multiplication
v * v
// res16: Quantity[Double, ^[Meter, 2]] = 4.0

// division
v / v
// res17: Quantity[Double, 1] = 1.0

// power
v.pow[3]
// res18: Quantity[Double, ^[Meter, 3]] = 8.0

// comparisons

v <= v
// res19: Boolean = true

v > v
// res20: Boolean = false

v === v
// res21: Boolean = true

v =!= v
// res22: Boolean = false

Truncating Operations

Some operations involving integral types such as Int, Long, or BigInt, are considered "truncating" - they lose the fractional component of the result. In coulomb these are distinguished with specific "truncating" operators:

// fractional values (Double, Float, BigDecimal, Rational, etc)
val fractional = 10.5.withUnit[Meter]
// fractional: Quantity[Double, Meter] = 10.5

// truncating value conversions (fractional -> integral)
val integral = fractional.tToValue[Int]
// integral: Quantity[Int, Meter] = 10

// truncating unit conversions
integral.tToUnit[Yard]
// res23: Quantity[Int, Yard] = 10

// truncating division
integral `tquot` 3
// res24: Quantity[Int, Meter] = 3

// truncating power
integral.tpow[1/2]
// res25: Quantity[Int, ^[Meter, /[1, 2]]] = 3

Non-truncating operations are defined in cases where the result will not discard fractional components:

// "normal" (aka truncating) operations work when fractional component of results are preserved
fractional / 3
// res26: Quantity[Double, Meter] = 3.5

Non-truncating operations are undefined on types that would cause truncation.

// standard division is undefined for cases that would truncate
integral / 3
// error: 
// Division not defined in scope for Quantity[Int, coulomb.units.si.Meter] and Quantity[Int, (1 : Int)].
// I found:
// 
//     coulomb.policy.standard.ctx_div_2V2U[Int, coulomb.units.si.Meter, Int, (1 : Int)
//       ](/* missing */summon[util.NotGiven[Int =:= Int]], ???, ???, ???, ???, ???)
// 
// But no implicit values were found that match type util.NotGiven[Int =:= Int].

Value and Unit Conversions

In coulomb, a Quantity[V, U] may experience conversions along two possible axes: converting value type V to a new value type V2, or converting unit U to a new unit U2:

val q = 10d.withUnit[Meter]
// q: Quantity[Double, Meter] = 10.0

// convert value type
q.toValue[Float]
// res28: Quantity[Float, Meter] = 10.0F

// convert unit type
q.toUnit[Yard]
// res29: Quantity[Double, Yard] = 10.936132983377078

Value conversions are successful whenever the corresponding ValueConversion[VF, VT] context is in scope, and similarly unit conversions are successful whenever the necessary UnitConversion[V, UF, UT] is in scope.

As you can see from the signature UnitConversion[V, UF, UT], any unit conversion is with respect to a particular value type. This is because the best way of converting from unit UF to UT will depend on the specific value type being converted. You can look at examples of unit conversions defined in coulomb-core here.

truncating conversions

As we saw in previous sections, some operations on coulomb quantities may result in "truncation" - the loss of fractional parts of values. As with operations, truncating conversions are represented by distinct conversions TruncatingValueConversion[VF, VT] and TruncatingUnitConversion[V, UF, UT].

// a truncating value conversion (fractional -> integral)
val qi = q.tToValue[Int]
// qi: Quantity[Int, Meter] = 10

// a truncating unit conversion (on an integral type)
qi.tToUnit[Yard]
// res30: Quantity[Int, Yard] = 10

implicit conversions

In Scala 3, implicit conversions are represented by scala.Conversion[F, T], which you can read more about here.

The coulomb-core library pre-defines such implicit conversions, based on ValueConversion and UnitConversion context in scope. By convention, coulomb performs value conversions first, then unit conversions.

Implicit quantity conversions can be used in typical Scala scenarios:

// implicitly convert double to float, and then cubic meters to liters
val iconv: Quantity[Float, Liter] = 1.0.withUnit[Meter ^ 3]
// iconv: Quantity[Float, Liter] = 1000.0F

However, numeric operators in coulomb may also make use of these implicit conversions:

val q1 = 1d.withUnit[Second]
// q1: Quantity[Double, Second] = 1.0
val q2 = 1.withUnit[Minute]
// q2: Quantity[Int, Minute] = 1

// in this operation, q2's integer value is implicitly converted to double,
// and then minutes are converted to seconds, and added to q1:
q1 + q2
// res31: Quantity[Double, Second] = 61.0

defining conversions

The coulomb-core and coulomb-spire libraries define value and unit conversions for a wide variety of popular numeric types, however you can also easily define your own.

object wrapperconv:
    import coulomb.conversion.*

    given vconv_Wrapper[VF, VT](using vcv: ValueConversion[VF, VT]): ValueConversion[Wrapper[VF], Wrapper[VT]] =
        new ValueConversion[Wrapper[VF], Wrapper[VT]]:
            def apply(w: Wrapper[VF]): Wrapper[VT] = Wrapper[VT](vcv(w.t))

    given uconv_Wrapper[V, UF, UT](using ucv: UnitConversion[V, UF, UT]): UnitConversion[Wrapper[V], UF, UT] =
        new UnitConversion[Wrapper[V], UF, UT]:
            def apply(w: Wrapper[V]): Wrapper[V] = Wrapper[V](ucv(w.t))

import wrapperconv.given

val wq = Wrapper(1d).withUnit[Minute]
// wq: Quantity[Wrapper[Double], Minute] = Wrapper(t = 1.0)

wq.toUnit[Second]
// res32: Quantity[Wrapper[Double], Second] = Wrapper(t = 60.0)

wq.toValue[Wrapper[Float]]
// res33: Quantity[Wrapper[Float], Minute] = Wrapper(t = 1.0F)

Value Promotion and Resolution

We saw in our earlier example that coulomb can perform implicit value and unit conversions when doing numeric operations. The high level logic (implemented in chained context rules) is:

  1. "Resolve" left and right value types (VL and VR) into a final output type VO
  2. Apply implicit conversion Quantity[VL, UL] -> Quantity[VO, UL]
  3. Apply implicit conversion Quantity[VR, UR] -> Quantity[VO, UL]
  4. Perform the relevant algebraic operation, in value space VO
  5. Return the resulting value as Quantity[VO, UL]

Note that not all numeric operations require all of these steps, however here is an example fragment of such code for addition which demonstrates them all:

transparent inline given ctx_add_2V2U[VL, UL, VR, UR](using
    vres: ValueResolution[VL, VR],
    icl: Conversion[Quantity[VL, UL], Quantity[vres.VO, UL]],
    icr: Conversion[Quantity[VR, UR], Quantity[vres.VO, UL]],
    alg: AdditiveSemigroup[vres.VO]
        ): Add[VL, UL, VR, UR] =
    new infra.AddNC((ql: Quantity[VL, UL], qr: Quantity[VR, UR]) => alg.plus(icl(ql).value, icr(qr).value).withUnit[UL])

In the example above, you can see that the context object that maps (VL, VR) => VO is of type ValueResolution[VL, VR].

It is possible to define all the necessary ValueResolution[VL, VR] for all possible pairs of (VL, VR), however for more than a small number of such types the number of pairs grows unwieldy rather fast (quadratically fast in fact). However, there is another preferred alternative that allows you to only define "key" pairs that define a Directed Acyclic Graph, and the coulomb typeclass system will efficiently search this space to identify the correct value of ValueResolution[VL, VR] at compile time.

Here is one example that captures the "total ordering" relation among value type resolutions for {Int, Long, Float, Double} that comes with coulomb-core:

// ValuePromotion infers the transitive closure of all promotions
given ctx_vpp_standard: ValuePromotionPolicy[
    (Int, Long) &: (Long, Float) &: (Float, Double) &: TNil
] = ValuePromotionPolicy()

Using this, we can finish off our Wrapper example with some rules for generating ValueResolution.

object wrappervr:
    import coulomb.ops.*
    transparent inline given vr_Wrapper[VL, VR](using vres: ValueResolution[VL, VR]): ValueResolution[Wrapper[VL], Wrapper[VR]] =
        new ValueResolution[Wrapper[VL], Wrapper[VR]]:
            type VO = Wrapper[vres.VO]

import wrappervr.given

val wq1 = Wrapper(1d).withUnit[Second]
// wq1: Quantity[Wrapper[Double], Second] = Wrapper(t = 1.0)
val wq2 = Wrapper(1f).withUnit[Minute]
// wq2: Quantity[Wrapper[Float], Minute] = Wrapper(t = 1.0F)

wq1 + wq2
// res34: Quantity[Wrapper[Double], Second] = Wrapper(t = 61.0)

Temperature and Time

The coulomb-units library defines units for temperature and time. When working with temperatures, one must distinguish between absolute temperatures, and units of degrees. Similarly, one must distinguish between absolute moments in time (aka timestamps, or instants) and units of duration. Consider the following example:

import coulomb.units.temperature.{*, given}

// Here are two absolute temperatures
val cels1 = 10d.withTemperature[Celsius]
// cels1: DeltaQuantity[Double, Celsius, Kelvin] = 10.0
val cels2 = 20d.withTemperature[Celsius]
// cels2: DeltaQuantity[Double, Celsius, Kelvin] = 20.0

// subtracting temperatures yields a Quantity of degrees:
val dcels = cels2 - cels1
// dcels: Quantity[Double, Celsius] = 10.0

// you can add or subtract a quantity from a temperature, and get a new temperature
cels2 + dcels
// res35: DeltaQuantity[Double, Celsius, Kelvin] = 30.0
cels2 - dcels
// res36: DeltaQuantity[Double, Celsius, Kelvin] = 10.0

As we saw in the example above, subtracting two absolute temperatures yields a Quantity of degrees. However, temperature scales are "anchored" at an absolute reference point, which makes adding absolute temperatures undefined. For example 10C + 20C does not equal 30C. Hence, it's an error to add two absolute temperatures.

// adding absolute temperatures is a type error!
cels1 + cels2
// error: 
// Addition not defined in scope for DeltaQuantity[Double, coulomb.units.temperature.Celsius] and Quantity[coulomb.units.temperature.Temperature[Double, coulomb.units.temperature.Celsius], (1 : Int)].
// I found:
// 
//     coulomb.policy.standard.ctx_deltaaddq_2V2U[coulomb.units.si.Kelvin, Double,
//       coulomb.units.temperature.Celsius,
//       coulomb.DeltaQuantity[Double, coulomb.units.temperature.Celsius,
//         coulomb.units.temperature.Kelvin²],
//     (1 : Int)](scala.util.NotGiven.value, scala.util.NotGiven.value,
//       coulomb.ops.ValueResolution.ctx_VR_LpR[Double,
//         coulomb.DeltaQuantity[Double, coulomb.units.temperature.Celsius,
//           coulomb.units.temperature.Kelvin²]
//       ](
//         new
//           coulomb.ops.ValuePromotion[Double,
//             coulomb.DeltaQuantity[Double, coulomb.units.temperature.Celsius,
//               coulomb.units.temperature.Kelvin²]
//           ]
//         ()
//       ),
//     ???, ???, ???)
// 
// But given instance ctx_VP_Path in object ValuePromotion does not match type coulomb.ops.ValuePromotion[Double,
//   coulomb.DeltaQuantity[Double, coulomb.units.temperature.Celsius,
//     coulomb.units.temperature.Kelvin²]
// ]
// 
// where:    Kelvin  is a type in object si with bounds 
//           Kelvin² is a type in object temperature which is an alias of coulomb.units.si.Kelvin
// .

We can see that coulomb time units support an algebra that behaves the same as temperatures above:

import coulomb.units.time.{*, given}

// Two absolute timestamps, relative to Jan 1 1970 (Unix epoch)
val date1 = 50d.withEpochTime[365 * Day]
// date1: DeltaQuantity[Double, *[365, Day], Second] = 50.0
val date2 = 51d.withEpochTime[365 * Day]
// date2: DeltaQuantity[Double, *[365, Day], Second] = 51.0

// subtracting two absolute times yields a Quantity of time units
val dyear = date2 - date1
// dyear: Quantity[Double, *[365, Day]] = 1.0

// one can add or subtract a Quantity from an absolute time
date2 + dyear
// res38: DeltaQuantity[Double, *[365, Day], Second] = 52.0
date2 - dyear
// res39: DeltaQuantity[Double, *[365, Day], Second] = 50.0
// adding absolute time values is an error
date1 + date2
// error: 
// Addition not defined in scope for DeltaQuantity[Double, (365 : Int) * coulomb.units.time.Day] and Quantity[coulomb.units.time.EpochTime[Double, (365 : Int) * coulomb.units.time.Day], (1 : Int)].
// I found:
// 
//     coulomb.policy.standard.ctx_deltaaddq_2V2U[coulomb.units.si.Second, Double,
//       (365 : Int) * coulomb.units.time.Day,
//       coulomb.DeltaQuantity[Double, (365 : Int) * coulomb.units.time.Day,
//         coulomb.units.si.Second],
//     (1 : Int)](scala.util.NotGiven.value, scala.util.NotGiven.value,
//       coulomb.ops.ValueResolution.ctx_VR_LpR[Double,
//         coulomb.DeltaQuantity[Double, (365 : Int) * coulomb.units.time.Day,
//           coulomb.units.si.Second]
//       ](
//         new
//           coulomb.ops.ValuePromotion[Double,
//             coulomb.DeltaQuantity[Double, (365 : Int) * coulomb.units.time.Day,
//               coulomb.units.si.Second]
//           ]
//         ()
//       ),
//     ???, ???, ???)
// 
// But given instance ctx_VP_Path in object ValuePromotion does not match type coulomb.ops.ValuePromotion[Double,
//   coulomb.DeltaQuantity[Double, (365 : Int) * coulomb.units.time.Day,
//     coulomb.units.si.Second]
// ].

Delta Units

As these examples show, time and temperature units both involve a distinction between "absolute" or "positional" values, and standard "quantities", and the algebras of these absolute values are the same under the hood.

In coulomb-core these kinds of absolute value are represented by a type DeltaUnit[V, U, B], where V and U are standard value and unit types, and B represents a base unit. In the case of temperatures, the base unit is Kelvin, and in the case of time it is Second.

DeltaUnit[V, U, B] is so named because the only algebraic operation one can do with a pair of Delta Units is to subtract them.

The DeltaUnit[V, U, B] type supports the following algebra:

DeltaUnit - DeltaUnit => Quantity
DeltaUnit + Quantity => DeltaUnit
DeltaUnit - Quantity => DeltaUnit

Coulomb Policies

The coulomb-core library is designed so that very few typeclasses are hard-coded. As previous sections demonstrate, it is relatively easy to implement your own typeclasses if you need to work with custom types, or would prefer behaviors that are different than available out-of-box typeclasses.

However, one tradeoff is that to obtain out-of-box features, the programmer needs to import a somewhat unweildy number of typeclasses.

To help reduce the number of imports and make it easier to understand various behavior options, coulomb takes advantage of the new Scala 3 export clauses to provide predefined groupings of imports which represent different "policies" for behavior.

The coulomb-core library defines two policies, which you can import from coulomb.policy. The first, which is used by most of the examples in this documentation, is coulomb.policy.standard. This policy supports:

Here is an example of using coulomb.policy.standard

// note this does not include other necessary imports
import coulomb.policy.standard.given
import scala.language.implicitConversions

val q1 = 1d.withUnit[Liter]
// q1: Quantity[Double, Liter] = 1.0
val q2 = 1.withUnit[Meter ^ 3]
// q2: Quantity[Int, ^[Meter, 3]] = 1

// with "standard" policy, coulomb can resolve differing value types and unit types
q1 + q2
// res41: Quantity[Double, Liter] = 1001.0

The second pre-defined policy is couomb.policy.strict, which does not allow implicit conversions of values or units. Operations involving identical value and unit types are always allowed, as are explicit conversions:

import coulomb.policy.strict.given

val q1 = 1d.withUnit[Liter]
// q1: Quantity[Double, Liter] = 1.0
val q2 = 2d.withUnit[Liter]
// q2: Quantity[Double, Liter] = 2.0

// strict policy allows operating with same unit and value
q1 + q2
// res43: Quantity[Double, Liter] = 3.0

// explicit value and unit conversions are always allowed
q1.toValue[Float]
// res44: Quantity[Float, Liter] = 1.0F
q1.toUnit[Meter ^ 3]
// res45: Quantity[Double, ^[Meter, 3]] = 0.001
val q3 = 1.withUnit[Meter ^ 3]

// strict policy does not allow implicit value or unit conversions
q1 + q3
// error:
// Addition not defined in scope for Quantity[Double, coulomb.units.accepted.Liter] and Quantity[Int, coulomb.units.si.Meter ^ (3 : Int)].
// I found:
// 
//     coulomb.policy.strict.ctx_add_2V2U[Double, coulomb.units.accepted.Liter, Int,
//       coulomb.units.si.Meter ^ (3 : Int)](scala.util.NotGiven.value,
//       scala.util.NotGiven.value,
//       coulomb.ops.ValueResolution.ctx_VR_LpR[Double, Int](
//         new coulomb.ops.ValuePromotion[Double, Int]()),
//     ???, ???, ???)
// 
// But given instance ctx_VP_Path in object ValuePromotion does not match type coulomb.ops.ValuePromotion[Double, Int].
// q1 + q3
//      ^^

The coulomb-spire library provides additional predefined policies that support the standard Scala numeric types as well as spire's specialized types.

If you import coulomb-spire policies, do not also import coulomb-core policies. Only one policy at a time should be imported.