diff --git a/README.md b/README.md index 6c361b7..e3f7c1f 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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` diff --git a/builder.go b/builder.go index ee23441..05ac51e 100644 --- a/builder.go +++ b/builder.go @@ -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} } diff --git a/constructors.go b/constructors.go index d74c6f8..91f2bda 100644 --- a/constructors.go +++ b/constructors.go @@ -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) } diff --git a/derived.go b/derived.go index b58e214..5ab2799 100644 --- a/derived.go +++ b/derived.go @@ -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] { @@ -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} +} diff --git a/quantity.go b/quantity.go index a4a982a..0ea2b0a 100644 --- a/quantity.go +++ b/quantity.go @@ -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. @@ -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 @@ -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. @@ -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} } @@ -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} @@ -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 } @@ -181,7 +200,7 @@ 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 } @@ -189,11 +208,11 @@ func Max[D any](a, b Quantity[D]) Quantity[D] { 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]) diff --git a/quantity_bench_test.go b/quantity_bench_test.go index d53761d..1a077ef 100644 --- a/quantity_bench_test.go +++ b/quantity_bench_test.go @@ -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) } } @@ -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) } } @@ -46,7 +58,7 @@ func BenchmarkString(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - _ = q.String() + benchmarkString = q.String() } } @@ -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) } } diff --git a/quantity_test.go b/quantity_test.go index 1947484..5c4d5c8 100644 --- a/quantity_test.go +++ b/quantity_test.go @@ -6,6 +6,7 @@ import ( "fmt" "math" "testing" + "time" "github.com/nodivbyzero/quant" ) @@ -384,7 +385,7 @@ func TestJSONMarshalingUsesBaseUnit(t *testing.T) { t.Fatalf("MarshalJSON error: %v", err) } - if got := string(data); got != "4.535923700000001" { + if got := string(data); got != "4.5359237" { t.Fatalf("MarshalJSON mismatch: got %q", got) } @@ -398,6 +399,66 @@ func TestJSONMarshalingUsesBaseUnit(t *testing.T) { } } +func TestJSONMarshalingInUnit(t *testing.T) { + data, err := quant.Pounds(10).MarshalJSONIn(quant.Pound) + if err != nil || string(data) != "10" { + t.Fatalf("MarshalJSONIn mismatch: data=%q err=%v", data, err) + } +} + +func TestDurationInterop(t *testing.T) { + d := 1500 * time.Millisecond + if got := quant.FromDuration(d).ToDuration(); got != d { + t.Fatalf("duration round trip mismatch: got %v want %v", got, d) + } +} + +type testWidgets struct{} +type testDozenWidgets struct{} + +func (testDozenWidgets) ToBase(v float64) float64 { return v * 12 } +func (testDozenWidgets) FromBase(v float64) float64 { return v / 12 } + +func TestCustomDimensionAndUnit(t *testing.T) { + q := quant.New[testWidgets](2, testDozenWidgets{}) + if got := q.To(testDozenWidgets{}); !almostEqual(got, 2) { + t.Fatalf("custom unit mismatch: got %v", got) + } +} + +func TestDerivedProducts(t *testing.T) { + if got := quant.Meters(3).MulLength(quant.Meters(4)).To(quant.SquareMeter); !almostEqual(got, 12) { + t.Fatalf("area product mismatch: got %v", got) + } + if got := quant.Watts(100).MulTime(quant.Seconds(2)).To(quant.Joule); !almostEqual(got, 200) { + t.Fatalf("energy product mismatch: got %v", got) + } + if got := quant.Volts(12).MulCurrent(quant.Amperes(2)).To(quant.Watt); !almostEqual(got, 24) { + t.Fatalf("power product mismatch: got %v", got) + } + if got := quant.Amperes(2).MulResistance(quant.Ohms(3)).To(quant.Volt); !almostEqual(got, 6) { + t.Fatalf("voltage product mismatch: got %v", got) + } +} + +func FuzzJSONUnmarshal(f *testing.F) { + f.Add([]byte("1.25")) + f.Add([]byte("-0")) + f.Fuzz(func(t *testing.T, data []byte) { + var q quant.Quantity[quant.Length] + _ = json.Unmarshal(data, &q) + }) +} + +func FuzzTextUnmarshal(f *testing.F) { + f.Add([]byte("1.25")) + f.Add([]byte("-0")) + f.Fuzz(func(t *testing.T, data []byte) { + var q quant.Quantity[quant.Length] + _ = q.UnmarshalText(data) + }) +} + func TestJSONMarshalingInStruct(t *testing.T) { type payload struct { Mass quant.Quantity[quant.Mass] `json:"mass"` @@ -502,7 +563,7 @@ func almostEqual(got, want float64) bool { return math.Abs(got-want) < 1e-9 } -func assertConversion[D any, UFrom quant.Unit[D], UTo quant.Unit[D]](t *testing.T, input float64, from UFrom, want float64, to UTo) { +func assertConversion[D quant.Dimension, UFrom quant.Unit[D], UTo quant.Unit[D]](t *testing.T, input float64, from UFrom, want float64, to UTo) { t.Helper() got := quant.New[D](input, from).To(to) diff --git a/time.go b/time.go index 89df885..1ed5938 100644 --- a/time.go +++ b/time.go @@ -1,5 +1,7 @@ package quant +import "time" + // Time is the dimension marker for time quantities. type Time struct{} @@ -18,3 +20,13 @@ var ( Decade = scaleUnit[Time]{factor: 10 * secondsPerYear} Century = scaleUnit[Time]{factor: 100 * secondsPerYear} ) + +// FromDuration converts a standard-library duration to a Time quantity. +func FromDuration(d time.Duration) Quantity[Time] { + return Quantity[Time]{value: d.Seconds()} +} + +// ToDuration converts a Time quantity to the nearest representable duration. +func (q Quantity[Time]) ToDuration() time.Duration { + return time.Duration(q.value * float64(time.Second)) +} diff --git a/unit.go b/unit.go index 04a7ac3..8ebbe67 100644 --- a/unit.go +++ b/unit.go @@ -2,30 +2,35 @@ package quant import "math" +// Dimension is the set of valid dimension markers. Dimensions are struct +// types, which prevents accidental types such as int from being used as a +// Quantity dimension while still allowing user-defined dimensions. +type Dimension interface { + ~struct{} +} + // Unit converts values between a unit and the base unit for a dimension. -// -// Implementations should be small value types so conversions stay allocation-free -// and inline well. -type Unit[D any] interface { - toBase(float64) float64 - fromBase(float64) float64 +// Implement ToBase and FromBase to define a custom unit outside this package. +type Unit[D Dimension] interface { + ToBase(float64) float64 + FromBase(float64) float64 } // BuiltinUnit is the concrete unit type used by quant's built-in units. -type BuiltinUnit[D any] struct { +type BuiltinUnit[D Dimension] struct { factor float64 offset float64 } -func (u BuiltinUnit[D]) toBase(v float64) float64 { +func (u BuiltinUnit[D]) ToBase(v float64) float64 { return (v + u.offset) * u.factor } -func (u BuiltinUnit[D]) fromBase(v float64) float64 { +func (u BuiltinUnit[D]) FromBase(v float64) float64 { return v/u.factor - u.offset } -type scaleUnit[D any] = BuiltinUnit[D] +type scaleUnit[D Dimension] = BuiltinUnit[D] const ( usSurveyFootInMeters = 1200.0 / 3937.0