Module: Philiprehberger::MetricUnits

Defined in:
lib/philiprehberger/metric_units.rb,
lib/philiprehberger/metric_units/version.rb

Defined Under Namespace

Classes: Error

Constant Summary collapse

LENGTH_FACTORS =

Conversion factors to base unit per category Length: base = meters Weight: base = grams Volume: base = liters

{
  km: 1000.0,
  m: 1.0,
  cm: 0.01,
  mm: 0.001,
  miles: 1609.344,
  yards: 0.9144,
  feet: 0.3048,
  inches: 0.0254
}.freeze
WEIGHT_FACTORS =
{
  kg: 1000.0,
  g: 1.0,
  mg: 0.001,
  lbs: 453.59237,
  oz: 28.349523125
}.freeze
VOLUME_FACTORS =
{
  liters: 1.0,
  ml: 0.001,
  gallons: 3.785411784,
  quarts: 0.946352946,
  pints: 0.473176473,
  cups: 0.2365882365
}.freeze
SPEED_FACTORS =
{
  meters_per_second: 1.0,
  kilometers_per_hour: 1.0 / 3.6,
  miles_per_hour: 0.44704,
  knots: 0.514444,
  feet_per_second: 0.3048
}.freeze
PRESSURE_FACTORS =
{
  pascals: 1.0,
  kilopascals: 1000.0,
  bar: 100_000.0,
  psi: 6894.757,
  atmospheres: 101_325.0,
  mmhg: 133.322
}.freeze
ENERGY_FACTORS =
{
  joules: 1.0,
  kilojoules: 1000.0,
  calories: 4.184,
  kilocalories: 4184.0,
  watt_hours: 3600.0,
  kilowatt_hours: 3_600_000.0,
  btu: 1055.06
}.freeze
DATA_FACTORS =

Data: base = bytes. Includes both decimal (SI) and binary (IEC) units.

{
  bytes: 1.0,
  kilobytes: 1_000.0,
  megabytes: 1_000_000.0,
  gigabytes: 1_000_000_000.0,
  terabytes: 1_000_000_000_000.0,
  petabytes: 1_000_000_000_000_000.0,
  kibibytes: 1024.0,
  mebibytes: 1_048_576.0,
  gibibytes: 1_073_741_824.0,
  tebibytes: 1_099_511_627_776.0,
  pebibytes: 1_125_899_906_842_624.0
}.freeze
TEMPERATURE_UNITS =
%i[celsius fahrenheit kelvin].freeze
CATEGORY_MAP =
{
  length: LENGTH_FACTORS,
  weight: WEIGHT_FACTORS,
  volume: VOLUME_FACTORS,
  temperature: nil,
  speed: SPEED_FACTORS,
  pressure: PRESSURE_FACTORS,
  energy: ENERGY_FACTORS,
  data: DATA_FACTORS
}.freeze
ABBREVIATIONS =
{
  # Length
  km: 'km', m: 'm', cm: 'cm', mm: 'mm',
  miles: 'mi', yards: 'yd', feet: 'ft', inches: 'in',
  # Weight
  kg: 'kg', g: 'g', mg: 'mg', lbs: 'lb', oz: 'oz',
  # Volume
  liters: 'L', ml: 'mL',
  gallons: 'gal', quarts: 'qt', pints: 'pt', cups: 'cup',
  # Temperature
  celsius: "\u00B0C", fahrenheit: "\u00B0F", kelvin: 'K',
  # Speed
  meters_per_second: 'm/s', kilometers_per_hour: 'km/h',
  miles_per_hour: 'mph', knots: 'kn', feet_per_second: 'ft/s',
  # Pressure
  pascals: 'Pa', kilopascals: 'kPa', bar: 'bar',
  psi: 'psi', atmospheres: 'atm', mmhg: 'mmHg',
  # Energy
  joules: 'J', kilojoules: 'kJ', calories: 'cal',
  kilocalories: 'kcal', watt_hours: 'Wh',
  kilowatt_hours: 'kWh', btu: 'BTU',
  # Data
  bytes: 'B', kilobytes: 'kB', megabytes: 'MB',
  gigabytes: 'GB', terabytes: 'TB', petabytes: 'PB',
  kibibytes: 'KiB', mebibytes: 'MiB', gibibytes: 'GiB',
  tebibytes: 'TiB', pebibytes: 'PiB'
}.freeze
ALIASES =

