Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 123 additions & 1 deletion docs/README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -149,13 +149,135 @@ Prefixed units: mm, kg, μm
A composite unit is a unit that is derived from two or more basic units
conjoined by multiplication or division.

Composite units are created using the `Unitsml.parse` method.
Composite units are created using the `Unitsml.parse` method, or built
programmatically (see below).

Compound units with operations: kg*s^-2
Units with powers: C^3
Square roots: sqrt(Hz)
Units with prefixes: m/s, m^2/s^2

==== Programmatic composition

When the parts are already available as data rather than a string, composite
units can be built without parsing.

Multiplication (`*`) and division (`/`) compose units, dimensions and formulas,
returning a `Unitsml::Formula`. Like the parser, `/` keeps the `"/"` separator
and inverts the right-hand operand's power, so a single division renders
identically to the parsed string (`W / m` == `Unitsml.parse("W/m")`):

[source,ruby]
----
# watt per metre per steradian, rendered "W/m^-1/sr^-1"
Unitsml::Unit.new("W") / Unitsml::Unit.new("m") / Unitsml::Unit.new("sr")

# m/s^2; powers come from the constructor's second argument
Unitsml::Unit.new("m") / Unitsml::Unit.new("s", 2)

# prefixes via the `prefix:` keyword
Unitsml::Unit.new("m", prefix: "k") # km
----

The same composition can be written as a fluent chain — `#unit`/`#dimension` are
sugar over `*` (`unit("m", -1)` == `* Unit.new("m", -1)`), and
`#quantity`/`#name`/`#multiplier` attach metadata. Metadata must come last, since
each chained term builds a fresh `Formula`:

[source,ruby]
----
Unitsml::Unit.new("W").unit("m", -1).unit("sr", -1).quantity("radiance")
----

An explicit separator can be placed between terms with `#extender` (alias
`#ext`), accepting exactly the parser's grammar set: `"*"`, `"/"` or `"//"`
(any other glyph is a display concern — use the `multiplier:` render option).
It is glyph-only: nothing is negated or transformed, so exponents are stated
explicitly, exactly as a parsed string stores them:

[source,ruby]
----
# byte-identical to Unitsml.parse("W/m") in every format
Unitsml::Unit.new("W").extender("/").unit("m", -1)

# mixed separators, as in Unitsml.parse("W*m/W")
Unitsml::Unit.new("W").ext("*").unit("m").ext("/").unit("W", -1)
----

The glyph may be a String, a Symbol, or an `Unitsml::Extender` object; an
`Extender` also works directly as an operator operand:

[source,ruby]
----
Unitsml::Unit.new("W") * Unitsml::Extender.new("/") * Unitsml::Unit.new("m", -1)
----

A separator must sit between two operands. An expression left ending in a
separator, or with two separators together, has no valid rendering and raises
`Unitsml::Errors::MisplacedExtenderError` when a `to_*` method is called (the
intermediate chain value is still usable — only rendering it is rejected):

[source,ruby]
----
Unitsml::Unit.new("W").ext("/").to_latex # raises MisplacedExtenderError
Unitsml::Unit.new("W").ext("/").unit("m", -1).to_latex # ok — "W/m^-1"
----

A unit is referenced by its symbol id, exactly as in a parsed string
(e.g. `"W"`). Dimensions compose the same way
(`Unitsml::Dimension.new("dim_L")`), but units and dimensions cannot be mixed in
one expression.

The `Unitsml.compose` keyword form builds the same `Formula` from a list. Pass
either `units:` or `dimensions:` (not both). A `units:` entry is a symbol id, a
`{ unit:, power:, prefix: }` hash, or a pre-built `Unitsml::Unit`; a
`dimensions:` entry is a dimension id or a `{ dimension:, power: }` hash:

[source,ruby]
----
Unitsml.compose(
units: ["W", { unit: "m", power: -1 }, { unit: "sr", power: -1 }],
quantity: "radiance",
)

