Unit Handling
Custom operations can be made unit-aware
(see Units for information on unit tags in esc).
This is done through the unit_handling
parameter to the @Operation decorator.
This parameter’s value defaults to None,
which gives unspecified behavior.
That is, the operation does not define how to handle unitful quantities;
if the user tries to call it on unitful stack values,
they will get a warning, and if they choose to continue,
the calculation will be carried out unitless and the results will be unitless.
No matter how you configure an operation, it’s always valid to call the operation with all unitless inputs. The unit handling behavior you choose is ignored in this case.
Built-in unit handling behaviors
Many simple operations can become unit-aware
by choosing an appropriate built-in unit handling behavior from esc.units.
Simply set unit_handling
to a newly constructed instance of one of these handler classes
(e.g., unit_handling=additive_unit_handling()):
- class esc.units.additive_unit_handling[source]
All inputs must have an identical unit tag (after coercion of singular/plural variants); the result uses that unit tag. A unitless input is considered to be different from all unitful inputs. Any number of operands are allowed.
- Raises:
esc.oops.IncommensurableUnitsError – if any unit tag differs.
- class esc.units.multiplicative_unit_handling[source]
Unit exponents are added. Any number of operands are allowed. When some operands are unitful and others are unitless, a warning will be issued on the first run.
- Raises:
esc.oops.UnitlessOperandError – if a unitless operand is not the identity value (1). Unlike most unit errors, this is a warning only, and overriding it will carry out the calculation and process the units as normal (the unitless operand will not change the units of the result).
- class esc.units.divisive_unit_handling[source]
Unit exponents are subtracted. Any number of operands are allowed. When some operands are unitful and others are unitless, a warning will be issued on the first run.
- Raises:
esc.oops.UnitlessOperandError – if a unitless operand is not the identity value (1). Unlike most unit errors, this is a warning only, and overriding it will carry out the calculation and process the units as normal (the unitless operand will not change the units of the result).
- class esc.units.power_unit_handling[source]
Exponents of the first input’s unit are multiplied by the exponent value. The exponent must be unitless and integer-valued. Only two operands are allowed, the base and the exponent.
- Raises:
esc.oops.UnitExponentError – if the exponent has a unit or is not an integer.
- class esc.units.root_unit_handling(degree)[source]
Exponents of the first unit are divided by the degree specified at handler instantiation time. All exponents must be evenly divisible. Only one operand is allowed, the base.
- Raises:
esc.oops.UnitRootError – if any exponent is not evenly divisible by the degree.
- class esc.units.no_output_unit_handling[source]
The operation doesn’t push anything; units are irrelevant.
- class esc.units.no_input_unit_handling[source]
The operation has no inputs and the results are unitless (e.g., unitless constants). Any number of outputs are allowed.
- class esc.units.unspecified_unit_handling[source]
The operation doesn’t support unit behavior.
Operations on unitful quantities cannot be carried out, but the user can choose to strip units and complete the calculation.
As an argument to
unit_handlingin an@Operationdecorator, this is functionally equivalent toNone. It is nevertheless good style to provide it to clarify that you’re intentionally choosing not to support units, rather than simply not bothering to add such support.
Custom unit handling behaviors
For more complex operations,
or ones that push multiple values to the stack,
the result may not be distillable to a single default unit-handling behavior.
Here, you can instead pass a custom
UnitHandler callable for unit_handling.
While the built-in behaviors
use subclasses of UnitHandler
for clarity of interface, structure, and customizability,
if you’re writing a one-off unit handler for an operation,
you will likely want to just write a single function that implements this interface
by including the appropriate parameters.
- class esc.units.UnitHandler[source]
A UnitHandler is a callable whose parameters are bound positionally to the operation’s inputs, using the same pattern as
@Operationfunctions.Most parameters are bound to a slice of values at the bottom of the stack, by position. These should ordinarily be identical to those used in your operation function. If the function has one parameter, it receives bos; if the function has two parameters, the first receives sos and the second bos; and so on. See
@Operation’s documentation for details.A varargs (
*args) parameter receives all inputUnitExpressioninstances as a list.If a parameter name ends with
_stackitem, it receives theStackIteminstead of theUnitExpression. This can be used with a varargs parameter as well (e.g.,*args_stackitem).Two special parameters are available, bound by name:
- Parameters:
num_results – The number of results that the operation returns with these inputs.
override –
Whether the user has repeated the operation to override a unit error.
Note
You probably do not need or want to consume this parameter.
The default behavior for overrides is to offer the user an override, and if accepted, carry out the calculation unitless and push unitless results to the stack. You don’t need to consume or use this parameter to get that behavior.
If for your operation there is some sensible way to recover from a unit error/warning and still return unitful values, you can consume this parameter and return the units that should be used for the result(s) in this case. If some unit errors that can occur are recoverable and others are not, raise an exception again for the unrecoverable ones.
This is used internally for the warning about multiplying a unitful by a unitless value.
- Returns:
A list of
UnitExpressioninstances, one for each output.
The
_strsuffix and the special parametersregistryandtestingoffered by operation parameter binding are currently not supported for unit handlers.If the callable has a
UnitHandler.descriptionattribute, or if that is not present a__doc__attribute (docstring), it will be shown in the help screen afterUnits:. In a couple of words, this should describe what happens to the units.- description = ''
Short phrase describing what this handler does to the units, shown on the help screen. Falls back to the docstring if empty.
Inside your callable, you will ordinarily use the methods on the
UnitExpression objects
to determine what units to return:
- class esc.units.UnitExpression(exponents=None)[source]
A symbolic unit expression stored as a dict mapping token strings to integer exponents.
- Examples:
>>> UnitExpression({"miles": 1}).display() 'miles' >>> UnitExpression({"miles": 1, "hours": -1}).display() 'miles / hours' >>> UnitExpression({"feet": 3}).display() 'feet^3' >>> UnitExpression({"seconds": -1}).display() 'seconds^-1' >>> UnitExpression({"m": 1, "s": -2}).display() 'm / s^2'
- property is_unitless
True if this expression has no non-zero exponents.
>>> UnitExpression({}).is_unitless True >>> UnitExpression({"m": 0}).is_unitless True >>> UnitExpression({"m": 1}).is_unitless False
- __add__(other)[source]
Unit algebra for addition/subtraction: units must match.
>>> m = UnitExpression({"m": 1}) >>> (m + UnitExpression({"m": 1})) == m True >>> (UnitExpression({}) + UnitExpression({})).is_unitless True >>> (UnitExpression({"meter": 1}) + UnitExpression({"meters": 1})).display() 'meter'
- Raises:
esc.oops.IncommensurableUnitsError – if the inputs have different unit tags.
- __sub__(other)[source]
Unit algebra for subtraction: units must match (same as addition).
- Raises:
esc.oops.IncommensurableUnitsError – if the inputs have different unit tags.
- __mul__(other)[source]
Unit algebra for multiplication: add exponents.
>>> m = UnitExpression({"m": 1}) >>> s = UnitExpression({"s": 1}) >>> m * s == UnitExpression({"m": 1, "s": 1}) True >>> m * UnitExpression({"m": 1}) == UnitExpression({"m": 2}) True >>> m * UnitExpression({}) == m True >>> UnitExpression({"meter": 1}) * UnitExpression({"meters": 1}) UnitExpression({'meter': 2})
- __truediv__(other)[source]
Unit algebra for division: subtract exponents.
>>> m = UnitExpression({"m": 1}) >>> s = UnitExpression({"s": 1}) >>> m / s == UnitExpression({"m": 1, "s": -1}) True >>> (m / m).is_unitless True >>> (UnitExpression({"meters": 1}) / UnitExpression({"meter": 1})).is_unitless True
- __pow__(n)[source]
Unit algebra for exponentiation: multiply all exponents by n. n must be an integer.
>>> UnitExpression({"m": 1}) ** 2 == UnitExpression({"m": 2}) True >>> (UnitExpression({"m": 1}) ** 0).is_unitless True >>> UnitExpression({"m": 1}) ** 2.5 Traceback (most recent call last): ... esc.oops.UnitExponentError: ...
- Raises:
esc.oops.UnitExponentError – if the exponent is not an integer.
- root(n)[source]
Unit algebra for roots: divide all exponents by n. All exponents must be evenly divisible.
>>> UnitExpression({"m": 2}).root(2) == UnitExpression({"m": 1}) True >>> UnitExpression({"m": 4, "s": -2}).root(2) UnitExpression({'m': 2, 's': -1})
- Raises:
esc.oops.UnitRootError – if any exponent is not evenly divisible by n.
- display()[source]
Render the unit expression for screen display.
Positive-exponent tokens first (joined by
*if multiple), then/and negative-exponent tokens. Exponents shown as^nwhen \(|n| > 1\). If all exponents are negative, use^-nnotation.>>> UnitExpression({"m": 1}).display() 'm' >>> UnitExpression({"m": 2}).display() 'm^2' >>> UnitExpression({"m": 1, "s": -1}).display() 'm / s' >>> UnitExpression({"m": 1, "s": -2}).display() 'm / s^2' >>> UnitExpression({"m": 1, "kg": 1, "s": -2}).display() 'kg * m / s^2' >>> UnitExpression({"s": -1}).display() 's^-1' >>> UnitExpression({"s": -2}).display() 's^-2' >>> UnitExpression({}).display() ''
- static parse(text)[source]
Parse a unit expression from user input.
Supports tokens separated by * and /, with optional ^N exponents.
>>> UnitExpression.parse("miles") == UnitExpression({"miles": 1}) True >>> UnitExpression.parse("miles / hours") == UnitExpression({"miles": 1, "hours": -1}) True >>> UnitExpression.parse("feet^3") == UnitExpression({"feet": 3}) True >>> UnitExpression.parse("m * s^-2") == UnitExpression({"m": 1, "s": -2}) True >>> UnitExpression.parse("m/s") == UnitExpression({"m": 1, "s": -1}) True >>> UnitExpression.parse("m^0").is_unitless True >>> UnitExpression.parse("$").display() '$' >>> UnitExpression.parse("$^2").display() '$^2'
The UnitDecimal class is used in some places
to carry units along with numeric values;
for instance, you’ll attach your
UnitExpressions to it when
testing unit handlers:
- class esc.units.UnitDecimal(value='0', context=None, unit=None)[source]
Subclass of Decimal with an optional
unitattribute (expected typeUnitExpressionorNone).UnitDecimals otherwise behave identically to Decimals in all respects.
>>> ud = UnitDecimal(42, unit=UnitExpression({"m": 1})) >>> isinstance(ud, Decimal) True >>> ud.unit.display() 'm' >>> str(ud) '42 m' >>> str(UnitDecimal(42)) '42'
Several classes of exception,
descended from UnitError,
are available to describe invalid combinations of units
that you might be passed in a unit handler;
see Execution errors in the class reference
for details.
Caution
esc allows a mix of unitful and unitless values to be passed to unit-aware operations. Whether this is sensible depends on the semantics of your operation.
In many cases, it is valid and even required. For instance, some inputs may be semantically unitless while others are unitful, or the operation might be manipulating the stack without doing calculations (in which case you presumably just want to keep whatever units, or no units, were on that stack item before).
However, if not attended to carefully,
this behavior can lead to results with nonsensical units being pushed onto the stack
without any warning.
For instance, if you create a “velocity” operation that takes a distance and a time,
define the return value of your unit_handling as [distance / time],
and the user passes 10 for the distance and 2 seconds for the time,
the resulting “velocity” will be 5 seconds^-1,
which is presumably not what either you or they expected.
If a mix of unitful and unitless values is not sensible,
or only certain combinations of unitful and unitless values are sensible,
your unit handler should check for invalid conditions
(using the is_unitless attribute of the unit object)
and raise a UnitlessOperandError.
The user will be able to override this by pressing the operation key again,
which will carry out the calculation without units.
Example unit handler
Consider the following operation, which calculates the distance traveled and final velocity for an object starting from rest under constant acceleration:
from esc.oops import UnitlessOperandError
from esc.units import UnitHandler
def distance_velocity_unit_handler(acceleration, time):
"""acceleration, time -> distance, velocity"""
units = [acceleration, time]
if ((not all(u.is_unitless for u in units))
and any(u.is_unitless for u in units)):
raise UnitlessOperandError()
return [
acceleration * time * time,
acceleration * time,
]
@Operation(key='a', menu=main_menu, push=2,
description='dist/vel',
log_as="accel {0} for {1}: travels {2} and reaches {3}",
unit_handling=distance_velocity_unit_handler)
def distance_and_final_velocity_from_standing(acceleration, time):
"""
Given a constant acceleration and an amount of time,
calculate the distance traveled and the final velocity
of an object starting from rest.
"""
return [
time * time * acceleration / 2,
time * acceleration,
]
You could equivalently write the unit handler as a subclass of
UnitHandler.
class distance_velocity_unit_handler(UnitHandler):
description = "acceleration, time -> distance, velocity"
def __call__(self, acceleration, time):
units = [acceleration, time]
if ((not all(u.is_unitless for u in units))
and any(u.is_unitless for u in units)):
raise UnitlessOperandError()
return [
acceleration * time * time,
acceleration * time,
]
Testing unit handlers
esc does not verify that the unit algebra specified by a unit handler matches the algebra of your operation, so it is a good idea to include a check of this behavior in your operation’s tests.
To test the unit behavior of an operation in a test case,
use UnitDecimal objects rather than numbers,
and attach appropriate UnitExpressions
to their unit attributes.
For example, here’s how the built-in add operation’s unit behavior is tested,
arbitrarily using units of meters and seconds:
from esc.functest import TestCase
from esc.units import UnitDecimal as UD
from esc.units import UnitExpression as U
# ...define the "add" operation...
add.ensure(
before=[UD(2, unit=U({"m": 1})), UD(3, unit=U({"m": 1}))],
after=[UD(5, unit=U({"m": 1}))]
)
add.ensure(
before=[UD(2, unit=U({"m": 1})), UD(3, unit=U({"s": 1}))],
raises=IncommensurableUnitsError
)
For more information on testing custom operations, see Writing tests.