Alias map: any token a user might write (symbols, abbreviations, plurals) mapped to the canonical unit symbol used by the CATEGORY_MAP.

{
  # Length
  'kilometer' => :km, 'kilometers' => :km, 'kilometre' => :km, 'kilometres' => :km, 'km' => :km,
  'meter' => :m, 'meters' => :m, 'metre' => :m, 'metres' => :m, 'm' => :m,
  'centimeter' => :cm, 'centimeters' => :cm, 'cm' => :cm,
  'millimeter' => :mm, 'millimeters' => :mm, 'mm' => :mm,
  'mile' => :miles, 'miles' => :miles, 'mi' => :miles,
  'yard' => :yards, 'yards' => :yards, 'yd' => :yards,
  'foot' => :feet, 'feet' => :feet, 'ft' => :feet,
  'inch' => :inches, 'inches' => :inches, 'in' => :inches,
  # Weight
  'kilogram' => :kg, 'kilograms' => :kg, 'kg' => :kg,
  'gram' => :g, 'grams' => :g, 'g' => :g,
  'milligram' => :mg, 'milligrams' => :mg, 'mg' => :mg,
  'pound' => :lbs, 'pounds' => :lbs, 'lb' => :lbs, 'lbs' => :lbs,
  'ounce' => :oz, 'ounces' => :oz, 'oz' => :oz,
  # Volume
  'liter' => :liters, 'liters' => :liters, 'litre' => :liters,
  'litres' => :liters, 'l' => :liters,
  'milliliter' => :ml, 'milliliters' => :ml, 'ml' => :ml,
  'gallon' => :gallons, 'gallons' => :gallons, 'gal' => :gallons,
  'quart' => :quarts, 'quarts' => :quarts, 'qt' => :quarts,
  'pint' => :pints, 'pints' => :pints, 'pt' => :pints,
  'cup' => :cups, 'cups' => :cups,
  # Temperature
  'c' => :celsius, 'celsius' => :celsius, "\u00B0c" => :celsius,
  'f' => :fahrenheit, 'fahrenheit' => :fahrenheit, "\u00B0f" => :fahrenheit,
  'k' => :kelvin, 'kelvin' => :kelvin,
  # Speed
  'm/s' => :meters_per_second, 'mps' => :meters_per_second,
  'km/h' => :kilometers_per_hour, 'kph' => :kilometers_per_hour, 'kmh' => :kilometers_per_hour,
  'mph' => :miles_per_hour, 'mi/h' => :miles_per_hour,
  'kn' => :knots, 'kt' => :knots, 'knot' => :knots, 'knots' => :knots,
  'ft/s' => :feet_per_second, 'fps' => :feet_per_second,
  # Pressure
  'pa' => :pascals, 'pascal' => :pascals, 'pascals' => :pascals,
  'kpa' => :kilopascals, 'kilopascal' => :kilopascals, 'kilopascals' => :kilopascals,
  'bar' => :bar, 'bars' => :bar,
  'psi' => :psi,
  'atm' => :atmospheres, 'atmosphere' => :atmospheres, 'atmospheres' => :atmospheres,
  'mmhg' => :mmhg, 'torr' => :mmhg,
  # Energy
  'j' => :joules, 'joule' => :joules, 'joules' => :joules,
  'kj' => :kilojoules, 'kilojoule' => :kilojoules, 'kilojoules' => :kilojoules,
  'cal' => :calories, 'calorie' => :calories, 'calories' => :calories,
  'kcal' => :kilocalories, 'kilocalorie' => :kilocalories, 'kilocalories' => :kilocalories,
  'wh' => :watt_hours, 'watt_hour' => :watt_hours, 'watt_hours' => :watt_hours,
  'kwh' => :kilowatt_hours, 'kilowatt_hour' => :kilowatt_hours, 'kilowatt_hours' => :kilowatt_hours,
  'btu' => :btu,
  # Data
  'b' => :bytes, 'byte' => :bytes, 'bytes' => :bytes,
  'kb' => :kilobytes, 'kilobyte' => :kilobytes, 'kilobytes' => :kilobytes,
  'mb' => :megabytes, 'megabyte' => :megabytes, 'megabytes' => :megabytes,
  'gb' => :gigabytes, 'gigabyte' => :gigabytes, 'gigabytes' => :gigabytes,
  'tb' => :terabytes, 'terabyte' => :terabytes, 'terabytes' => :terabytes,
  'pb' => :petabytes, 'petabyte' => :petabytes, 'petabytes' => :petabytes,
  'kib' => :kibibytes, 'kibibyte' => :kibibytes, 'kibibytes' => :kibibytes,
  'mib' => :mebibytes, 'mebibyte' => :mebibytes, 'mebibytes' => :mebibytes,
  'gib' => :gibibytes, 'gibibyte' => :gibibytes, 'gibibytes' => :gibibytes,
  'tib' => :tebibytes, 'tebibyte' => :tebibytes, 'tebibytes' => :tebibytes,
  'pib' => :pebibytes, 'pebibyte' => :pebibytes, 'pebibytes' => :pebibytes
}.freeze
HUMANIZE_BYTES_DECIMAL =

