Coulomb Unit Analysis Library

repository·scala3·Indexed 41 days ago

https://github.com/erikerlandson/coulomb

A statically typed unit analysis library for Scala that enables compile-time dimensional analysis. It ensures physical quantities are used correctly in mathematical expressions and is compatible with the Typelevel ecosystem (Cats friendly). The library provides tools for defining BaseUnit, DerivedUnit, and DeltaUnit, as well as type-safe conversion coefficients and offsets for value types including Double, Float, BigDecimal, and Rational.

Tokens
16K
Snippets
71
Records
76
Agent score
100%

What's inside Coulomb

  1. Overview of Coulomb

    scala3
    Coulomb is a statically typed unit analysis library for Scala. It allows developers to perform dimensional analysis at compile time, ensuring that physical quantities (like length, mass, or time) are used correctly within their mathematical expressions. It is designed to be compatible with the Typelevel ecosystem (Cats friendly).
  2. What is a DeltaUnit?

    scala3

    A DeltaUnit[V, U, B] represents an absolute value anchored to a base unit B (e.g., Kelvin for temperature or Second for time).

    It is designed specifically to handle the algebra of absolute positions. The only valid operations involving two DeltaUnits are subtraction, which results in a standard Quantity.

    Supported Algebra:

    • DeltaUnit - DeltaUnit => Quantity
    • DeltaUnit + Quantity => DeltaUnit
    • DeltaUnit - Quantity => DeltaUnit
  3. Use Quantity[V, U] for unit analysis

    scala3

    A Quantity[V, U] represents a value of type V paired with a unit type U. This allows coulomb to perform dimensional analysis at compile time, ensuring that operations like adding meters + seconds result in a type error, while meters / seconds correctly results in a new Quantity with the unit Meter / Second.

    val time = 10.0.withUnit[Second]
    val dist = 100.0.withUnit[Meter]
    
    val v = dist / time
    v.show
  4. Use physical constants as units or values

    scala3

    Physical constants in coulomb-units can be used in two ways:

    1. As a Quantity object using the constant[V, C] function, where V is the value type and C is the constant type.
    2. As a unit for conversion using .toUnit[C].
    import coulomb.units.constants.{*, given}
    
    // the "constant" function gives a physical constant as a Quantity object with a particular value type
    val c = constant[Double, SpeedOfLight]
    
    // use the speed of light as a unit to express a velocity as a fraction of light speed
    val v = 30e6.withUnit[Meter / Second]
    v.toUnit[SpeedOfLight]
  5. Handling Integer values and unit conversions

    scala3

    In coulomb, implicit truncating conversions (e.g., converting Mega * Byte to Byte using an Int type) are generally avoided because they are numerically unsafe.

    In coulomb-pureconfig I/O, conversions will only succeed for integer types if:

    1. The unit in the configuration source exactly matches the target type.
    2. The conversion coefficient between the source unit and the target unit is exactly 1.

    Best Practice: To ensure unit conversions always succeed without precision loss, use fractional value types like Float or Double for your Quantity definitions.

    // source for a quantity value
    val qsrc = ConfigSource.string("""
    {
        value: 3
        unit: megabyte
    }
    """)
    
    // succeeds when type matches
    qsrc.load[Quantity[Int, Mega * Byte]]
    
    // succeeds when conversion coefficient is exactly 1
    qsrc.load[Quantity[Long, Byte * Mega]]
    
    // fails if conversion is not exactly 1 (e.g. requires truncation)
    qsrc.load[Quantity[Int, Kilo * Byte]]
  6. Algebraic support for refined predicates

    scala3

    The coulomb-refined package enables algebraic operations on refined types. The availability of operations depends on the value type (Fractional vs Integral) and the predicate used (Positive vs NonNegative).

    Note that for Either objects returned by refineVU, algebraic operations on Left values result in a Left.

    | Value Type | Predicate | Add Alg | Mult Alg | `+` | `*` | `/` | `pow` (exponent) |
    | --- | --- | --- | --- | --- | --- | --- | --- |
    | Fractional | Positive | semigroup | group | Y | Y | Y | Y (rational) |
    | Fractional | NonNegative | semigroup | semigroup | Y | Y | N | Y (pos int) |
    | Integral | Positive | semigroup | semigroup | Y | Y | N | Y (pos int) |
    | Integral | NonNegative | semigroup | semigroup | Y | Y | N | Y (pos int) |
  7. Convert RuntimeQuantity to static Quantity using CoefficientRuntime

    scala3

    To bridge the gap between runtime unit expressions and static unit types (e.g., when loading unit-aware configurations), use a CoefficientRuntime.

    1. Define a CoefficientRuntime (e.g., using MappingCoefficientRuntime.of) and provide the package names containing the unit types you want to support.
    2. Use the .toQuantity[ValueType, StaticUnitType] method on a RuntimeQuantity.

    Because the conversion depends on runtime data, the result is returned as an Either[_, Quantity[ValueType, StaticUnitType]] to handle cases where units are incompatible.

    // 1. Setup the coefficient runtime with package mappings
    given given_CRT: CoefficientRuntime = MappingCoefficientRuntime.of[
        "coulomb.units.si" *: 
        "coulomb.units.si.prefixes" *: 
        EmptyTuple
    ]
    
    // 2. Convert from runtime to static
    // Valid conversion
    val result1 = rq.toQuantity[Float, Kilo * Meter / Second]
    
    // Valid conversion with different value type
    val result2 = rq.toQuantity[Double, Meter / Second]
    
    // This will return a Left (failure) because units are incompatible
    val result3 = rq.toQuantity[Float, Second]
  8. How absolute temperatures and timestamps work

    scala3

    In coulomb, certain units represent absolute positions rather than relative amounts. This applies to:

    • Temperatures: Absolute temperatures (e.g., Celsius) vs. units of degrees.
    • Time: Absolute timestamps (instants) vs. units of duration.

    Algebraic Rules for Absolute Values

    Because these values are 'anchored' to a reference point (like the Kelvin scale or the Unix epoch), certain operations are invalid:

    • Subtraction: Subtracting two absolute temperatures/timestamps yields a Quantity (a delta/duration).
    • Addition/Subtraction with Quantity: You can add or subtract a Quantity (delta) to/from an absolute value to get a new absolute value.
    • Invalid Operation: Adding two absolute temperatures or two absolute timestamps is a type error (e.g., 10C + 20C is undefined).
    // Temperature Example
    import coulomb.units.temperature.{*, given}
    import coulomb.units.syntax.*
    
    val cels1 = 10d.withTemperature[Celsius]
    val cels2 = 20d.withTemperature[Celsius]
    
    val dcels = cels2 - cels1 // Yields a Quantity of degrees
    cels2 + dcels            // Valid
    // cels1 + cels2          // TYPE ERROR
    
    // Time Example
    import coulomb.units.time.{*, given}
    import coulomb.units.syntax.*
    
    val date1 = 50d.withEpochTime[365 * Day]
    val date2 = 51d.withEpochTime[365 * Day]
    
    val dyear = date2 - date1 // Yields a Quantity of time
    date2 + dyear             // Valid
    // date1 + date2          // TYPE ERROR
  9. Define algebras for custom Value Types

    scala3

    While Quantity[V, U] can use any type V as its value type, operations like addition will only work if an appropriate algebra (e.g., AdditiveSemigroup) is defined for that type. You can implement these algebras to enable standard coulomb operations on your custom wrappers.

    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
  10. Import coulomb-parser components

    scala3

    To use the parsing API, DSL, and standard unit definitions, use the following imports:

    import coulomb.*
    import coulomb.syntax.*
    
    // algebraic definitions
    import algebra.instances.all.given
    
    // unit definitions
    import coulomb.units.si.{*, given}
    import coulomb.units.si.prefixes.{*, given}
    import coulomb.units.info.{*, given}
    import coulomb.units.time.{*, given}
    
    // parsing definitions
    import coulomb.parser.RuntimeUnitParser
    import coulomb.parser.dsl.RuntimeUnitDslParser
  11. Convert between coulomb and java.time

    scala3

    The library provides conversions between coulomb time types and java.time types:

    • coulomb time units (e.g., Second, Minute, Hour) $\leftrightarrow$ java.time.Duration.
    • coulomb absolute time (EpochTime) $\leftrightarrow$ java.time.Instant.

    Explicit Conversions

    Use .toQuantity[V, U] to convert a java.time object to a coulomb quantity, and .toDuration or .toInstant to convert back.

    import java.time.{ Duration, Instant }
    import coulomb.units.time.{*, given}
    
    // extension conversion methods
    import coulomb.units.javatime.*
    
    val dur = Duration.ofSeconds(70, 400000000)
    
    // explicit conversion from java.time duration to a coulomb quantity
    dur.toQuantity[Double, Minute]
    
    // convert back to java.time
    dq.toDuration
    
    // the day of the first human moon landing
    val ins = Instant.parse("1969-07-20T00:00:00Z")
    
    // days relative to standard Unix epoch
    ins.toEpochTime[Double, Day]
    
    // convert back to java.time
    et.toInstant