coulomb-core
This page describes the fundamental coulomb
concepts, implemented in coulomb-core
.
Quick Start
import
// fundamental coulomb types and methods
import coulomb.*
import coulomb.syntax.*
// algebraic definitions
import algebra.instances.all.given
import coulomb.ops.algebra.all.given
// unit and value type policies for operations
import coulomb.policy.standard.given
import scala.language.implicitConversions
// 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 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 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
// ^
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]].
// 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
// 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[scala.util.NotGiven[Int =:= Int]], ???, ???, ???, ???,
// ???)
//
// But no implicit values were found that match type scala.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:
- "Resolve" left and right value types (
VL
andVR
) into a final output typeVO
- Apply implicit conversion
Quantity[VL, UL]
->Quantity[VO, UL]
- Apply implicit conversion
Quantity[VR, UR]
->Quantity[VO, UL]
- Perform the relevant algebraic operation, in value space
VO
- 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]
// ].
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:
- implicit value type promotions
- implicit unit conversions
- implicit value conversions
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,
// new coulomb.ops.ValueResolution.NC[Any, Any, Any](), ???, ???, ???)
//
// But Found: coulomb.ops.ValueResolution.NC[Any, Any, Any]
// Required: coulomb.ops.ValueResolution[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.