Ordered scales used by .humanize_bytes for auto-scaling

%i[bytes kilobytes megabytes gigabytes terabytes petabytes].freeze
HUMANIZE_BYTES_BINARY =
%i[bytes kibibytes mebibytes gibibytes tebibytes pebibytes].freeze
VERSION =
'0.7.0'

Class Method Summary collapse

Class Method Details

.abbreviation(unit) ⇒ String?

Return the standard abbreviation for a unit

Parameters:

  • unit (Symbol, String)

    the unit name

Returns:

  • (String, nil)

    the abbreviation, or nil if unknown



255
256
257
# File 'lib/philiprehberger/metric_units.rb', line 255

def self.abbreviation(unit)
  ABBREVIATIONS[unit.to_sym]
end

.all_unitsHash{Symbol => Array<Symbol>}

Return a mapping of every category to its units.

Equivalent to calling units_for for each entry in categories, but returned in a single call. Useful for populating UI pickers.

Returns:

  • (Hash{Symbol => Array<Symbol>})

    category name to array of unit symbols



247
248
249
# File 'lib/philiprehberger/metric_units.rb', line 247

def self.all_units
  categories.to_h { |cat| [cat, units_for(cat)] }
end

.categoriesArray<Symbol>

Return all available categories

Returns:

  • (Array<Symbol>)


223
224
225
# File 'lib/philiprehberger/metric_units.rb', line 223

def self.categories
  CATEGORY_MAP.keys
end

.category_for(unit) ⇒ Symbol?

Return the category a unit belongs to.

Parameters:

  • unit (Symbol, String)

    the unit name

Returns:

  • (Symbol, nil)

    the category symbol, or nil if unknown



328
329
330
331
332
333
# File 'lib/philiprehberger/metric_units.rb', line 328

def self.category_for(unit)
  unit_sym = unit.to_sym
  return :temperature if TEMPERATURE_UNITS.include?(unit_sym)

  internal_category_for(unit_sym)
end

.compatible?(unit1, unit2) ⇒ Boolean

Check whether two units belong to the same category and can be converted between one another.

Useful for pair-validating units before calling convert. Returns false if either unit is unknown.

Parameters:

  • unit1 (Symbol, String)

    the first unit name

  • unit2 (Symbol, String)

    the second unit name

Returns:

  • (Boolean)

    true iff both units belong to the same category



344
345
346
347
348
# File 'lib/philiprehberger/metric_units.rb', line 344

def self.compatible?(unit1, unit2)
  cat1 = category_for(unit1)
  cat2 = category_for(unit2)
  !cat1.nil? && cat1 == cat2