Unitsml.compose(dimensions: ["dim_L", { dimension: "dim_M", power: 2 }])
----

The `quantity`, `name` and `multiplier` metadata are also accepted directly by
the format methods. They apply to `to_xml` (`quantity`/`name` are ignored by the
other formats), and a value passed at render time overrides one carried by a
parsed comma-metadata expression:

[source,ruby]
----
formula = Unitsml.compose(units: ["W", { unit: "m", power: -1 }])
formula.to_xml(quantity: "radiance") # emits a <Quantity> element
----

The fluent `#unit`/`#dimension` chain and the keyword form join terms with `*`,
turning division into a negative exponent, so they render like `W*m^-1`. (The
`/` operator and explicit `#extender`/`#ext` separators keep their glyph
instead — see above.) Where terms are `*`-joined, the separator is a display
choice — pass the `multiplier:` argument to any `to_*` method (`:space`,
`:nospace`, or a custom string such as `"·"`) to change it:

[source,ruby]
----
formula = Unitsml::Unit.new("W").unit("m", -1)
formula.to_asciimath # => "W*m^-1"
formula.to_asciimath(multiplier: :space) # => "W m^-1"
formula.to_asciimath(multiplier: "·") # => "W·m^-1"
----

`Unitsml.compose` and the fluent chain validate every reference, power, prefix
and metadata value and raise a `Unitsml::Errors::*` (all subclasses of
`Unitsml::Errors::BaseError`) for anything they cannot resolve: an unknown unit,
dimension or prefix, a units/dimensions mix, an empty composition, a power the
parser cannot express (a decimal exponent — use a `Rational`), or a non-string
`name`/`multiplier`. `Unit.new`/`Dimension.new` validate their own reference,
prefix and power on construction too, so an unresolvable value raises
immediately whichever surface you use.


=== Format representation

Expand Down
10 changes: 10 additions & 0 deletions lib/unitsml.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
module Unitsml
module_function

autoload :Compose, "unitsml/compose"
autoload :Dimension, "unitsml/dimension"
autoload :Configuration, "unitsml/configuration"
autoload :Errors, "unitsml/errors"
Expand All @@ -20,6 +21,7 @@ module Unitsml
autoload :Number, "unitsml/number"
autoload :Parse, "unitsml/parse"
autoload :Parser, "unitsml/parser"
autoload :PowerNumerator, "unitsml/power_numerator"
autoload :Prefix, "unitsml/prefix"
autoload :PrefixAdapter, "unitsml/prefix_adapter"
autoload :Sqrt, "unitsml/sqrt"
Expand All @@ -33,4 +35,12 @@ module Unitsml
def parse(string)
Unitsml::Parser.new(string).parse
end

def compose(units: nil, dimensions: nil,
quantity: nil, name: nil, multiplier: nil)
Unitsml::Compose::Composite.new(
units: units, dimensions: dimensions,
quantity: quantity, name: name, multiplier: multiplier
).to_formula
end
end
136 changes: 136 additions & 0 deletions lib/unitsml/compose.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# frozen_string_literal: true

module Unitsml
# Programmatic composite-unit construction: the operator DSL / fluent chain
# (Composable), the Unitsml.compose keyword form (Composite), and the shared
# assembly (Builder). The raw-input validators below are shared by BOTH the
# keyword form and the fluent chain so a bad reference/power/prefix fails fast
# with the same Errors::* class no matter which surface is used.
module Compose
autoload :Builder, "unitsml/compose/builder"
autoload :Composable, "unitsml/compose/composable"
autoload :Composite, "unitsml/compose/composite"
autoload :TermTree, "unitsml/compose/term_tree"

# The parser's grammar knows exactly these separators; anything else is a
# display concern served by the render-time multiplier: option.
EXTENDERS = ["*", "/", "//"].freeze

module_function

# A render name is embedded directly into <UnitName>, so it must be a plain
# String/Symbol (or nil); a Hash/other serializes as garbage. Shared by the
# compose build path and the render-option path (to_*(name:)).
def validate_name!(name)
return if type_any?([NilClass, String, Symbol], name)

