Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 69 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ It uses phantom types to model dimensions like length, area, mass, volume, flow,
- Base-unit `String()` formatting and explicit unit-aware `Format(...)`
- Derived dimensions with type-safe division for built-in combinations like length/time
- JSON marshaling based on the scalar base-unit value
- JSON scalar output rounded to 15 significant digits, plus `MarshalJSONIn` for a chosen unit
- Text marshaling and `database/sql` interop helpers
- `time.Duration` interop with `FromDuration` and `ToDuration`
- Support for affine temperature conversions
- Separate temperature-delta quantities for safe temperature differences

Expand Down Expand Up @@ -100,13 +102,25 @@ fmt.Println(quant.Pounds(10)) // 4.5359237 kg
fmt.Println(quant.Pounds(10).Format(quant.Pound)) // 10 lb
```

JSON marshaling stores the scalar value in the base unit for the dimension:
JSON marshaling stores the scalar value in the base unit for the dimension and
uses 15 significant digits to avoid exposing binary floating-point noise:

```go
data, _ := json.Marshal(quant.Pounds(10))
fmt.Println(string(data)) // 4.535923700000001
fmt.Println(string(data)) // 4.5359237
```

For an API whose contract is a particular unit, use the explicit helper:

```go
data, _ := quant.Pounds(10).MarshalJSONIn(quant.Pound)
fmt.Println(string(data)) // 10
```

The default scalar form is intentionally compact, but it does not carry a
unit label. Use a surrounding object or `MarshalJSONIn` when the consumer
cannot know the base unit from the schema.

Text marshaling follows the same base-unit rule:

```go
Expand Down Expand Up @@ -270,8 +284,61 @@ duration := quant.Minutes(30)
speed := distance.Div(duration)

fmt.Println(speed.To(quant.KilometerPerHour)) // 10

area := quant.Meters(3).MulLength(quant.Meters(4))
energy := quant.Watts(100).MulTime(quant.Seconds(2))
power := quant.Volts(12).MulCurrent(quant.Amperes(2))
voltage := quant.Amperes(2).MulResistance(quant.Ohms(3))
```

### Why this one?

`quant` is aimed at Go programs that want compile-time separation between
dimensions without a runtime registry or string parser in the core package.
Unlike APIs built from dedicated numeric types and one method per unit,
`quant` uses one generic `Quantity[D]` plus typed units. That makes custom
dimensions and generic helpers natural, while keeping conversions in small
value types. The trade-off is that the generic API cannot express every
physically valid product as an operator overload; named methods such as
`MulLength` make the supported derived relationships explicit.

`time.Duration` is an excellent standard-library example of a scalar with a
well-defined unit, but it is intentionally limited to time and does not carry
dimension information for arithmetic with length, mass, or electrical values.
`quant` follows that same value-oriented style while extending the type safety
to physical dimensions.

### Semantics and custom dimensions

`pH` is logarithmic, so `Add`, `Sub`, and scalar multiplication on
`Quantity[Acidity]` are only numeric operations, not general physical laws.
`PartsPer` is a ratio and `Pieces` is a count; treat arithmetic on them as
domain-specific. Absolute temperatures should be changed with `AddDelta` and
`SubDelta`; `Add` on `Quantity[Temperature]` remains available for generic
code but is not a physically meaningful temperature operation.

Frequency describes cycles per time, while `AngularVelocity` describes angle
per time. They have overlapping units (RPM and degrees/second) but represent
different physical quantities and therefore remain separate dimensions.
Torque and energy can both have N·m dimensions, but torque is kept separate
to prevent silently treating a rotational moment as transferred energy.

Custom dimensions are struct markers, and custom units implement `ToBase` and
`FromBase`:

```go
type Widget struct{}
type DozenWidgets struct{}

func (DozenWidgets) ToBase(v float64) float64 { return v * 12 }
func (DozenWidgets) FromBase(v float64) float64 { return v / 12 }