end

.convert(value, from:, to:) ⇒ Float

Convert a value from one unit to another

Parameters:

  • value (Numeric)

    the value to convert

  • from (Symbol)

    the source unit

  • to (Symbol)

    the target unit

Returns:

  • (Float)

    the converted value

Raises:

  • (Error)

    if units are unknown or incompatible



200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
# File 'lib/philiprehberger/metric_units.rb', line 200

def self.convert(value, from:, to:)
  raise Error, 'value must be numeric' unless value.is_a?(Numeric)

  from = from.to_sym
  to = to.to_sym

  return convert_temperature(value, from, to) if temperature_unit?(from) || temperature_unit?(to)

  from_category = internal_category_for(from)
  to_category = internal_category_for(to)

  raise Error, "unknown unit: #{from}" unless from_category
  raise Error, "unknown unit: #{to}" unless to_category
  raise Error, "cannot convert between #{from_category} and #{to_category}" unless from_category == to_category

  factors = CATEGORY_MAP[from_category]
  base_value = value * factors[from]
  base_value / factors[to]
end

.convert_and_format(value, from:, to:, precision: 2) ⇒ String

Convert a value between units and return a formatted string in the target unit.

Combines convert and format into a single call, using fixed-point formatting that preserves trailing zeros (e.g. precision 2 yields “5.00 km”).

Parameters:

  • value (Numeric)

    the value to convert

  • from (Symbol, String)

    the source unit

  • to (Symbol, String)

    the target unit

  • precision (Integer) (defaults to: 2)

    decimal places in the output (default: 2)

Returns:

  • (String)

    formatted string, e.g. “5.00 km”

Raises:

  • (Error)

    if units are unknown, incompatible, value is non-numeric, or precision is invalid



287
288
289
290
291
292
293
294
295
# File 'lib/philiprehberger/metric_units.rb', line 287

def self.convert_and_format(value, from:, to:, precision: 2)
  raise Error, 'precision must be a non-negative integer' unless precision.is_a?(Integer) && precision >= 0

  converted = convert(value, from: from, to: to)
  abbr = abbreviation(to)
  raise Error, "unknown unit abbreviation: #{to}" unless abbr

  "#{Kernel.format("%.#{precision}f", converted)} #{abbr}"
end

.convert_str(string, to:) ⇒ Float

Parse a string and convert it to another unit in one step.

Parameters:

  • string (String)

    the input, e.g. “5 km”

  • to (Symbol, String)

    the target unit

Returns:

  • (Float)

    the converted numeric value

Raises:

  • (Error)

    if the string cannot be parsed or units are incompatible



378
379
380
381
# File 'lib/philiprehberger/metric_units.rb', line 378

def self.convert_str(string, to:)
  value, from = parse(string)
  convert(value, from: from, to: to)
end

.format(value, unit, precision: 2) ⇒ String

Format a value with its unit abbreviation

Parameters:

  • value (Numeric)

    the value to format

  • unit (Symbol, String)

    the unit name

  • precision (Integer) (defaults to: 2)

    decimal places (default: 2)

Returns:

  • (String)

    formatted string, e.g. “3.14 kg”

Raises:

  • (Error)

    if unit abbreviation is unknown



266
267
268
269
270
271
272
273
# File 'lib/philiprehberger/metric_units.rb', line 266

def self.format(value, unit, precision: 2)
  raise Error, 'value must be numeric' unless value.is_a?(Numeric)

  abbr = abbreviation(unit)
  raise Error, "unknown unit abbreviation: #{unit}" unless abbr

  "#{value.round(precision)} #{abbr}"
end

.format_range(min, max, unit, precision: 2, separator: '–') ⇒ String

