"""
Toleranced values and interval arithmetic
=========================================
This module provides the Toleranced class for representing values
with tolerances and performing interval arithmetic operations.
"""
from __future__ import annotations
from collections.abc import Callable
from typing import Self, overload
from .interval import Interval
class Unspecified:
""":meta private:"""
def __repr__(self):
return "Unspecified"
_UNSPECIFIED = Unspecified()
_Unspecified = Unspecified
del Unspecified
[docs]
class Toleranced(Interval):
"""
Interval Arithmetic Type for values with tolerances.
Args:
typ: Typical value (average/nominal)
plus: Relative positive increment (max bound, or None for unbounded)
minus: Relative negative increment (min bound, or None for unbounded),
If this argument is unspecified, the range will be symmetric.
>>> # Create a resistor with ±5% tolerance
>>> r1 = Toleranced.percent(10_000, 5) # 10kΩ ± 5%
>>> print(r1)
10000 ± 5%
>>> # Create a voltage with asymmetric bounds
>>> v1 = Toleranced.min_typ_max(2.7, 3.3, 3.6) # 2.7V to 3.6V, typical 3.3V
>>> print(v1)
Toleranced(2.7 <= 3.3 <= 3.6)
>>> # Arithmetic with tolerances
>>> r2 = Toleranced.percent(1_000, 1) # 1kΩ ± 1%
>>> total = r1 + r2
>>> print(f"Total: {total.min_value:.0f}Ω to {total.max_value:.0f}Ω")
Total: 10490Ω to 11510Ω
>>> # Voltage divider calculation
>>> v_out = v1 * r2 / (r1 + r2)
>>> print(v_out)
Toleranced(0.3, 0.0466158, 0.0677672)
"""
typ: float
"""The typical/nominal value."""
plus: float | None
"""Positive tolerance from typical to maximum value. None indicates unbounded maximum."""
minus: float | None
"""Negative tolerance from typical to minimum value. None indicates unbounded minimum."""
__used_percent: tuple[float, float] | None = None
@overload
def __init__(self, typ: float, plusminus: float | None, /): ...
@overload
def __init__(self, typ: float, plus: float | None, minus: float | None): ...
def __init__(
self,
typ: float,
plus: float | None,
minus: float | None | _Unspecified = _UNSPECIFIED,
):
if isinstance(minus, _Unspecified):
minus = plus
self.typ = typ
self.plus = plus
self.minus = minus
assert self.plus is None or self.plus >= 0.0
assert self.minus is None or self.minus >= 0.0
def __str__(self):
# Both bounds present
typ = self.typ
plus = self.plus
minus = self.minus
if plus == minus:
if plus is None:
return f"{typ:g} ± ∞"
elif self.__used_percent:
return f"{typ:g} ± {self.__used_percent[0]}%"
return f"{typ:g} ± {plus:g}"
# Only min bound present
elif self.plus is None:
return f"Toleranced({self.min_value:g} <= typ:{self.typ:g})"
# Only max bound present
elif self.minus is None:
return f"Toleranced(typ:{self.typ:g} <= {self.max_value:g})"
return f"Toleranced({self.min_value:g} <= {self.typ:g} <= {self.max_value:g})"
def __repr__(self):
return f"Toleranced({self.typ:g}, {self.plus:g}, {self.minus:g})"
@property
def max_value(self) -> float:
"""The maximum value of the tolerance range (typ + plus)."""
if self.plus is not None:
return self.typ + self.plus
raise ValueError("plus must be specified to compute max_value")
@property
def min_value(self) -> float:
"""The minimum value of the tolerance range (typ - minus)."""
if self.minus is not None:
return self.typ - self.minus
raise ValueError("minus must be specified to compute min_value")
[docs]
def center_value(self) -> float:
"""Calculate the geometric center of the range (midpoint between min and max)."""
return self.min_value + 0.5 * (self.max_value - self.min_value)
[docs]
def plus_percent(self) -> float:
"""Calculate the positive tolerance as a percentage of the typical value.
Returns:
Percentage value (e.g., 5.0 for 5%).
Raises:
ValueError: If plus is None or typ is zero.
"""
if self.plus is None:
raise ValueError("plus must be specified to compute tol+%(Toleranced)")
if self.typ == 0.0:
raise ValueError("typ() != 0.0 to compute tol+%(Toleranced)")
return 100.0 * self.plus / self.typ
[docs]
def minus_percent(self) -> float:
"""Calculate the negative tolerance as a percentage of the typical value.
Returns:
Percentage value (e.g., 5.0 for 5%).
Raises:
ValueError: If minus is None or typ is zero.
"""
if self.minus is None:
raise ValueError("minus must be specified to compute tol-%(Toleranced)")
if self.typ == 0.0:
raise ValueError("typ() != 0.0 to compute tol-%(Toleranced)")
return 100.0 * self.minus / self.typ
[docs]
def in_range(self, value: float | Toleranced) -> bool:
"""Check if a value or range is contained within this tolerance range.
Args:
value: A float or another Toleranced value to check.
Returns:
True if the value/range is completely within this range.
>>> tol = Toleranced(10, 1, 1) # 9 to 11
>>> tol.in_range(10.5)
True
>>> tol.in_range(Toleranced(10, 0.5, 0.5)) # 9.5 to 10.5
True
"""
if isinstance(value, Toleranced):
return (
value.min_value >= self.min_value and value.max_value <= self.max_value
)
elif isinstance(value, float | int):
return self.min_value <= value <= self.max_value
else:
raise ValueError("in_range() requires a Toleranced or float value.")
[docs]
def range(self) -> float:
"""Calculate the total range (max - min)."""
return self.max_value - self.min_value
def _full_tolerance(self):
"""Return True if typ, plus, and minus are all specified (not None). 0.0 is valid and means exact."""
return self.plus is not None and self.minus is not None
def __add__(self, other: Toleranced | float) -> Toleranced:
"""Add two toleranced values or a toleranced value and a float.
When adding two Toleranced values, tolerances propagate: the resulting
tolerance is the sum of the individual tolerances.
Args:
other: Another Toleranced value or a float.
Returns:
New Toleranced value representing the sum.
Raises:
ValueError: If either value has unbounded tolerances (None).
>>> a = Toleranced(10, 1, 1) # 9 to 11
>>> b = Toleranced(5, 0.5, 0.5) # 4.5 to 5.5
>>> c = a + b
>>> print(c)
15 ± 1.5
"""
if isinstance(other, Toleranced):
if (self.plus is None or self.minus is None) or (
other.plus is None or other.minus is None
):
raise ValueError(
"Toleranced() arithmetic operations require fully specified arguments (None is not allowed, 0.0 is valid)"
)
return Toleranced(
self.typ + other.typ,
self.plus + other.plus,
self.minus + other.minus,
)
elif isinstance(other, int | float):
return Toleranced(self.typ + other, self.plus, self.minus)
return NotImplemented
def __radd__(self, other: float) -> Toleranced:
return self.__add__(other)
def __sub__(self, other: Toleranced | float) -> Toleranced:
"""Subtract two toleranced values or subtract a float from a toleranced value.
When subtracting two Toleranced values, tolerances propagate: the resulting
tolerance accounts for the worst-case combination of bounds.
Args:
other: Another Toleranced value or a float to subtract.
Returns:
New Toleranced value representing the difference.
Raises:
ValueError: If either value has unbounded tolerances (None).
>>> v_in = Toleranced.percent(12, 5) # 12V ± 5%
>>> v_drop = Toleranced(0.7, 0.1, 0.1) # 0.7V ± 0.1V
>>> v_out = v_in - v_drop
>>> print(v_out)
11.3 ± 0.7
"""
if isinstance(other, Toleranced):
if (self.plus is None or self.minus is None) or (
other.plus is None or other.minus is None
):
raise ValueError(
"Toleranced() arithmetic operations require fully specified arguments (None is not allowed, 0.0 is valid)"
)
return Toleranced(
self.typ - other.typ,
self.plus + other.minus,
self.minus + other.plus,
)
elif isinstance(other, int | float):
return Toleranced(self.typ - other, self.plus, self.minus)
return NotImplemented
def __rsub__(self, other: float) -> Toleranced:
return Toleranced(other, 0.0, 0.0) - self
def __mul__(self, other: float | Toleranced) -> Toleranced:
"""Multiply two toleranced values or a toleranced value and a float.
When multiplying two Toleranced values, computes the product of all
combinations of min/max values to determine the resulting range.
Args:
other: Another Toleranced value or a float.
Returns:
New Toleranced value representing the product.
Raises:
ValueError: If either value has unbounded tolerances (None) when
multiplying two Toleranced values.
>>> r = Toleranced.percent(1000, 5) # 1kΩ ± 5%
>>> i = Toleranced(0.001, 0.0001, 0.0001) # 1mA ± 0.1mA
>>> power = r * i * i # P = I²R
"""
if isinstance(other, Toleranced):
if (self.plus is None or self.minus is None) or (
other.plus is None or other.minus is None
):
raise ValueError(
"Toleranced() arithmetic operations require fully specified arguments (None is not allowed, 0.0 is valid)"
)
typ = self.typ * other.typ
variants = [
self.min_value * other.min_value,
self.min_value * other.max_value,
self.max_value * other.min_value,
self.max_value * other.max_value,
]
plus = max(variants) - typ
minus = typ - min(variants)
return Toleranced(typ, plus, minus)
elif isinstance(other, int | float):
plus = abs(self.plus * other) if self.plus is not None else None
minus = abs(self.minus * other) if self.minus is not None else None
return Toleranced(self.typ * other, plus, minus)
return NotImplemented
def __rmul__(self, other: float) -> Toleranced:
return self.__mul__(other)
def __truediv__(self, other: Toleranced | float) -> Toleranced:
"""Divide two toleranced values or divide a toleranced value by a float.
When dividing two Toleranced values, computes the inverse of the
denominator and multiplies. Division by zero is detected and raises an error.
Args:
other: Another Toleranced value or a float to divide by.
Returns:
New Toleranced value representing the quotient.
Raises:
ValueError: If either value has unbounded tolerances (None) when
dividing two Toleranced values, or if dividing by a negative float.
ZeroDivisionError: If the denominator range includes zero.
>>> v = Toleranced.percent(3.3, 5) # 3.3V ± 5%
>>> r = Toleranced.percent(1000, 1) # 1kΩ ± 1%
>>> i = v / r # Current in amps
"""
if isinstance(other, Toleranced):
if (self.plus is None or self.minus is None) or (
other.plus is None or other.minus is None
):
raise ValueError(
"Toleranced() arithmetic operations require fully specified arguments (None is not allowed, 0.0 is valid)"
)
if other.in_range(0.0):
raise ZeroDivisionError("Cannot divide by zero for Toleranced values.")
typ = 1.0 / other.typ
inv = Toleranced(
typ, 1.0 / other.min_value - typ, typ - 1.0 / other.max_value
)
return self * inv
elif isinstance(other, int | float):
if other == 0:
raise ZeroDivisionError("Cannot divide by zero.")
elif other < 0:
raise ValueError("Cannot divide Toleranced by negative value.")
plus = self.plus / other if self.plus is not None else None
minus = self.minus / other if self.minus is not None else None
return self.__class__(self.typ / other, plus, minus)
return NotImplemented
def __rtruediv__(self, other: float) -> Toleranced:
return Toleranced.exact(other) / self
[docs]
def apply(self, f: Callable[[float], float]) -> Self:
"""Apply a function to the toleranced value.
Applies the given function to min, typ, and max values and creates
a new Toleranced from the results. Useful for non-linear transformations.
Args:
f: A function that takes a float and returns a float.
Returns:
New Toleranced value with the function applied.
>>> import math
>>> v = Toleranced(4.0, 0.5, 0.5) # 3.5 to 4.5
>>> sqrt_v = v.apply(math.sqrt) # sqrt applied to range
>>> print(f"{sqrt_v.min_value:.2f} to {sqrt_v.max_value:.2f}")
1.87 to 2.12
"""
tv = f(self.typ)
minv = f(self.min_value)
maxv = f(self.max_value)
return self.min_typ_max(minv, tv, maxv)
[docs]
@classmethod
def min_typ_max(
cls, min_val: float | None, typ_val: float | None, max_val: float | None
) -> Self:
"""Create a Toleranced value from min, typ, and max values.
At least two of the three values must be specified. If typ is not
provided, it will be calculated as the midpoint of min and max.
Args:
min_val: Minimum value (or None if unbounded below).
typ_val: Typical/nominal value (or None to use midpoint).
max_val: Maximum value (or None if unbounded above).
Returns:
New Toleranced value.
Raises:
ValueError: If fewer than two values are specified, or if
min > typ, typ > max, or min > max.
>>> # Supply voltage specification
>>> vdd = Toleranced.min_typ_max(2.7, 3.3, 3.6)
>>> print(vdd)
Toleranced(2.7 <= 3.3 <= 3.6)
>>> # Temperature range without typical
>>> temp = Toleranced.min_typ_max(-40, None, 85)
>>> print(f"Center temp: {temp.typ}°C")
Center temp: 22.5°C
"""
if typ_val is not None and min_val is not None and max_val is not None:
if typ_val < min_val or max_val < typ_val:
raise ValueError("min-typ-max() should be [min] <= [typ] <= [max]")
return cls(typ_val, max_val - typ_val, typ_val - min_val)
elif min_val is not None and max_val is not None:
if max_val < min_val:
raise ValueError("min-typ-max() should have max >= min.")
t = min_val + 0.5 * (max_val - min_val)
return cls(t, max_val - t, t - min_val)
elif typ_val is not None and min_val is not None:
if typ_val < min_val:
raise ValueError("min-typ-max() should have min <= typ")
return cls(typ_val, None, typ_val - min_val)
elif typ_val is not None and max_val is not None:
if typ_val > max_val:
raise ValueError("min-typ-max() should have typ <= max")
return cls(typ_val, max_val - typ_val, None)
else:
raise ValueError(
"min-typ-max() should have at least two of min, typ, max values"
)
[docs]
@classmethod
def min_max(cls, min_val: float, max_val: float) -> Self:
"""Create a Toleranced defined by absolute min and max values.
The typical value will be set to the midpoint of min and max.
Args:
min_val: Minimum value.
max_val: Maximum value.
Returns:
New Toleranced value.
>>> # Operating frequency range
>>> freq = Toleranced.min_max(10e6, 20e6) # 10-20 MHz
>>> print(f"Center: {freq.typ/1e6} MHz")
Center: 15.0 MHz
"""
return cls.min_typ_max(min_val, None, max_val)
[docs]
@classmethod
def min_typ(cls, min_val: float, typ_val: float) -> Self:
"""Create a Toleranced defined by an absolute minimum and typical value.
The maximum is unbounded (None).
Args:
min_val: Minimum value.
typ_val: Typical/nominal value.
Returns:
New Toleranced value with unbounded maximum.
>>> # Minimum load current
>>> i_load = Toleranced.min_typ(0.1, 0.5) # At least 100mA, typ 500mA
"""
return cls.min_typ_max(min_val, typ_val, None)
[docs]
@classmethod
def typ_max(cls, typ_val: float, max_val: float) -> Self:
"""Create a Toleranced defined by a typical and absolute maximum value.
The minimum is unbounded (None).
Args:
typ_val: Typical/nominal value.
max_val: Maximum value.
Returns:
New Toleranced value with unbounded minimum.
>>> # Maximum current consumption
>>> i_max = Toleranced.typ_max(50e-6, 100e-6) # Typ 50µA, max 100µA
"""
return cls.min_typ_max(None, typ_val, max_val)
[docs]
@classmethod
def percent(
cls, typ: float, plus: float, minus: float | _Unspecified = _UNSPECIFIED
) -> Self:
"""Create a Toleranced based on symmetric or asymmetric percentages of the typical value.
If the `minus` argument is unspecified, the range will be symmetric.
Args:
typ: Typical/nominal value.
plus: Positive tolerance as a percentage (0-100).
minus: Negative tolerance as a percentage (0-100).
If unspecified, uses the same value as plus.
Returns:
New Toleranced value.
Raises:
ValueError: If plus or minus is not in the range 0-100.
>>> # 10kΩ resistor with ±5% tolerance
>>> r1 = Toleranced.percent(10_000, 5)
>>> print(r1)
10000 ± 5%
>>> # Voltage with asymmetric tolerance
>>> v = Toleranced.percent(3.3, 10, 5) # +10%, -5%
>>> print(f"{v.min_value}V to {v.max_value}V")
3.135V to 3.63V
"""
if isinstance(minus, _Unspecified):
minus = plus
if not (0.0 <= plus <= 100.0):
raise ValueError("tol+ must be in range 0.0 <= tol+ <= 100.0")
if not (0.0 <= minus <= 100.0):
raise ValueError("tol- must be in range 0.0 <= tol- <= 100.0")
abstyp = abs(typ)
aplus = abstyp * plus / 100.0
aminus = abstyp * minus / 100.0
tol = cls(typ, aplus, aminus)
tol.__used_percent = plus, minus
return tol
[docs]
@classmethod
def sym(cls, typ: float, plusminus: float) -> Self:
"""Create a Toleranced with symmetric bounds.
Effectively an alias of the two-argument constructor.
Args:
typ: Typical/nominal value.
plusminus: Symmetric tolerance (both plus and minus).
Returns:
New Toleranced value with symmetric tolerance.
>>> # Component with ±0.1mm tolerance
>>> dim = Toleranced.sym(5.0, 0.1) # 4.9 to 5.1 mm
"""
return cls(typ, plusminus)
@overload
@classmethod
def exact(cls, typ: float) -> Self: ...
@overload
@classmethod
def exact(cls, typ: Toleranced) -> Toleranced: ...
[docs]
@classmethod
def exact(cls, typ: float | Toleranced) -> Self | Toleranced:
"""Create a Toleranced with zero tolerance (exact value).
If given a Toleranced value, returns it unchanged. If given a float,
creates a Toleranced with plus=0 and minus=0.
Args:
typ: Value to wrap as exact, or existing Toleranced.
Returns:
Toleranced value with zero tolerance, or the input if already Toleranced.
>>> # Ideal reference voltage (no tolerance)
>>> v_ref = Toleranced.exact(2.5)
>>> print(v_ref)
2.5 ± 0
"""
if isinstance(typ, Toleranced):
return typ
return cls(typ, 0.0)