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.3.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



245
246
247
# File 'lib/philiprehberger/metric_units.rb', line 245

def self.abbreviation(unit)
  ABBREVIATIONS[unit.to_sym]
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



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

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

  internal_category_for(unit_sym)
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_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



304
305
306
307
# File 'lib/philiprehberger/metric_units.rb', line 304

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



256
257
258
259
260
261
262
263
# File 'lib/philiprehberger/metric_units.rb', line 256

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

.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



316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
# File 'lib/philiprehberger/metric_units.rb', line 316

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



281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
# File 'lib/philiprehberger/metric_units.rb', line 281

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