Format a value range with a shared unit, producing strings such as ‘“5-10 km”`. The range is normalized so the smaller value appears first and both endpoints are rounded to the requested `precision`. When both endpoints round to the same value the single value is returned.

Parameters:

  • min (Numeric)

    the lower bound

  • max (Numeric)

    the upper bound

  • unit (Symbol, String)

    the unit shared by both bounds

  • precision (Integer) (defaults to: 2)

    decimal places (default: 2)

  • separator (String) (defaults to: '–')

    range separator (default: an en-dash)

Returns:

  • (String)

    the formatted range

Raises:

  • (Error)

    if either bound is non-numeric or the unit is unknown



309
310
311
312
313
314
315
316
317
318
319
320
321
322
# File 'lib/philiprehberger/metric_units.rb', line 309

def self.format_range(min, max, unit, precision: 2, separator: '')
  raise Error, 'min must be numeric' unless min.is_a?(Numeric)
  raise Error, 'max must be numeric' unless max.is_a?(Numeric)

  abbr = abbreviation(unit)
  raise Error, "unknown unit abbreviation: #{unit}" unless abbr

  lo, hi = [min, max].minmax
  lo_r = lo.round(precision)
  hi_r = hi.round(precision)
  return "#{lo_r} #{abbr}" if lo_r == hi_r

  "#{lo_r}#{separator}#{hi_r} #{abbr}"
end

.humanize_bytes(bytes, binary: false, precision: 2) ⇒ String

Auto-scale a byte count to a human-readable string.

Parameters:

  • bytes (Numeric)

    the byte count (may be negative)

  • binary (Boolean) (defaults to: false)

    if true, use IEC (KiB/MiB/…); if false, SI (kB/MB/…)

  • precision (Integer) (defaults to: 2)

    decimal places in the output (default: 2)

Returns:

  • (String)

    formatted string, e.g. “1.50 MB” or “1.50 MiB”

Raises:

  • (Error)

    if bytes is not numeric or precision is negative



390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
# File 'lib/philiprehberger/metric_units.rb', line 390

def self.humanize_bytes(bytes, binary: false, precision: 2)
  raise Error, 'bytes must be numeric' unless bytes.is_a?(Numeric)
  raise Error, 'precision must be a non-negative integer' unless precision.is_a?(Integer) && precision >= 0

  sign = bytes.negative? ? -1 : 1
  abs = bytes.abs.to_f
  units = binary ? HUMANIZE_BYTES_BINARY : HUMANIZE_BYTES_DECIMAL
  step = binary ? 1024.0 : 1000.0

  unit = units.first
  value = abs
  units.each_with_index do |candidate, idx|
    threshold = step**idx
    next_threshold = step**(idx + 1)
    next unless abs < next_threshold || idx == units.length - 1

    unit = candidate
    value = abs / threshold
    break
  end

  self.format(sign * value, unit, precision: precision)
end

.parse(string) ⇒ Array(Float, Symbol)

Parse a string like “5 km”, “3.14kg”, or “72°F” into [value, unit_symbol].

Parameters:

  • string (String)

    the string to parse

Returns:

  • (Array(Float, Symbol))

    a two-element array: [value, canonical unit symbol]

Raises:

  • (Error)

    if the string cannot be parsed or the unit is unknown



355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
# File 'lib/philiprehberger/metric_units.rb', line 355

def self.parse(string)
  raise Error, 'value must be a string' unless string.is_a?(String)

  trimmed = string.strip
  raise Error, 'cannot parse empty string' if trimmed.empty?

  match = trimmed.match(/\A(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)\s*(.+?)\z/)
  raise Error, "cannot parse: #{string.inspect}" unless match

  value = Float(match[1])
  token = match[2].strip.downcase
  unit = ALIASES[token]
  raise Error, "unknown unit: #{match[2]}" unless unit

  [value, unit]
end

.units_for(category) ⇒ Array<Symbol>

Return all units for a given category

Parameters:

  • category (Symbol)

    the category name

Returns:

  • (Array<Symbol>)

Raises:

  • (Error)

    if category is unknown



232
233
234
235
236
237
238
239
# File 'lib/philiprehberger/metric_units.rb', line 232

def self.units_for(category)
  category = category.to_sym
  raise Error, "unknown category: #{category}" unless CATEGORY_MAP.key?(category)

  return TEMPERATURE_UNITS if category == :temperature

  CATEGORY_MAP[category].keys
end