Skip to content

Add functional conversions for non-proportional units#188

Open
ntn wants to merge 5 commits intomainfrom
functional-conversions
Open

Add functional conversions for non-proportional units#188
ntn wants to merge 5 commits intomainfrom
functional-conversions

Conversation

@ntn
Copy link
Contributor

@ntn ntn commented Mar 10, 2026

Add functional conversions for non-proportional units

Adds support for arbitrary conversion functions between units, enabling non-proportional relationships like temperature. Addresses #59, taking a different approach from #151.

Problem

Every conversion in the gem today is a simple ratio: multiply by some constant to go between units. That works for length, weight, and volume, but not for relationships with offsets. Converting Celsius to Fahrenheit (F = C * 9/5 + 32) can't be expressed as a single multiplicative factor.

Approach

Units can now define conversions as proc pairs, one to convert toward the base unit, one to convert away from it:

Measured::Temperature = Measured.build do
  unit :C, aliases: [:celsius]

  unit :K, aliases: [:kelvin], convert_to: "C",
    forward: ->(k) { k - BigDecimal("273.15") },
    backward: ->(c) { c + BigDecimal("273.15") },
    description: "celsius + 273.15"

  unit :F, aliases: [:fahrenheit], convert_to: "C",
    forward: ->(f) { (f - 32) * Rational(5, 9) },
    backward: ->(c) { c * Rational(9, 5) + 32 },
    description: "celsius * 9/5 + 32"
end

forward maps a value in the defined unit into the base unit. backward does the reverse. For indirect paths (e.g. K to F), the builder composes the procs automatically.

What changed

  • Measured::Unit - parse_value now recognizes [{forward:, backward:}, "base_unit"] alongside the existing string and [number, unit] formats. Adds functional? and a public description reader.
  • Measured::UnitSystemBuilder - unit() accepts convert_to:, forward:, backward:, and description: keyword arguments as a cleaner alternative to the value: hash format.
  • Measured::ConversionTableBuilderBase - shared module extracted from both builders containing cycle detection, acyclic graph validation, and direct conversion caching.
  • Measured::FunctionalConversionTableBuilder - builds a conversion table of procs instead of Rationals. The identity lambda preserves input types. Handles mixed systems where some units are static and others are functional.
  • Measured::UnitSystem - selects the builder based on whether any unit is functional. Adds functional? for introspection. convert coerces inputs to Rational before calling procs for exact arithmetic, and dispatches through either proc.call(value) or value * rational depending on the table entry.
  • Measured::CacheError - raised at construction time if a functional system attempts to use a cache (procs aren't serializable).

Backward compatibility

Static unit systems are untouched: same builder, same Rational arithmetic, same JSON caching. All existing tests pass without modification. The value: [{...}, "unit"] format still works internally.

How this differs from #151

#151 introduced StaticUnitConversion / DynamicUnitConversion wrapper classes and a subclass of ConversionTableBuilder that mutated units in place via to_dynamic. This PR takes a simpler path:

  • conversion_amount is either a Rational or a Proc directly, no intermediary objects
  • Units are never mutated; FunctionalConversionTableBuilder is a standalone class
  • Dedicated convert_to: / forward: / backward: kwargs instead of overloading value: with hash syntax
  • Shared builder logic extracted into a module rather than using inheritance

Limitations

  • Functional systems cannot be cached. This is inherent to the proc-based approach.

ntn added 5 commits March 9, 2026 23:17
…fety

Extract ConversionTableBuilderBase module to DRY up cycle detection,
graph validation, and conversion caching shared by both builders.

Add convert_to:/forward:/backward:/description: kwargs to
UnitSystemBuilder#unit as a cleaner alternative to the value: hash format.

Fix IDENTITY lambda to preserve input types, coerce convert inputs to
Rational for exact arithmetic, and use Rational(1) in comparable_amount.

Add UnitSystem#functional? for introspection.
@ntn ntn mentioned this pull request Mar 10, 2026
4 tasks
@ntn ntn self-assigned this Mar 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant