coulomb-core
This page describes the fundamental coulomb
concepts, implemented in coulomb-core
.
Quick Start
packages
Include coulomb-core
with your Scala project:
libraryDependencies += "com.manyangled" %% "coulomb-core" % "0.9.1"
// coulomb's predefined units package
// (optional if you are defining your own units)
libraryDependencies += "com.manyangled" %% "coulomb-units" % "0.9.1"
import
// fundamental coulomb types and methods
import coulomb.*
import coulomb.syntax.*
// algebraic definitions
import algebra.instances.all.given
// SI unit definitions
import coulomb.units.si.{*, given}
Unit Analysis
Unit analysis - aka dimensional analysis
- is the practice of tracking units of measure along with numeric computations as an informational aid and consistency check.
For example, if one has a duration
t = 10 seconds
and a distanced = 100 meters
, then unit analysis tells us that the valued/t
has the unitmeters/second
.
Unit analysis performs a role very similar 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 bad = 7 + false
// ^^^
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
// volume.toUnit[Acre].show
// ^^^^^^^^^^^^^^^^^^^
// error:
// unit type (Meter ^ 3) not convertable to Acre
// volume.toUnit[Acre].show
// ^^^^^^^^^^^^^^^^^^^
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, unit_Meter }
export coulomb.units.time.{ Hour, 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]"
Numeric Value Types
The coulomb core libraries provide out-of-box support for the following numeric types:
Value Type | Defined In |
---|---|
Int | Scala |
Long | Scala |
Float | Scala |
Double | Scala |
BigInt | Scala |
BigDecimal | Scala |
Rational | Spire |
Algebraic | Spire |
Real | Spire |
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:
// No given instance of type algebra.ring.AdditiveSemigroup[Wrapper[Float]] was found for parameter alg of method + in object Quantity
// val wsum = w + w
// ^
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
// comparison operators
v <= v
// res19: Boolean = true
v > v
// res20: Boolean = false
v === v
// res21: Boolean = true
v =!= v
// res22: Boolean = false
Truncating Division
coulomb
also supports truncating division via the tquot
operator,
typically used for integral types.
The standard typelevel cats
algebras do not define truncating division,
however you can import these typeclasses from spire
.
// import spire algebra typeclasses to include TruncatedDivision
import spire.std.any.given
// truncating integer division
5.withUnit[Meter] `tquot` 2.withUnit[Second]
// res23: Quantity[Int, /[Meter, Second]] = 2
Operations and Conversions
Unit Conversions
coulomb
will implicitly convert units to align them for operations,
such as addition, subtraction or comparisons.
Units are always converted to the units of the left-hand operand.
In the following examples, you can see that kilometers are converted to meters before operating.
1000.0.withUnit[Meter] + 1.0.withUnit[Kilo * Meter]
// res24: Quantity[Double, Meter] = 2000.0
1000.0.withUnit[Meter] - 0.5.withUnit[Kilo * Meter]
// res25: Quantity[Double, Meter] = 500.0
1000.0.withUnit[Meter] === 1.0.withUnit[Kilo * Meter]
// res26: Boolean = true
1000.0.withUnit[Meter] > 0.5.withUnit[Kilo * Meter]
// res27: Boolean = true
coulomb
only defines implicit unit conversions on fractional value types,
such as Double
, Float
, BigDecimal
and Spire types like Rational
or Real
.
Unit conversions on integral types such as Int
or Long
are not supported
due to the numeric instability caused by integer value truncations.
Value Conversions
coulomb
does not perform implicit value conversions, as illustrated in the following:
// coulomb will not convert double to float implicitly
1f.withUnit[Meter] + 1d.withUnit[Meter]
// error:
// No given instance of type algebra.ring.AdditiveSemigroup[Double | Float] was found for parameter alg of method + in object Quantity
// 1f.withUnit[Meter] + 1d.withUnit[Meter]
// ^
However, you can always convert values using the toValue
method.
// use toValue method to align value types
1f.withUnit[Meter] + 1d.withUnit[Meter].toValue[Float]
// res29: Quantity[Float, Meter] = 2.0F
Releases of coulomb
prior to 0.9 supported implicit value conversions.
Implicit value conversions were removed for a variety of reasons.
Changing value types involves subtle tradeoffs in numeric precision that are best left to the developer.
Additionally, implicit value conversions interact with Scala's native value conversions
(e.g. promoting Float to Double)
in ways that are difficult to untangle.
Removing implicit value conversions has allowed substantial simplifications to coulomb
's
system of typeclasses.
Value and Unit Conversions
In coulomb
, a Quantity[V, U]
may experience two kinds of conversion:
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]
// res30: Quantity[Float, Meter] = 10.0F
// convert unit type
q.toUnit[Yard]
// res31: 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.
coulomb
only predefines UnitConversion
typeclasses for fractional types
such as Double
, Float
, BigDecimal
, etc.
Unit conversions on non fractional integral types are not numerically safe,
due to the presence of integer truncations.
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
implicit unit conversions,
based on UnitConversion
context in scope.
The coulomb
libraries themselves do not make use of Scala implicit conversions.
You only need to import them if you want to use them in your own code.
Implicit quantity conversions can be used in typical Scala scenarios:
import scala.language.implicitConversions
import coulomb.conversion.implicits.given
// implicitly convert cubic meters to liters
val iconv: Quantity[Double, Liter] = 1.0.withUnit[Meter ^ 3]
// iconv: Quantity[Double, Liter] = 1000.0
// implicitly convert "raw" values to unitless Quantity
val uq: Quantity[Int, 1] = 100
// uq: Quantity[Int, 1] = 100
Defining Conversions
The coulomb-core
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)
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 temperature units
import coulomb.units.temperature.{*, given}
// import extensions for temp and time
import coulomb.units.syntax.*
// 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
// res34: DeltaQuantity[Double, Celsius, Kelvin] = 30.0
cels2 - dcels
// res35: 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:
// Found: (cels2 :
// coulomb.units.temperature.Temperature[Double,
// coulomb.units.temperature.Celsius]
// )
// Required: coulomb.Quantity[Double, Any]
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
// res37: DeltaQuantity[Double, *[365, Day], Second] = 52.0
date2 - dyear
// res38: DeltaQuantity[Double, *[365, Day], Second] = 50.0
// adding absolute time values is an error
date1 + date2
// error:
// Found: (date2 :
// coulomb.units.time.EpochTime[Double, (365 : Int) * coulomb.units.time.Day])
// Required: coulomb.Quantity[Double, Any]
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