Skip to content
Open
Show file tree
Hide file tree
Changes from 12 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
86 changes: 85 additions & 1 deletion docs/README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -149,13 +149,97 @@ 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`:

[source,ruby]
----
# watt per metre per steradian; "/" inverts the right-hand operand
Unitsml::Unit.new("W") / Unitsml::Unit.new("m") / Unitsml::Unit.new("sr")

# m·s⁻²; 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")
----

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

Composed terms are always joined with `*` (division becomes a negative
exponent), so a composed `Formula` renders like `W*m^-1`, not `W/m`. 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:
Comment thread
suleman-uzair marked this conversation as resolved.
Outdated

[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`. The operator DSL composes the objects you construct, so —
as with rendering a `Unit` or `Dimension` directly — a hand-built object
carrying an unresolvable prefix or dimension name is the caller's responsibility.


=== 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
55 changes: 55 additions & 0 deletions lib/unitsml/compose.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# 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"

module_function

# 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 = reference.to_s
return string unless string.strip.empty?

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

# Validate a dimension reference (Dimension.new does not), so the keyword
# form and the fluent #dimension chain both fail fast with the same
# Errors::UnknownDimensionError instead of crashing at render.
Comment thread
suleman-uzair marked this conversation as resolved.
Outdated
def dimension_ref(reference)
string = reference.to_s
return string if 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)
prefix.is_a?(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 value.is_a?(Number)
return value if value.raw_value.match?(%r{\A-?\d+(//?-?\d+)?\z})

raise Errors::InvalidPowerError.new(value: value,
reason: :invalid_number)
end
Comment thread
suleman-uzair marked this conversation as resolved.
end
end
207 changes: 207 additions & 0 deletions lib/unitsml/compose/builder.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
# frozen_string_literal: true

module Unitsml
module Compose
# Assembles composed root Formulas for both the operator DSL (build_product)
# and the keyword form (from_terms). Each leaf is REBUILT with its final
# power computed up front (coerce, negate for division, drop an
# inversion-produced ^1); the builder only READS operands and constructs
# fresh objects, so it can never mutate a caller's operand and never runs an
# in-place negation on shared state. Branch structure is rebuilt via
# TermTree; leaf text lives on Unit/Dimension.
module Builder
class << self
# Combine a left term list with the right operand's terms, interleaving
# multiplication. `/` inverts every right-hand leaf's power.
def build_product(left_terms, right_terms, operator)
left = build_terms(left_terms, invert: false)
right = build_terms(right_terms, invert: operator == "/")
build_root_formula(left + [mul_extender] + right)
end

# Build a root Formula from an ordered list of unit/dimension operands,
# interleaving multiplication between them.
def from_terms(operands, quantity: nil, name: nil, multiplier: nil)
terms = interleave(build_terms(operands, invert: false))
build_root_formula(terms, build_metadata(quantity, name, multiplier))
end

# Return a Formula carrying the given render metadata (quantity/name/
# multiplier). A Formula gets a copy with merged metadata; a lone
# Unit/Dimension is wrapped into a root Formula. Backs the fluent
# #quantity/#name/#multiplier chain methods.
def attach_metadata(node, **extras)
return from_terms([node], **extras) unless node.is_a?(Formula)

added = build_metadata(extras[:quantity], extras[:name],
extras[:multiplier])
copy = node.dup
copy.explicit_value = merge_metadata(node.explicit_value, added)
copy
end

private

def merge_metadata(existing, added)
merged = (existing || {}).merge(added || {})
merged.empty? ? nil : merged
end

def build_root_formula(terms, metadata = nil)
reject_mixed_terms!(terms)
text = terms_text(terms)
Formula.new(terms, explicit_value: metadata, root: true,
orig_text: text, norm_text: text)
end

# --- leaf reconstruction (via TermTree.map_leaves) -----------------

# Rebuild every operand: map_leaves reconstructs branch wrappers and
# each Unit/Dimension leaf is copied with a freshly built final power
# (and a duplicated prefix). Non-leaf terms (Extender/Number) plain-dup.
def build_terms(terms, invert:)
TermTree.map_leaves(terms) { |leaf| rebuild_leaf(leaf, invert) }
end

def rebuild_leaf(leaf, invert)
return leaf.dup unless leaf.is_a?(Unit) || leaf.is_a?(Dimension)

copy = leaf.dup
copy.power_numerator = final_power(leaf.power_numerator, invert)
copy.prefix = leaf.prefix.dup if leaf.is_a?(Unit) && leaf.prefix
copy
end

# The leaf's final exponent as a fresh Number (or nil). For division the
# exponent is negated; a negation that resolves to +1 (dividing by a
# ^-1 term) renders as no exponent, matching the parser, so it is
# dropped. A non-inverted explicit ^1 is preserved.
def final_power(power, invert)
raw = power_string(power)
return raw && Number.new(raw) unless invert

negated = negate_string(raw)
negated == "1" ? nil : Number.new(negated)
end

# A builder-supplied power as a parser-style exponent string (or nil):
# 1 -> "1", 1/2 -> "1/2", 2.0 -> "2". A non-integer Float is rejected
# (the parser has no decimal exponent); an existing Number is read by
# value, never shared.
def power_string(power)
case power
when nil then nil
when Number then power.raw_value
when Integer then power.to_s
when Rational then rational_string(power)
when Float then float_string(power)
else raise Errors::InvalidPowerError.new(value: power)
end
end

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

power.to_i.to_s
end

def rational_string(power)
power.denominator == 1 ? power.numerator.to_s : power.to_s
end

# Negate an exponent string: nil (no exponent) -> "-1", else flip sign.
def negate_string(raw)
return "-1" if raw.nil?

raw.start_with?("-") ? raw.delete_prefix("-") : "-#{raw}"
end
Comment thread
suleman-uzair marked this conversation as resolved.

# --- unit/dimension mixing guard (via TermTree.each_leaf) ----------

# Units and dimensions cannot share one expression (the XML serializer
# would drop the unit block); a single walk collects the leaf kinds.
def reject_mixed_terms!(terms)
kinds = leaf_kinds(terms)
return unless kinds.include?(Unit) && kinds.include?(Dimension)

raise Errors::MixedTermsError
end

def leaf_kinds(terms)
kinds = []
TermTree.each_leaf(terms) do |leaf|
kinds << leaf.class if leaf.is_a?(Unit) || leaf.is_a?(Dimension)
end
kinds
end

# --- text synthesis ------------------------------------------------

# Reconstruct parser-style source text; leaf text lives on the domain
# objects (Unit/Dimension#xml_postprocess_name) and branches wrap their
# inner text. Recurses over the same tree shape as TermTree.
def terms_text(term)
case term
when Array then term.map { |child| terms_text(child) }.join
when Unit, Dimension then term.xml_postprocess_name
when Extender then term.symbol
else branch_text(term)
end
end

def branch_text(term)
case term
when Fenced
"#{term.open_paren}#{terms_text(term.value)}#{term.close_paren}"
when Sqrt then "sqrt(#{terms_text(term.value)})"
when Formula then terms_text(term.value)
else ""
end
end

# --- small helpers -------------------------------------------------

def interleave(operands)
operands.each_with_index.flat_map do |operand, index|
index.zero? ? [operand] : [mul_extender, operand]
end
end

def build_metadata(quantity, name, multiplier)
validate_name!(name)
validate_multiplier!(multiplier)
metadata = { quantity: quantity, name: name,
multiplier: multiplier }.compact
metadata.empty? ? nil : metadata
end

# name is embedded directly into <UnitName>, so it must be a plain
# String/Symbol; a Hash/other would serialize as garbage. (quantity is
# resolved and dropped silently when unresolvable, so needs no guard.)
def validate_name!(name)
return if name.nil? || name.is_a?(String) || name.is_a?(Symbol)

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

# The multiplier is a render separator: nil, a String, or :space/
# :nospace. Reject anything else at the compose boundary so a bad value
# fails as an Errors::* instead of leaking at render.
def validate_multiplier!(multiplier)
return if multiplier.nil? || multiplier.is_a?(String)
return if %i[space nospace].include?(multiplier)

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

def mul_extender
Extender.new("*")
end
end
end
end
end
Loading
Loading