diff --git a/README.md b/README.md index 8fcd82d..5309269 100644 --- a/README.md +++ b/README.md @@ -460,6 +460,43 @@ fmt.Println(poh) // 11 The package favors correctness and simplicity first, while keeping the implementation small and allocation-free in normal use. +## Optional Parsing + +The core package does not parse unit names. Import `parse` when string input is +part of an application's boundary: + +```go +length, err := parse.ParseLength("5 km") +mass, err := parse.ParseMass("10lb") +_ = length.To(quant.Meter) // 5000 +_ = mass.To(quant.Kilogram) // 4.5359237 +``` + +Custom dimensions can provide their own accepted symbols with `parse.UnitSet`: + +```go +type Widget struct{} +type Box struct{} + +func (Box) ToBase(v float64) float64 { return v * 12 } +func (Box) FromBase(v float64) float64 { return v / 12 } +func (Box) Dimension() Widget { return Widget{} } + +widgets, err := parse.Parse("2 box", parse.UnitSet[Widget]{"box": Box{}}) +``` + +## CODATA Constants + +`codata` contains selected 2022 CODATA recommended values from NIST. Values +are plain `float64` constants with their SI units stated in the package docs; +uncertainties and correlations are intentionally left to the authoritative +CODATA tables. + +```go +speedOfLight := codata.SpeedOfLight // m/s +elementaryCharge := codata.ElementaryCharge // C +``` + ## Development Run tests with: diff --git a/codata/constants.go b/codata/constants.go new file mode 100644 index 0000000..ef07277 --- /dev/null +++ b/codata/constants.go @@ -0,0 +1,34 @@ +// Package codata contains selected 2022 CODATA recommended constants. +// +// Values are exposed as float64 constants in the SI units documented by NIST. +// Uncertainties are not represented; consult the source when uncertainty or +// correlations matter. This package intentionally does not invent Quantity +// dimensions for compound units that are not modeled by quant. +package codata + +const ( + // SpeedOfLight is the speed of light in vacuum, in m/s (exact). + SpeedOfLight = 299792458.0 + // PlanckConstant is Planck's constant, in J s (exact). + PlanckConstant = 6.62607015e-34 + // ReducedPlanckConstant is the reduced Planck constant, in J s. + ReducedPlanckConstant = 1.054571817e-34 + // ElementaryCharge is the elementary charge, in C (exact). + ElementaryCharge = 1.602176634e-19 + // BoltzmannConstant is the Boltzmann constant, in J/K (exact). + BoltzmannConstant = 1.380649e-23 + // AvogadroConstant is the Avogadro constant, in 1/mol (exact). + AvogadroConstant = 6.02214076e23 + // GravitationalConstant is Newton's constant of gravitation, in m^3 kg^-1 s^-2. + GravitationalConstant = 6.67430e-11 + // FineStructureConstant is the fine-structure constant, dimensionless. + FineStructureConstant = 7.2973525643e-3 + // ElectronMass is the electron mass, in kg. + ElectronMass = 9.1093837139e-31 + // ProtonMass is the proton mass, in kg. + ProtonMass = 1.67262192595e-27 + // MolarGasConstant is the molar gas constant, in J/(mol K). + MolarGasConstant = 8.314462618 + // VacuumPermittivity is the vacuum electric permittivity, in F/m. + VacuumPermittivity = 8.8541878188e-12 +) diff --git a/codata/constants_test.go b/codata/constants_test.go new file mode 100644 index 0000000..5bb98f2 --- /dev/null +++ b/codata/constants_test.go @@ -0,0 +1,19 @@ +package codata_test + +import ( + "testing" + + "github.com/nodivbyzero/quant/codata" +) + +func TestDefiningConstants(t *testing.T) { + if codata.SpeedOfLight != 299792458 { + t.Fatalf("speed of light changed: got %g", codata.SpeedOfLight) + } + if codata.ElementaryCharge != 1.602176634e-19 { + t.Fatalf("elementary charge changed: got %g", codata.ElementaryCharge) + } + if codata.AvogadroConstant != 6.02214076e23 { + t.Fatalf("Avogadro constant changed: got %g", codata.AvogadroConstant) + } +} diff --git a/parse/parse.go b/parse/parse.go new file mode 100644 index 0000000..771b125 --- /dev/null +++ b/parse/parse.go @@ -0,0 +1,122 @@ +// Package parse provides optional string parsing for quant quantities. +// +// The core quant package deliberately has no unit-name parser. Callers opt in +// here and provide the unit registry they want to accept. +package parse + +import ( + "fmt" + "strconv" + "strings" + + "github.com/nodivbyzero/quant" +) + +// UnitSet maps accepted symbols to units for one dimension. +type UnitSet[D quant.Dimension] map[string]quant.Unit[D] + +// Parse parses a scalar followed by an optional unit symbol. Both "5 km" and +// "5km" are accepted. A missing symbol uses the empty-string entry in units. +func Parse[D quant.Dimension](input string, units UnitSet[D]) (quant.Quantity[D], error) { + value, symbol, err := split(input) + if err != nil { + return quant.Quantity[D]{}, err + } + + u, ok := units[symbol] + if !ok { + return quant.Quantity[D]{}, fmt.Errorf("parse: unknown unit %q", symbol) + } + + return quant.New[D](value, u), nil +} + +// ParseLength parses a length using the built-in length symbols in LengthUnits. +func ParseLength(input string) (quant.Quantity[quant.Length], error) { + return Parse(input, LengthUnits) +} + +// ParseMass parses a mass using the built-in mass symbols in MassUnits. +func ParseMass(input string) (quant.Quantity[quant.Mass], error) { + return Parse(input, MassUnits) +} + +// ParseTime parses a time using the built-in time symbols in TimeUnits. +func ParseTime(input string) (quant.Quantity[quant.Time], error) { + return Parse(input, TimeUnits) +} + +// ParseTemperature parses a temperature using the built-in temperature symbols +// in TemperatureUnits. +func ParseTemperature(input string) (quant.Quantity[quant.Temperature], error) { + return Parse(input, TemperatureUnits) +} + +var LengthUnits = UnitSet[quant.Length]{ + "": quant.Meter, "m": quant.Meter, "km": quant.Kilometer, "cm": quant.Centimeter, + "mm": quant.Millimeter, "mi": quant.Mile, "ft": quant.Foot, "in": quant.Inch, +} + +var MassUnits = UnitSet[quant.Mass]{ + "": quant.Kilogram, "kg": quant.Kilogram, "g": quant.Gram, "mg": quant.Milligram, + "lb": quant.Pound, "oz": quant.Ounce, +} + +var TimeUnits = UnitSet[quant.Time]{ + "": quant.Second, "s": quant.Second, "ms": quant.Millisecond, "min": quant.Minute, + "h": quant.Hour, "d": quant.Day, +} + +var TemperatureUnits = UnitSet[quant.Temperature]{ + "": quant.Kelvin, "K": quant.Kelvin, "C": quant.Celsius, "F": quant.Fahrenheit, +} + +func split(input string) (float64, string, error) { + s := strings.TrimSpace(input) + if s == "" { + return 0, "", fmt.Errorf("parse: empty input") + } + + end := 0 + if s[0] == '+' || s[0] == '-' { + end++ + } + digits := false + for end < len(s) && s[end] >= '0' && s[end] <= '9' { + end++ + digits = true + } + if end < len(s) && s[end] == '.' { + end++ + for end < len(s) && s[end] >= '0' && s[end] <= '9' { + end++ + digits = true + } + } + if !digits { + return 0, "", fmt.Errorf("parse: invalid number in %q", input) + } + if end < len(s) && (s[end] == 'e' || s[end] == 'E') { + end++ + if end < len(s) && (s[end] == '+' || s[end] == '-') { + end++ + } + expStart := end + for end < len(s) && s[end] >= '0' && s[end] <= '9' { + end++ + } + if expStart == end { + return 0, "", fmt.Errorf("parse: invalid exponent in %q", input) + } + } + + value, err := strconv.ParseFloat(s[:end], 64) + if err != nil { + return 0, "", fmt.Errorf("parse: %w", err) + } + symbol := strings.TrimSpace(s[end:]) + if strings.ContainsAny(symbol, " \t\r\n") { + return 0, "", fmt.Errorf("parse: invalid unit %q", symbol) + } + return value, symbol, nil +} diff --git a/parse/parse_test.go b/parse/parse_test.go new file mode 100644 index 0000000..841ac7d --- /dev/null +++ b/parse/parse_test.go @@ -0,0 +1,43 @@ +package parse_test + +import ( + "math" + "testing" + + "github.com/nodivbyzero/quant" + "github.com/nodivbyzero/quant/parse" +) + +func TestBuiltInParsers(t *testing.T) { + length, err := parse.ParseLength("5 km") + if err != nil || length.To(quant.Meter) != 5000 { + t.Fatalf("length parse: value=%v err=%v", length.To(quant.Meter), err) + } + + mass, err := parse.ParseMass("10lb") + if err != nil || math.Abs(mass.To(quant.Kilogram)-4.5359237) > 1e-12 { + t.Fatalf("mass parse: value=%v err=%v", mass.To(quant.Kilogram), err) + } + + temperature, err := parse.ParseTemperature("32 F") + if err != nil || math.Abs(temperature.To(quant.Kelvin)-273.15) > 1e-12 { + t.Fatalf("temperature parse: value=%v err=%v", temperature.To(quant.Kelvin), err) + } +} + +func TestCustomRegistry(t *testing.T) { + units := parse.UnitSet[Widget]{ + "box": BoxUnit{}, + } + q, err := parse.Parse("2 box", units) + if err != nil || q.To(BoxUnit{}) != 2 { + t.Fatalf("custom parse: value=%v err=%v", q.To(BoxUnit{}), err) + } +} + +type Widget struct{} +type BoxUnit struct{} + +func (BoxUnit) ToBase(v float64) float64 { return v * 12 } +func (BoxUnit) FromBase(v float64) float64 { return v / 12 } +func (BoxUnit) Dimension() Widget { return Widget{} }