Add GCUs as a unit type (#588)

* Add GCUs as a unit type

* address comments

* address comments
diff --git a/internal/measurement/measurement.go b/internal/measurement/measurement.go
index e95b261..5332574 100644
--- a/internal/measurement/measurement.go
+++ b/internal/measurement/measurement.go
@@ -111,8 +111,9 @@
 	}
 
 	return v1.Unit == v2.Unit ||
-		(isTimeUnit(v1.Unit) && isTimeUnit(v2.Unit)) ||
-		(isMemoryUnit(v1.Unit) && isMemoryUnit(v2.Unit))
+		(timeUnits.sniffUnit(v1.Unit) != nil && timeUnits.sniffUnit(v2.Unit) != nil) ||
+		(memoryUnits.sniffUnit(v1.Unit) != nil && memoryUnits.sniffUnit(v2.Unit) != nil) ||
+		(gcuUnits.sniffUnit(v1.Unit) != nil && gcuUnits.sniffUnit(v2.Unit) != nil)
 }
 
 // Scale a measurement from an unit to a different unit and returns
@@ -124,12 +125,15 @@
 		v, u := Scale(-value, fromUnit, toUnit)
 		return -v, u
 	}
-	if m, u, ok := memoryLabel(value, fromUnit, toUnit); ok {
+	if m, u, ok := memoryUnits.convertUnit(value, fromUnit, toUnit); ok {
 		return m, u
 	}
-	if t, u, ok := timeLabel(value, fromUnit, toUnit); ok {
+	if t, u, ok := timeUnits.convertUnit(value, fromUnit, toUnit); ok {
 		return t, u
 	}
+	if g, u, ok := gcuUnits.convertUnit(value, fromUnit, toUnit); ok {
+		return g, u
+	}
 	// Skip non-interesting units.
 	switch toUnit {
 	case "count", "sample", "unit", "minimum", "auto":
@@ -172,157 +176,121 @@
 	}
 }
 
-// isMemoryUnit returns whether a name is recognized as a memory size
-// unit.
-func isMemoryUnit(unit string) bool {
-	switch strings.TrimSuffix(strings.ToLower(unit), "s") {
-	case "byte", "b", "kilobyte", "kb", "megabyte", "mb", "gigabyte", "gb":
-		return true
-	}
-	return false
+// unit includes a list of aliases representing a specific unit and a factor
+// which one can multiple a value in the specified unit by to get the value
+// in terms of the base unit.
+type unit struct {
+	canonicalName string
+	aliases       []string
+	factor        float64
 }
 
-func memoryLabel(value int64, fromUnit, toUnit string) (v float64, u string, ok bool) {
-	fromUnit = strings.TrimSuffix(strings.ToLower(fromUnit), "s")
-	toUnit = strings.TrimSuffix(strings.ToLower(toUnit), "s")
+// unitType includes a list of units that are within the same category (i.e.
+// memory or time units) and a default unit to use for this type of unit.
+type unitType struct {
+	defaultUnit unit
+	units       []unit
+}
 
-	switch fromUnit {
-	case "byte", "b":
-	case "kb", "kbyte", "kilobyte":
-		value *= 1024
-	case "mb", "mbyte", "megabyte":
-		value *= 1024 * 1024
-	case "gb", "gbyte", "gigabyte":
-		value *= 1024 * 1024 * 1024
-	case "tb", "tbyte", "terabyte":
-		value *= 1024 * 1024 * 1024 * 1024
-	case "pb", "pbyte", "petabyte":
-		value *= 1024 * 1024 * 1024 * 1024 * 1024
-	default:
-		return 0, "", false
-	}
-
-	if toUnit == "minimum" || toUnit == "auto" {
-		switch {
-		case value < 1024:
-			toUnit = "b"
-		case value < 1024*1024:
-			toUnit = "kb"
-		case value < 1024*1024*1024:
-			toUnit = "mb"
-		case value < 1024*1024*1024*1024:
-			toUnit = "gb"
-		case value < 1024*1024*1024*1024*1024:
-			toUnit = "tb"
-		default:
-			toUnit = "pb"
+// findByAlias returns the unit associated with the specified alias. It returns
+// nil if the unit with such alias is not found.
+func (ut unitType) findByAlias(alias string) *unit {
+	for _, u := range ut.units {
+		for _, a := range u.aliases {
+			if alias == a {
+				return &u
+			}
 		}
 	}
-
-	var output float64
-	switch toUnit {
-	default:
-		output, toUnit = float64(value), "B"
-	case "kb", "kbyte", "kilobyte":
-		output, toUnit = float64(value)/1024, "kB"
-	case "mb", "mbyte", "megabyte":
-		output, toUnit = float64(value)/(1024*1024), "MB"
-	case "gb", "gbyte", "gigabyte":
-		output, toUnit = float64(value)/(1024*1024*1024), "GB"
-	case "tb", "tbyte", "terabyte":
-		output, toUnit = float64(value)/(1024*1024*1024*1024), "TB"
-	case "pb", "pbyte", "petabyte":
-		output, toUnit = float64(value)/(1024*1024*1024*1024*1024), "PB"
-	}
-	return output, toUnit, true
+	return nil
 }
 
-// isTimeUnit returns whether a name is recognized as a time unit.
-func isTimeUnit(unit string) bool {
+// sniffUnit simpifies the input alias and returns the unit associated with the
+// specified alias. It returns nil if the unit with such alias is not found.
+func (ut unitType) sniffUnit(unit string) *unit {
 	unit = strings.ToLower(unit)
 	if len(unit) > 2 {
 		unit = strings.TrimSuffix(unit, "s")
 	}
-
-	switch unit {
-	case "nanosecond", "ns", "microsecond", "millisecond", "ms", "s", "second", "sec", "hr", "day", "week", "year":
-		return true
-	}
-	return false
+	return ut.findByAlias(unit)
 }
 
-func timeLabel(value int64, fromUnit, toUnit string) (v float64, u string, ok bool) {
-	fromUnit = strings.ToLower(fromUnit)
-	if len(fromUnit) > 2 {
-		fromUnit = strings.TrimSuffix(fromUnit, "s")
-	}
-
-	toUnit = strings.ToLower(toUnit)
-	if len(toUnit) > 2 {
-		toUnit = strings.TrimSuffix(toUnit, "s")
-	}
-
-	var d time.Duration
-	switch fromUnit {
-	case "nanosecond", "ns":
-		d = time.Duration(value) * time.Nanosecond
-	case "microsecond":
-		d = time.Duration(value) * time.Microsecond
-	case "millisecond", "ms":
-		d = time.Duration(value) * time.Millisecond
-	case "second", "sec", "s":
-		d = time.Duration(value) * time.Second
-	case "cycle":
-		return float64(value), "", true
-	default:
-		return 0, "", false
-	}
-
-	if toUnit == "minimum" || toUnit == "auto" {
-		switch {
-		case d < 1*time.Microsecond:
-			toUnit = "ns"
-		case d < 1*time.Millisecond:
-			toUnit = "us"
-		case d < 1*time.Second:
-			toUnit = "ms"
-		case d < 1*time.Minute:
-			toUnit = "sec"
-		case d < 1*time.Hour:
-			toUnit = "min"
-		case d < 24*time.Hour:
-			toUnit = "hour"
-		case d < 15*24*time.Hour:
-			toUnit = "day"
-		case d < 120*24*time.Hour:
-			toUnit = "week"
-		default:
-			toUnit = "year"
+// autoScale takes in the value with units of the base unit and returns
+// that value scaled to a reasonable unit if a reasonable unit is
+// found.
+func (ut unitType) autoScale(value float64) (float64, string, bool) {
+	var f float64
+	var unit string
+	for _, u := range ut.units {
+		if u.factor >= f && (value/u.factor) >= 1.0 {
+			f = u.factor
+			unit = u.canonicalName
 		}
 	}
-
-	var output float64
-	dd := float64(d)
-	switch toUnit {
-	case "ns", "nanosecond":
-		output, toUnit = dd/float64(time.Nanosecond), "ns"
-	case "us", "microsecond":
-		output, toUnit = dd/float64(time.Microsecond), "us"
-	case "ms", "millisecond":
-		output, toUnit = dd/float64(time.Millisecond), "ms"
-	case "min", "minute":
-		output, toUnit = dd/float64(time.Minute), "mins"
-	case "hour", "hr":
-		output, toUnit = dd/float64(time.Hour), "hrs"
-	case "day":
-		output, toUnit = dd/float64(24*time.Hour), "days"
-	case "week", "wk":
-		output, toUnit = dd/float64(7*24*time.Hour), "wks"
-	case "year", "yr":
-		output, toUnit = dd/float64(365*24*time.Hour), "yrs"
-	default:
-		// "sec", "second", "s" handled by default case.
-		output, toUnit = dd/float64(time.Second), "s"
+	if f == 0 {
+		return 0, "", false
 	}
-	return output, toUnit, true
+	return value / f, unit, true
+}
+
+// convertUnit converts a value from the fromUnit to the toUnit, autoscaling
+// the value if the toUnit is "minimum" or "auto". If the fromUnit is not
+// included in the unitType, then a false boolean will be returned. If the
+// toUnit is not in the unitType, the value will be returned in terms of the
+// default unitType.
+func (ut unitType) convertUnit(value int64, fromUnitStr, toUnitStr string) (float64, string, bool) {
+	fromUnit := ut.sniffUnit(fromUnitStr)
+	if fromUnit == nil {
+		return 0, "", false
+	}
+	v := float64(value) * fromUnit.factor
+	if toUnitStr == "minimum" || toUnitStr == "auto" {
+		if v, u, ok := ut.autoScale(v); ok {
+			return v, u, true
+		}
+		return v / ut.defaultUnit.factor, ut.defaultUnit.canonicalName, true
+	}
+	toUnit := ut.sniffUnit(toUnitStr)
+	if toUnit == nil {
+		return v / ut.defaultUnit.factor, ut.defaultUnit.canonicalName, true
+	}
+	return v / toUnit.factor, toUnit.canonicalName, true
+}
+
+var memoryUnits = unitType{
+	units: []unit{
+		{"B", []string{"b", "byte"}, 1},
+		{"kB", []string{"kb", "kbyte", "kilobyte"}, float64(1 << 10)},
+		{"MB", []string{"mb", "mbyte", "megabyte"}, float64(1 << 20)},
+		{"GB", []string{"gb", "gbyte", "gigabyte"}, float64(1 << 30)},
+		{"TB", []string{"tb", "tbyte", "terabyte"}, float64(1 << 40)},
+		{"PB", []string{"pb", "pbyte", "petabyte"}, float64(1 << 50)},
+	},
+	defaultUnit: unit{"B", []string{"b", "byte"}, 1},
+}
+
+var timeUnits = unitType{
+	units: []unit{
+		{"ns", []string{"ns", "nanosecond"}, float64(time.Nanosecond)},
+		{"us", []string{"μs", "us", "microsecond"}, float64(time.Microsecond)},
+		{"ms", []string{"ms", "millisecond"}, float64(time.Millisecond)},
+		{"s", []string{"s", "sec", "second"}, float64(time.Second)},
+		{"hrs", []string{"hour", "hr"}, float64(time.Hour)},
+	},
+	defaultUnit: unit{"s", []string{}, float64(time.Second)},
+}
+
+var gcuUnits = unitType{
+	units: []unit{
+		{"n*GCU", []string{"nanogcu"}, 1e-9},
+		{"u*GCU", []string{"microgcu"}, 1e-6},
+		{"m*GCU", []string{"milligcu"}, 1e-3},
+		{"GCU", []string{"gcu"}, 1},
+		{"k*GCU", []string{"kilogcu"}, 1e3},
+		{"M*GCU", []string{"megagcu"}, 1e6},
+		{"G*GCU", []string{"gigagcu"}, 1e9},
+		{"T*GCU", []string{"teragcu"}, 1e12},
+		{"P*GCU", []string{"petagcu"}, 1e15},
+	},
+	defaultUnit: unit{"GCU", []string{}, 1.0},
 }
diff --git a/internal/measurement/measurement_test.go b/internal/measurement/measurement_test.go
index 155cafa..7521a64 100644
--- a/internal/measurement/measurement_test.go
+++ b/internal/measurement/measurement_test.go
@@ -15,6 +15,7 @@
 package measurement
 
 import (
+	"math"
 	"testing"
 )
 
@@ -34,14 +35,42 @@
 		{1024, "gb", "tb", 1, "TB"},
 		{1024, "tb", "pb", 1, "PB"},
 		{2048, "mb", "auto", 2, "GB"},
-		{3.1536e7, "s", "auto", 1, "yrs"},
+		{3.1536e7, "s", "auto", 8760, "hrs"},
 		{-1, "s", "ms", -1000, "ms"},
 		{1, "foo", "count", 1, ""},
 		{1, "foo", "bar", 1, "bar"},
+		{2000, "count", "count", 2000, ""},
+		{2000, "count", "auto", 2000, ""},
+		{2000, "count", "minimum", 2000, ""},
+		{8e10, "nanogcu", "petagcus", 8e-14, "P*GCU"},
+		{1.5e10, "microGCU", "teraGCU", 1.5e-8, "T*GCU"},
+		{3e6, "milliGCU", "gigagcu", 3e-6, "G*GCU"},
+		{1000, "kilogcu", "megagcu", 1, "M*GCU"},
+		{2000, "GCU", "kiloGCU", 2, "k*GCU"},
+		{7, "megaGCU", "gcu", 7e6, "GCU"},
+		{5, "gigagcus", "milligcu", 5e12, "m*GCU"},
+		{7, "teragcus", "microGCU", 7e18, "u*GCU"},
+		{1, "petaGCU", "nanogcus", 1e24, "n*GCU"},
+		{100, "NanoGCU", "auto", 100, "n*GCU"},
+		{5000, "nanogcu", "auto", 5, "u*GCU"},
+		{3000, "MicroGCU", "auto", 3, "m*GCU"},
+		{4000, "MilliGCU", "auto", 4, "GCU"},
+		{4000, "GCU", "auto", 4, "k*GCU"},
+		{5000, "KiloGCU", "auto", 5, "M*GCU"},
+		{6000, "MegaGCU", "auto", 6, "G*GCU"},
+		{7000, "GigaGCU", "auto", 7, "T*GCU"},
+		{8000, "TeraGCU", "auto", 8, "P*GCU"},
+		{9000, "PetaGCU", "auto", 9000, "P*GCU"},
 	} {
-		if gotValue, gotUnit := Scale(tc.value, tc.fromUnit, tc.toUnit); gotValue != tc.wantValue || gotUnit != tc.wantUnit {
-			t.Errorf("Scale(%d, %q, %q) = (%f, %q), want (%f, %q)",
+		if gotValue, gotUnit := Scale(tc.value, tc.fromUnit, tc.toUnit); !floatEqual(gotValue, tc.wantValue) || gotUnit != tc.wantUnit {
+			t.Errorf("Scale(%d, %q, %q) = (%g, %q), want (%g, %q)",
 				tc.value, tc.fromUnit, tc.toUnit, gotValue, gotUnit, tc.wantValue, tc.wantUnit)
 		}
 	}
 }
+
+func floatEqual(a, b float64) bool {
+	diff := math.Abs(a - b)
+	avg := (math.Abs(a) + math.Abs(b)) / 2
+	return diff/avg < 0.0001
+}