raise Errors::InvalidUnitEntryError.new(value: name, field: :name)
end

# A multiplier is a render separator: nil, a String, or :space/:nospace.
# Reject anything else where it enters (compose metadata, or a
# to_*(multiplier:) option) so a bad value fails as Errors::*, not later.
def validate_multiplier!(multiplier)
return if type_any?([NilClass, String], multiplier)
return if %i[space nospace].include?(multiplier)

raise Errors::InvalidUnitEntryError.new(value: multiplier,
field: :multiplier)
end

# An explicit extender: an Extender object (reduced to its symbol), or a
# glyph the parser could have produced ("*", "/" or "//"). Custom separators
# belong to the multiplier: render option, not the Formula.
def extender_sym(value)
symbol = type?(Extender, value) ? value.symbol : value
string = safe_string(symbol)
return string if EXTENDERS.include?(string)

raise Errors::InvalidUnitEntryError.new(value: symbol, field: :extender)
end

# A unit reference just needs to be non-blank; Unit.new then fail-fasts on
# an unresolvable (or dim_*) name. Rejects nil/"" (which Unit.new would let
# through as its internal empty sentinel and crash at render).
def unit_ref(reference)
string = safe_string(reference)
return string if string && !string.strip.empty?

raise Errors::UnknownUnitError.new(value: reference)
end

# Normalize and validate a dimension reference at the shared compose
# boundary, so the keyword form, fluent #dimension chain, and Dimension.new
# all fail fast with the same Errors::UnknownDimensionError behavior.
def dimension_ref(reference)
string = safe_string(reference)
return string if string && Unitsdb.dimensions.parsables.key?(string)

raise Errors::UnknownDimensionError.new(value: reference)
end

# A prefix supplied as a Prefix object is reduced to its name so Unit.new
# validates it; other values (String/Symbol/nil) pass through.
def prefix_ref(prefix)
type?(Prefix, prefix) ? prefix.prefix_name : prefix
end

# A Number power must still be a parser-valid exponent (integer or n/m,
# optionally signed / double-slashed); a decimal ("0.5") or garbage ("abc")
# has no parsed equivalent and is rejected. Other types (Integer/Rational/
# Float/nil) are validated by Builder during assembly.
def power(value)
return value unless type?(Number, value)
return value if value.raw_value.match?(%r{\A-?\d+(//?-?\d+)?\z})

raise Errors::InvalidPowerError.new(value: value,
reason: :invalid_number)
end

# #to_s for a raw compose input without letting a pathological override (one
# that raises, has no #to_s, or returns a non-String) escape as a
# non-Errors::* exception; the caller treats nil as "not usable".
def safe_string(value)
string = value.to_s
string if string.is_a?(String)
rescue StandardError
nil
end

# A raw Numeric exponent, rendered in the parser's string form (a whole
# integer, or an n/m fraction from a Rational). A decimal (non-integer
# Float) has no parser representation and is rejected — nothing beyond what
# the parser accepts is introduced.
def numeric_exponent_string(numeric)
case numeric
when Rational then rational_exponent_string(numeric)
when Float then integer_float_string(numeric)
else numeric.to_s # Integer (and any other whole Numeric)
end
end

def rational_exponent_string(rational)
rational.denominator == 1 ? rational.numerator.to_s : rational.to_s
end

def integer_float_string(float)
unless float.finite? && float == float.to_i
raise Errors::InvalidPowerError.new(value: float,
reason: :non_integer_float)
end

float.to_i.to_s
end

# Class-membership test via Module#=== rather than #is_a?, so a compose
# input that is a BasicObject (which has no #is_a?/#nil?) is rejected as a
# typed Errors::* instead of the check itself raising a raw NoMethodError.
def type?(klass, value)
klass === value # rubocop:disable Style/CaseEquality
end

def type_any?(klasses, value)
klasses.any? { |klass| type?(klass, value) }
end
end
end
Loading
Loading