widgets := quant.New[Widget](2, DozenWidgets{}).To(DozenWidgets{}) // 2
```

Custom dimensions do not automatically acquire a base-unit symbol for
`String`; use `To` or `Format` with an application-owned formatter.

## Supported Dimensions

- Acceleration: `MeterPerSecondSquared`, `GForce`, `StandardGravity`
Expand Down
4 changes: 2 additions & 2 deletions builder.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
package quant

// builder is a fluent helper for constructing quantities from unit values.
type builder[D any] struct {
type builder[D Dimension] struct {
value float64
}

// Amount starts a fluent quantity construction.
func Amount[D any](v float64) builder[D] {
func Amount[D Dimension](v float64) builder[D] {
return builder[D]{value: v}
}

Expand Down
2 changes: 1 addition & 1 deletion constructors.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package quant

func quantityFrom[D any, U Unit[D]](v float64, u U) Quantity[D] {
func quantityFrom[D Dimension, U Unit[D]](v float64, u U) Quantity[D] {
return New[D](v, u)
}

Expand Down
24 changes: 22 additions & 2 deletions derived.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
package quant

// Mul is a phantom dimension representing the product of two dimensions.
type Mul[A, B any] struct{}
type Mul[A, B Dimension] struct{}

// Div is a phantom dimension representing the quotient of two dimensions.
type Div[A, B any] struct{}
type Div[A, B Dimension] struct{}

// Div divides a length quantity by a time quantity and returns speed.
func (q Quantity[Length]) Div(other Quantity[Time]) Quantity[Speed] {
Expand All @@ -15,3 +15,23 @@ func (q Quantity[Length]) Div(other Quantity[Time]) Quantity[Speed] {
func (q Quantity[Speed]) DivSpeed(other Quantity[Time]) Quantity[Acceleration] {
return Quantity[Acceleration]{value: q.value / other.value}
}

// MulLength multiplies two lengths to produce an area.
func (q Quantity[Length]) MulLength(other Quantity[Length]) Quantity[Area] {
return Quantity[Area]{value: q.value * other.value}
}

// MulTime multiplies power by time to produce energy.
func (q Quantity[Power]) MulTime(other Quantity[Time]) Quantity[Energy] {
return Quantity[Energy]{value: q.value * other.value}
}

// MulCurrent multiplies voltage by current to produce active power.
func (q Quantity[Voltage]) MulCurrent(other Quantity[Current]) Quantity[Power] {
return Quantity[Power]{value: q.value * other.value}
}

// MulResistance multiplies current by resistance to produce voltage.
func (q Quantity[Current]) MulResistance(other Quantity[Resistance]) Quantity[Voltage] {
return Quantity[Voltage]{value: q.value * other.value}
}
47 changes: 33 additions & 14 deletions quantity.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,23 +12,23 @@ import (
//
// The dimension type parameter is a phantom type used only for compile-time
// safety.
type Quantity[D any] struct {
type Quantity[D Dimension] struct {
value float64
}

// New constructs a quantity from a value expressed in the provided unit.
func New[D any, U Unit[D]](v float64, u U) Quantity[D] {
return Quantity[D]{value: u.toBase(v)}
func New[D Dimension, U Unit[D]](v float64, u U) Quantity[D] {
return Quantity[D]{value: u.ToBase(v)}
}

// From constructs a quantity from a value expressed in a built-in unit.
func From[D any](v float64, u BuiltinUnit[D]) Quantity[D] {
return Quantity[D]{value: u.toBase(v)}
func From[D Dimension](v float64, u BuiltinUnit[D]) Quantity[D] {
return Quantity[D]{value: u.ToBase(v)}
}

// To converts the quantity to the provided unit and returns the scalar value.
func (q Quantity[D]) To(u Unit[D]) float64 {
return u.fromBase(q.value)
return u.FromBase(q.value)
}

// Value converts the quantity to the provided unit and returns the scalar value.
Expand All @@ -39,7 +39,13 @@ func (q Quantity[D]) Value(u Unit[D]) float64 {
// MarshalJSON encodes the quantity as its scalar value in the base unit for
// the dimension.
func (q Quantity[D]) MarshalJSON() ([]byte, error) {
return json.Marshal(q.value)
return marshalScalar(q.value)
}

// MarshalJSONIn encodes the quantity as a scalar in the requested unit. The
// default MarshalJSON remains a base-unit scalar for compatibility.
func (q Quantity[D]) MarshalJSONIn(u Unit[D]) ([]byte, error) {
return marshalScalar(u.FromBase(q.value))
}

// UnmarshalJSON decodes a quantity from a scalar value in the base unit for
Expand All @@ -50,7 +56,7 @@ func (q *Quantity[D]) UnmarshalJSON(data []byte) error {

// MarshalText encodes the quantity as a base-unit scalar value.
func (q Quantity[D]) MarshalText() ([]byte, error) {
return []byte(strconv.FormatFloat(q.value, 'g', 12, 64)), nil
return []byte(formatScalar(q.value)), nil
}

// UnmarshalText decodes a base-unit scalar value into the quantity.
Expand All @@ -76,12 +82,12 @@ func (q Quantity[D]) Format(u Unit[D]) string {
}

// SQLQuantity wraps a quantity for database/sql interop using base-unit scalar values.
type SQLQuantity[D any] struct {
type SQLQuantity[D Dimension] struct {
Quantity[D]
}

// SQL wraps a quantity in a database-friendly adapter.
func SQL[D any](q Quantity[D]) SQLQuantity[D] {
func SQL[D Dimension](q Quantity[D]) SQLQuantity[D] {
return SQLQuantity[D]{Quantity: q}
}

Expand Down Expand Up @@ -111,6 +117,19 @@ func (q Quantity[D]) String() string {
return q.Format(u)
}

func formatScalar(v float64) string {
// Fifteen significant digits removes the common binary-float display wart
// while retaining useful float64 precision for API payloads.
return strconv.FormatFloat(v, 'g', 15, 64)
}

func marshalScalar(v float64) ([]byte, error) {
if math.IsNaN(v) || math.IsInf(v, 0) {
return nil, fmt.Errorf("quant: cannot marshal non-finite quantity %v as JSON", v)
}
return []byte(formatScalar(v)), nil
}

// Add adds two quantities of the same dimension.
func (q Quantity[D]) Add(other Quantity[D]) Quantity[D] {
return Quantity[D]{value: q.value + other.value}
Expand Down Expand Up @@ -172,7 +191,7 @@ func (q Quantity[D]) IsNegative() bool {
}

// Min returns the smaller of two quantities with the same dimension.
func Min[D any](a, b Quantity[D]) Quantity[D] {
func Min[D Dimension](a, b Quantity[D]) Quantity[D] {
if a.LessThan(b) {
return a
}
Expand All @@ -181,19 +200,19 @@ func Min[D any](a, b Quantity[D]) Quantity[D] {
}

// Max returns the larger of two quantities with the same dimension.
func Max[D any](a, b Quantity[D]) Quantity[D] {
func Max[D Dimension](a, b Quantity[D]) Quantity[D] {
if a.GreaterThan(b) {
return a
}

return b
}

func baseUnitSymbol[D any]() string {
func baseUnitSymbol[D Dimension]() string {
return unitSymbol(baseUnit[D]())
}

func baseUnit[D any]() Unit[D] {
func baseUnit[D Dimension]() Unit[D] {
switch any(*new(D)).(type) {
case Mass:
return any(Kilogram).(Unit[D])
Expand Down
24 changes: 18 additions & 6 deletions quantity_bench_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,16 @@ import (
"github.com/nodivbyzero/quant"
)

var (
benchmarkQuantity quant.Quantity[quant.Length]
benchmarkSpeed quant.Quantity[quant.Speed]
benchmarkFloat float64
benchmarkString string
)

func BenchmarkNew(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = quant.New[quant.Length](5, quant.Kilometer)
benchmarkQuantity = quant.New[quant.Length](5, quant.Kilometer)
}
}

Expand All @@ -17,27 +24,32 @@ func BenchmarkTo(b *testing.B) {
b.ResetTimer()

for i := 0; i < b.N; i++ {
_ = q.To(quant.Mile)
benchmarkFloat = q.To(quant.Mile)
}
}

func BenchmarkAdd(b *testing.B) {
a := quant.Kilometers(5)
c := quant.Meters(250)
result := a
b.ResetTimer()

for i := 0; i < b.N; i++ {
_ = a.Add(c)
result = result.Add(c)
}
benchmarkQuantity = result
}

func BenchmarkDiv(b *testing.B) {
distance := quant.Kilometers(5)
duration := quant.Minutes(30)
result := distance
step := quant.Meters(250)
b.ResetTimer()

for i := 0; i < b.N; i++ {
_ = distance.Div(duration)
benchmarkSpeed = result.Div(duration)
result = result.Add(step)
}
}

Expand All @@ -46,7 +58,7 @@ func BenchmarkString(b *testing.B) {
b.ResetTimer()

for i := 0; i < b.N; i++ {
_ = q.String()
benchmarkString = q.String()
}
}

Expand All @@ -55,6 +67,6 @@ func BenchmarkFormat(b *testing.B) {
b.ResetTimer()

for i := 0; i < b.N; i++ {
_ = q.Format(quant.Pound)
benchmarkString = q.Format(quant.Pound)
}
}
Loading
Loading