""" PhysicalQuantity class definition
"""
from __future__ import annotations
import copy
import json
import numpy as np
from .unit import (
PhysicalUnit, UnitError, base_names, convertvalue, findunit,
isphysicalunit, unit_table,
)
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .dBQuantity import dBQuantity
__all__ = ['PhysicalQuantity', 'PhysicalUnit', 'UnitError', 'unit_table']
[docs]class PhysicalQuantity:
"""Represents a physical quantity with a value and a unit.
Supports arithmetic operations (+, -, *, /, //, **), comparisons,
conversions between compatible units, and some mathematical functions.
Attributes
----------
value : int | float | complex
The numerical value of the quantity.
unit : PhysicalUnit
The unit associated with the value.
format : str, optional
A format string used for converting the value to a string. Defaults to ''.
annotation : str, optional
An optional annotation or description for the quantity. Defaults to ''.
__array_priority__ : int
Ensures NumPy ufuncs are handled correctly (set to 1000).
"""
__array_priority__: int = 1000 # Ensure numpy compatibility over lists etc.
format: str = '' # Display format for value -> string conversion
annotation: str = '' # Optional annotation for the Quantity
value: int | float | complex # Numerical value of the quantity
unit: PhysicalUnit # The associated PhysicalUnit object
[docs] def __init__(self, value: int | float | complex, unit: str | PhysicalUnit, annotation: str = ''):
"""Initializes a PhysicalQuantity.
Parameters
----------
value : int | float | complex
The numerical value of the quantity.
unit : str | PhysicalUnit
The unit of the quantity, either as a string name or a PhysicalUnit object.
annotation : str, optional
An optional annotation for the quantity. Defaults to ''.
Examples
--------
>>> from PhysicalQuantities import PhysicalQuantity
>>> v = PhysicalQuantity(1, 'V')
>>> v
1 V
"""
try:
ip = get_ipython() # type: ignore
self.ptformatter = ip.display_formatter.formatters['text/plain'] # type: ignore
except NameError:
self.ptformatter = None
self.value = value
self.annotation = annotation
self.unit = findunit(unit)
[docs] def __dir__(self) -> list[str]:
"""Lists available attributes, including units for conversion via attribute access.
Extends the default `dir()` list with names of units from `unit_table`
that share the same base unit dimension as the current quantity. This allows
tab completion for unit conversions like `quantity.mV`.
Returns
-------
list[str]
A list of attribute names, including compatible unit names.
"""
ulist = list(super().__dir__())
u = unit_table.values()
for _u in u:
if isphysicalunit(_u):
if str(_u.baseunit) is str(self.unit.baseunit):
ulist.append(_u.name)
return ulist
[docs] def __getattr__(self, attr) -> int | float | complex | PhysicalQuantity:
"""Converts to a different scaling prefix of the same unit via attribute access.
Allows retrieving the quantity expressed in a unit with a different scaling
prefix (e.g., `quantity.mV`).
If the attribute name ends with an underscore (`_`), the numerical value
(in the specified unit) is returned without the unit (e.g., `quantity.mV_`).
Accessing just `_` returns the original numerical value.
This method is called only if standard attribute lookup fails.
Parameters
----------
attr : str
The attribute name, expected to be a unit name (optionally with a
trailing `_`) or just `_`.
Returns
-------
int | float | complex | PhysicalQuantity
The quantity converted to the specified unit scaling, or the numerical
value if the attribute ends with `_`.
Raises
------
AttributeError
If `attr` is not a unit name found in `unit_table`, if the found unit
is incompatible with the quantity's unit, or if the attribute syntax
is otherwise invalid.
Examples
--------
>>> from PhysicalQuantities import q
>>> a = 2 * q.mm
>>> a._
2
>>> a.mm_
2
>>> a.m_ # Converts mm to m and returns the value
0.002
>>> a.m # Converts mm to m and returns the PhysicalQuantity
0.002 m
>>> a.base # Accesses the .base property, does not go through __getattr__
0.002 m
>>> a.kg # doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
...
AttributeError: Unit 'kg' is not compatible with unit 'mm'
"""
# Check if it's the special case for accessing the value directly
if attr == '_':
return self.value
dropunit = (attr[-1] == '_')
attr_unit_name = attr.strip('_')
# Optimization: check unit_table *first*. If not there, it's not a unit attr.
if attr_unit_name not in unit_table:
# If it wasn't found in unit_table, raise standard AttributeError
# This allows access to normal methods/properties like .base, .value etc.
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{attr}'")
# If it IS in unit_table, proceed with unit conversion logic
try:
attrunit = unit_table[attr_unit_name]
# Check for dimensional compatibility BEFORE conversion using the 'powers' array
if self.unit.powers != attrunit.powers:
raise AttributeError(f"Unit '{attr_unit_name}' is not compatible with unit '{self.unit.name}' (dimension mismatch)")
# If compatible, perform the conversion
converted_quantity = self.to(attrunit.name)
if dropunit:
return converted_quantity.value
else:
return converted_quantity
except KeyError:
raise AttributeError(f'Unit {attr} not found')
[docs] def __getitem__(self, key):
"""Allows indexing if the underlying value is an array or list.
Parameters
----------
key : slice | int
The index or slice.
Returns
-------
PhysicalQuantity
A new PhysicalQuantity containing the indexed/sliced value.
Raises
------
AttributeError
If the underlying value does not support indexing.
Examples
--------
>>> from PhysicalQuantities import PhysicalQuantity
>>> import numpy as np
>>> q_array = PhysicalQuantity(np.array([1, 2, 3]), 'm')
>>> q_array[1]
2 m
>>> q_array[0:2]
[1 2] m
"""
if isinstance(self.value, np.ndarray) or isinstance(self.value, list):
return self.__class__(self.value[key], self.unit)
raise AttributeError('Not a PhysicalQuantity array or list', list)
[docs] def __setitem__(self, key, value):
"""Allows item assignment if the underlying value is an array or list.
The assigned value must be a PhysicalQuantity and will be converted to the
unit of this quantity before assignment.
Parameters
----------
key : slice | int
The index or slice where the value should be assigned.
value : PhysicalQuantity
The PhysicalQuantity instance to assign.
Raises
------
AttributeError
If the underlying value does not support item assignment, or if
`value` is not a PhysicalQuantity.
UnitError
If the unit of `value` is not compatible with this quantity's unit.
Examples
--------
>>> from PhysicalQuantities import PhysicalQuantity, q
>>> import numpy as np
>>> q_array = PhysicalQuantity(np.array([1.0, 2.0, 3.0]), 'm')
>>> q_array[0] = 50 * q.cm
>>> q_array
[0.5 2. 3. ] m
"""
if not isinstance(value, PhysicalQuantity):
raise AttributeError('Not a Physical Quantity')
if isinstance(self.value, np.ndarray) or isinstance(self.value, list):
self.value[key] = value.to(str(self.unit)).value
return self.__class__(self.value[key], self.unit)
raise AttributeError('Not a PhysicalQuantity array or list', list)
[docs] def __len__(self):
"""Returns the length if the underlying value is an array or list.
Returns
-------
int
The length of the underlying value array/list.
Raises
------
TypeError
If the underlying value does not have a defined length.
Examples
--------
>>> from PhysicalQuantities import PhysicalQuantity
>>> import numpy as np
>>> q_list = PhysicalQuantity([1, 2, 3], 's')
>>> len(q_list)
3
"""
if isinstance(self.value, np.ndarray) or isinstance(self.value, list):
return len(self.value)
raise TypeError('Object of type %s has no len()' % type(self.value))
def _ipython_key_completions_(self):
"""Provides key completions for IPython environments (used for `obj['<tab>]`)."""
return self.unit_table.keys()
@property
def np(self) -> np.ndarray:
""" Return a numpy array with the unit as metadata attribute
Returns
-------
np.ndarray
dtype.metadata = dict(unit=PhysicalUnit)
"""
if isinstance(self.value, np.ndarray):
array = self.value
metadata = dict(unit=str(self.unit))
dtype = np.dtype(str(array.dtype), metadata=metadata)
return array.astype(dtype)
array = np.array(self.value)
metadata = dict(unit=str(self.unit))
dtype = np.dtype(dtype=array.dtype, metadata=metadata)
array = array.astype(dtype)
return array
@property
def dB(self) -> dBQuantity:
"""Converts the quantity to a dB representation (if applicable).
Uses heuristics to determine whether to use 10*log10 (for power-like units
containing 'W') or 20*log10 (for amplitude-like units).
Returns
-------
dBQuantity
The quantity expressed in decibels relative to its unit (e.g., dBV, dBW).
Examples
--------
>>> from PhysicalQuantities import q
>>> (10 * q.V).dB
20.0 dBV
>>> (10 * q.W).dB
10.0 dBW
"""
from .dBQuantity import PhysicalQuantity_to_dBQuantity
return PhysicalQuantity_to_dBQuantity(self)
[docs] def rint(self):
"""Rounds the numerical value(s) to the nearest integer.
Applies `numpy.rint` to the underlying value.
Returns
-------
PhysicalQuantity
A new quantity with the value(s) rounded to the nearest integer.
"""
value = np.rint(self.value)
return self.__class__(value, self.unit)
[docs] def __str__(self):
"""Returns the string representation 'value unit'.
Uses IPython's float precision settings if available via `self.ptformatter`
and no specific `self.format` is set.
Returns
-------
str
The string representation of the quantity.
"""
if self.ptformatter is not None and self.format == '' and isinstance(self.value, float): # pragma: no cover
# %precision magic only works for floats
fmt = self.ptformatter.float_format
return u"%s %s" % (fmt % self.value, str(self.unit))
return '{0:{format}} {1}'.format(self.value, str(self.unit), format=self.format)
[docs] def __complex__(self):
"""Converts the quantity to a complex number after converting to base units.
Returns
-------
complex
The numerical value of the quantity in base units as a complex number.
"""
return self.base.value
[docs] def __float__(self):
"""Converts the quantity to a float after converting to base units.
Returns
-------
float
The numerical value of the quantity in base units as a float.
"""
return self.base.value
[docs] def __repr__(self):
"""Returns the canonical string representation (delegates to `__str__`)."""
return self.__str__()
def _repr_markdown_(self):
"""Returns a Markdown representation for IPython/Jupyter environments.
Formats the output as 'value unit' using Markdown for the unit.
Uses LaTeX via Sympy for Sympy values if detected.
Respects IPython float formatting if available.
Returns
-------
str
Markdown formatted string.
"""
if self.ptformatter is not None and self.format == '' and isinstance(self.value, float): # pragma: no cover
# %precision magic only works for floats
fmt = self.ptformatter.float_format
return u"%s %s" % (fmt % self.value, self.unit._repr_markdown_())
if str(type(self.value)).find('sympy') > 0:
from sympy import printing # type: ignore
return '${0}$ {1}'.format(printing.latex(self.value), self.unit.markdown)
return '{0:{format}} {1}'.format(self.value, self.unit.markdown, format=self.format)
def _repr_latex_(self):
"""Returns a LaTeX representation for IPython/Jupyter environments.
Currently delegates to `_repr_markdown_`.
Returns
-------
str
LaTeX formatted string (via Markdown).
"""
return self._repr_markdown_()
def _sum(self, other, sign1, sign2):
"""Internal helper method for addition (`sign2`=1) and subtraction (`sign2`=-1).
Performs `sign1 * self + sign2 * other`, converting `other` to the unit of `self`.
Parameters
----------
other : PhysicalQuantity
The quantity to add or subtract.
sign1 : int | float
Multiplier for self (typically 1).
sign2 : int | float
Multiplier for other (+1 for add, -1 for subtract).
Returns
-------
PhysicalQuantity
The result of the operation, in the units of `self`.
Raises
-------
UnitError
If `other` is not a PhysicalQuantity or if units are incompatible.
"""
if not isinstance(other, PhysicalQuantity):
raise UnitError(f'Incompatible types {type(self)} and {type(other)}')
new_value = sign1 * self.value + \
sign2 * other.value * other.unit.conversion_factor_to(self.unit)
return self.__class__(new_value, self.unit)
[docs] def __add__(self, other):
"""Adds another PhysicalQuantity. Units must be compatible."""
if isinstance(other, np.ndarray):
metadata = other.dtype.metadata
if metadata and 'unit' in metadata:
other = PhysicalQuantity(other, metadata['unit'])
return self._sum(other, 1, 1)
__radd__ = __add__
[docs] def __sub__(self, other):
"""Subtracts another PhysicalQuantity. Units must be compatible."""
return self._sum(other, 1, -1)
[docs] def __rsub__(self, other):
"""Subtracts this quantity from another (`other - self`). Units must be compatible."""
# Check if other is a PhysicalQuantity
if isinstance(other, PhysicalQuantity):
# Delegate to other's subtraction method
return other._sum(self, 1, -1) # other + (-1)*self
else:
# Subtraction of PhysicalQuantity from a scalar is ambiguous
raise TypeError(f"Unsupported operand type(s) for -: '{type(other).__name__}' and '{type(self).__name__}'")
[docs] def __mul__(self, other):
"""Multiplies by a scalar or another PhysicalQuantity.
- `self * scalar`: Scales the value, keeps the unit.
- `self * other_quantity`: Multiplies values and units.
If the resulting unit is dimensionless, returns a scaled scalar.
"""
if not isinstance(other, PhysicalQuantity):
# Handle quantity * scalar or quantity * unit
if isphysicalunit(other):
# quantity * unit
value = self.value # Value remains the same
unit = self.unit * other # Multiply units
if unit.is_dimensionless:
# If result is dimensionless, return scaled scalar value
return value * unit.factor
else:
# Return new quantity with combined unit
return self.__class__(value, unit)
else:
# Assume quantity * scalar (or list, array, complex, etc.)
# Revert to simpler multiplication, relying on self.value's behavior
# This handles numeric types, lists, arrays via duck typing / NumPy overload
return self.__class__(self.value * other, self.unit)
else:
# Handle quantity * quantity
value = self.value * other.value
unit = self.unit * other.unit
if unit.is_dimensionless:
return value * unit.factor
else:
return self.__class__(value, unit)
__rmul__ = __mul__
[docs] def __floordiv__(self, other):
"""Performs floor division (`self // other`).
- `self // scalar`: Floor divides value, keeps the unit.
- `self // other_quantity`: Floor divides values, divides units.
If the resulting unit is dimensionless, returns a scaled scalar.
Parameters
----------
other : number | PhysicalQuantity
The divisor.
Returns
-------
PhysicalQuantity | number
The result of the floor division.
"""
if not isinstance(other, PhysicalQuantity):
return self.__class__(self.value // other, self.unit)
value = self.value // other.value
unit = self.unit // other.unit
if unit.is_dimensionless:
return value * unit.factor
else:
return self.__class__(value, unit)
[docs] def __rfloordiv__(self, other):
"""Performs reverse floor division (`other // self`).
`other` must be a scalar. The resulting unit is the reciprocal of `self.unit`.
Parameters
----------
other : number
The dividend (must be a scalar).
Returns
-------
PhysicalQuantity
The result with reciprocal units.
Raises
------
TypeError
If `other` is a PhysicalQuantity.
"""
if isinstance(other, PhysicalQuantity):
# Floor division between two quantities is handled by __floordiv__
# Reverse floor division is not defined between two quantities in this way.
raise TypeError(f"Unsupported operand type(s) for //: '{type(other).__name__}' and '{type(self).__name__}'")
else:
# Handle scalar // quantity
value = other // self.value
reciprocal_unit = 1 / self.unit
return self.__class__(value, reciprocal_unit)
[docs] def __div__(self, other):
"""Performs true division (`self / other`) (Python 2 style).
See `__truediv__`.
"""
if not isinstance(other, PhysicalQuantity):
return self.__class__(self.value / other, self.unit)
value = self.value / other.value
unit = self.unit / other.unit
if unit.is_dimensionless:
return value * unit.factor
else:
return self.__class__(value, unit)
[docs] def __rdiv__(self, other):
"""Performs reverse true division (`other / self`) (Python 2 style).
See `__rtruediv__`.
"""
# This method primarily handles scalar / quantity
if isinstance(other, PhysicalQuantity):
# Division between two quantities is handled by __div__ / __truediv__.
# Reverse division is not defined between two quantities in this way.
raise TypeError(f"Unsupported operand type(s) for /: '{type(other).__name__}' and '{type(self).__name__}'")
else:
# Handle scalar / quantity
value = other / self.value
reciprocal_unit = 1 / self.unit
return self.__class__(value, reciprocal_unit)
__truediv__ = __div__
__rtruediv__ = __rdiv__ # Alias __rtruediv__ to the corrected __rdiv__
[docs] def __round__(self, ndigits=0):
"""Rounds the numerical value to a given number of decimal places.
Applies `round()` or `numpy.round()` to the value.
Parameters
----------
ndigits : int, optional
Number of decimal places to round to (default is 0).
Returns
-------
PhysicalQuantity
A new quantity with the rounded value.
"""
if isinstance(self.value, np.ndarray):
return self.__class__(np.round(self.value, ndigits), self.unit)
else:
return self.__class__(round(self.value, ndigits), self.unit)
[docs] def __pow__(self, other):
"""Raises the quantity to a power (`self ** other`).
The exponent `other` must be a dimensionless scalar.
Parameters
----------
other : number
The exponent (must be dimensionless).
Returns
-------
PhysicalQuantity
The quantity raised to the power `other`.
Raises
-------
UnitError
If `other` is a PhysicalQuantity (exponent must be scalar).
"""
if isinstance(other, PhysicalQuantity):
raise UnitError('Exponents must be dimensionless not of unit %s' % other.unit)
return self.__class__(pow(self.value, other), pow(self.unit, other))
[docs] def __rpow__(self, other):
"""Raises a scalar base to the power of this quantity (`other ** self`).
This operation is only valid if `self` is dimensionless.
Parameters
----------
other : number
The base.
Raises
-------
UnitError
If `self` is not dimensionless.
"""
raise UnitError('Exponents must be dimensionless, not of unit %s' % self.unit)
[docs] def __abs__(self):
"""Returns the quantity with the absolute value.
Applies `abs()` to the numerical value.
Returns
-------
PhysicalQuantity
A new quantity with the absolute value.
"""
return self.__class__(abs(self.value), self.unit)
[docs] def __pos__(self):
"""Returns the quantity itself (unary plus).
Returns
-------
PhysicalQuantity
The quantity itself (`+self`).
"""
if isinstance(self.value, np.ndarray):
return self.__class__(np.ndarray.__pos__(self.value), self.unit)
return self.__class__(self.value, self.unit)
[docs] def __neg__(self):
"""Returns the quantity with the negated value (unary minus).
Returns
-------
PhysicalQuantity
A new quantity with the negated value (`-self`).
"""
if isinstance(self.value, np.ndarray):
return self.__class__(np.ndarray.__neg__(self.value), self.unit)
return self.__class__(-self.value, self.unit)
# __nonzero__ is Python 2. Use __bool__ in Python 3.
[docs] def __bool__(self) -> bool:
"""Tests if the quantity's value is non-zero (Python 3 boolean context).
This method provides the standard boolean interpretation used in contexts like `if quantity:`.
Returns
-------
bool
`True` if the value is non-zero, `False` otherwise.
For array values, tests if *any* element is non-zero using `numpy.any()`.
"""
if isinstance(self.value, np.ndarray):
# Correct Python 3 boolean context: check if *any* element is non-zero
# Explicitly cast numpy.bool_ to standard Python bool
return bool(np.any(self.value))
# Standard boolean conversion for scalars
return bool(self.value)
[docs] def __gt__(self, other):
"""Tests if this quantity is greater than another (`self > other`).
Compares values after converting both quantities to base units.
Parameters
----------
other : PhysicalQuantity
The quantity to compare against.
Returns
-------
bool
`True` if `self` is strictly greater than `other`.
Raises
-------
TypeError
If `other` is not a `PhysicalQuantity`.
UnitError
If units are incompatible.
"""
if not isinstance(other, PhysicalQuantity):
# Raise TypeError for comparison with incompatible types
raise TypeError(f"\'>' not supported between instances of '{type(self).__name__}' and '{type(other).__name__}'")
# Check dimensional compatibility first
if self.unit.powers != other.unit.powers:
raise UnitError(f'Cannot compare quantities with incompatible units: {self.unit} and {other.unit}')
# Compare values in base units
return self.base.value > other.base.value
[docs] def __ge__(self, other):
"""Tests if this quantity is greater than or equal to another (`self >= other`).
Compares values after converting both quantities to base units.
Parameters
----------
other : PhysicalQuantity
The quantity to compare against.
Returns
-------
bool
`True` if `self` is greater than or equal to `other`.
Raises
-------
TypeError
If `other` is not a `PhysicalQuantity`.
UnitError
If units are incompatible.
"""
if not isinstance(other, PhysicalQuantity):
raise TypeError(f"\'>=' not supported between instances of '{type(self).__name__}' and '{type(other).__name__}'")
if self.unit.powers != other.unit.powers:
raise UnitError(f'Cannot compare quantities with incompatible units: {self.unit} and {other.unit}')
return self.base.value >= other.base.value
[docs] def __lt__(self, other):
"""Tests if this quantity is less than another (`self < other`).
Compares values after converting both quantities to base units.
Parameters
----------
other : PhysicalQuantity
The quantity to compare against.
Returns
-------
bool
`True` if `self` is strictly less than `other`.
Raises
-------
TypeError
If `other` is not a `PhysicalQuantity`.
UnitError
If units are incompatible.
"""
if not isinstance(other, PhysicalQuantity):
raise TypeError(f"\'<' not supported between instances of '{type(self).__name__}' and '{type(other).__name__}'")
if self.unit.powers != other.unit.powers:
raise UnitError(f'Cannot compare quantities with incompatible units: {self.unit} and {other.unit}')
return self.base.value < other.base.value
[docs] def __le__(self, other):
"""Tests if this quantity is less than or equal to another (`self <= other`).
Compares values after converting both quantities to base units.
Parameters
----------
other : PhysicalQuantity
The quantity to compare against.
Returns
-------
bool
`True` if `self` is less than or equal to `other`.
Raises
-------
TypeError
If `other` is not a `PhysicalQuantity`.
UnitError
If units are incompatible.
"""
if not isinstance(other, PhysicalQuantity):
raise TypeError(f"\'<=' not supported between instances of '{type(self).__name__}' and '{type(other).__name__}'")
if self.unit.powers != other.unit.powers:
raise UnitError(f'Cannot compare quantities with incompatible units: {self.unit} and {other.unit}')
return self.base.value <= other.base.value
[docs] def __eq__(self, other):
"""Tests if two quantities are equal (`self == other`).
Compares values after converting both quantities to base units.
Returns `False` if `other` is not a `PhysicalQuantity` or if units
are dimensionally incompatible.
Parameters
----------
other : object
The object to compare against.
Returns
-------
bool
`True` if `self` is equal to `other`.
"""
if not isinstance(other, PhysicalQuantity):
# According to Python data model, __eq__ should return False for different types
return False
# Check dimensional compatibility
if self.unit.powers != other.unit.powers:
# Dimensionally incompatible objects cannot be equal
return False
# Compare values in base units (consider np.isclose for floats if needed)
return self.base.value == other.base.value
[docs] def __ne__(self, other):
"""Tests if two quantities are not equal (`self != other`).
Parameters
----------
other : object
The object to compare against.
Returns
-------
bool
`True` if `self` is not equal to `other`.
"""
# Delegate to __eq__
return not self.__eq__(other)
[docs] def convert(self, unit):
"""Converts the quantity *in-place* to a different unit.
Adjusts the value and updates the unit attribute. The new unit must be
compatible with the original unit.
Parameters
----------
unit : str | PhysicalUnit
The target unit to convert to.
Raises
-------
UnitError
If the target unit is not compatible.
"""
unit = findunit(unit)
self.value = convertvalue(self.value, self.unit, unit)
self.unit = unit
@staticmethod
def _round(x):
"""Custom rounding function (rounds towards zero).
Used internally by the `to` method for multi-unit conversions.
Equivalent to `np.trunc`.
"""
if np.greater(x, 0.):
return np.floor(x)
else:
return np.ceil(x)
[docs] def __deepcopy__(self, memo: dict) -> PhysicalQuantity:
"""Creates a deep copy of the PhysicalQuantity instance.
Ensures that the numerical `value` is also deep-copied, crucial for
mutable types like numpy arrays.
Parameters
----------
memo : dict
The memo dictionary used by `copy.deepcopy`.
Returns
-------
PhysicalQuantity
A new, independent copy of the quantity.
"""
new_value = copy.deepcopy(self.value)
new_instance = self.__class__(new_value, self.unit)
memo[id(self)] = new_instance
return new_instance
@property
def autoscale(self) -> PhysicalQuantity:
"""Rescales the quantity to a unit with a 'reasonable' prefix.
Attempts to find a unit prefix (like k, m, n, etc.) such that the numerical
value falls roughly between 1 and 1000 (or 0.001 and 1 for scales < 1).
Works best for simple units with standard SI prefixes defined.
Returns the original quantity if no suitable rescaling is found.
Returns
-------
PhysicalQuantity
A new quantity object, possibly rescaled.
Examples
--------
>>> from PhysicalQuantities import PhysicalQuantity, q
>>> (4e-9 * q.F).autoscale
4.0 nF
>>> (0.005 * q.V).autoscale
5.0 mV
>>> (12345 * q.m).autoscale
12.345 km
"""
if len(self.unit.names) == 1:
b = self.base
n = np.log10(abs(b.value))
# we want to be between 0..999
_scale = np.floor(n)
# now search for unit
for i in unit_table:
u = unit_table[i]
if isinstance(u, PhysicalUnit):
if u.baseunit is self.unit.baseunit:
f = np.log10(u.factor) - _scale
if (f > -3) and (f < 1):
return self.to(i)
return self
[docs] def to(self, *units):
"""Converts the quantity to the specified unit(s).
Parameters
----------
*units : str | PhysicalUnit
One or more target units (names or `PhysicalUnit` objects).
Returns
-------
PhysicalQuantity | tuple[PhysicalQuantity, ...]
- If one unit is specified: A new `PhysicalQuantity` object representing
the value in that unit.
- If multiple units are specified: A tuple of `PhysicalQuantity` objects,
one for each specified unit. The values are calculated such that their
sum equals the original quantity, and intermediate values (except the
last) are integers (using `_round`). This is useful for irregular unit
systems like hours/minutes/seconds.
Raises
-------
UnitError
If any target unit is incompatible with the quantity's current unit.
Examples
--------
>>> from PhysicalQuantities import PhysicalQuantity, q
>>> b = PhysicalQuantity(4, 'J/s')
>>> b.to('W')
4.0 W
>>> t = PhysicalQuantity(3661, 's')
>>> h, m, s = t.to('h', 'min', 's') # Note the order matters for tuple unpacking
>>> h, m, s # doctest: +SKIP
(1 h, 1 min, 1.0 s)
>>> t = PhysicalQuantity(1000, 's')
>>> t.to('h', 'min', 's') # doctest: +SKIP
(0 h, 16 min, 40.0 s)
Notes
-----
When multiple units are provided, they are processed in order of magnitude
(largest first, based on sorting internally). The internal `_round` method
(truncation) is used to determine integer parts for intermediate units.
Floating point inaccuracies might occur.
"""
units = list(map(findunit, units))
if len(units) == 1:
unit = units[0]
value = convertvalue(self.value, self.unit, unit)
return self.__class__(value, unit)
else:
units.sort()
result = []
value = self.value
unit = self.unit
for i in range(len(units)-1, -1, -1):
value *= unit.conversion_factor_to(units[i])
if i == 0:
rounded = value
else:
rounded = self._round(value)
result.append(self.__class__(rounded, units[i]))
value = value - rounded
unit = units[i]
return tuple(result)
@property
def base(self) -> PhysicalQuantity:
"""Converts the quantity to its equivalent representation in SI base units.
Calculates the value in terms of the fundamental SI base units (kg, m, s, A, K, mol, cd)
and constructs the corresponding unit string.
Handles units with offsets (like temperature scales) correctly during value conversion.
Returns
-------
PhysicalQuantity
A new quantity object expressed in SI base units.
Examples
--------
>>> from PhysicalQuantities import PhysicalQuantity, q
>>> (1 * q.km).base
1000.0 m
>>> (1 * q.V).base
1.0 kg*m**2*s**-3*A**-1
>>> q.PhysicalQuantity(0, 'degC').base # 0 degC -> 273.15 K
273.15 K
>>> q.PhysicalQuantity(32, 'degF').base # 32 degF -> 273.15 K
273.15 K
"""
# Correct conversion to base: value * factor + offset
new_value = self.value * self.unit.factor + self.unit.offset
num = ''
denom = ''
for i in range(len(base_names)):
unit = base_names[i]
power = self.unit.powers[i]
if power < 0:
denom += '/' + unit
if power < -1:
denom += '**' + str(-power)
elif power > 0:
num += '*' + unit
if power > 1:
num += '**' + str(power)
if len(num) == 0:
num = '1'
else:
num = num[1:]
return self.__class__(new_value, num + denom)
# Comment regarding complex numbers removed as properties are standard
@property
def real(self) -> PhysicalQuantity:
"""Returns the real part of the quantity's value, keeping the unit.
Returns
-------
PhysicalQuantity
A new quantity with the real part of the original value.
Examples
--------
>>> from PhysicalQuantities import PhysicalQuantity
>>> b = PhysicalQuantity(2 + 1j, 'V')
>>> b.real
2.0 V
"""
return self.__class__(self.value.real, self.unit)
@property
def imag(self) -> PhysicalQuantity:
"""Returns the imaginary part of the quantity's value, keeping the unit.
Returns
-------
PhysicalQuantity
A new quantity with the imaginary part of the original value.
Examples
--------
>>> from PhysicalQuantities import PhysicalQuantity
>>> b = PhysicalQuantity(2 + 1j, 'V')
>>> b.imag
1.0 V
"""
return self.__class__(self.value.imag, self.unit)
[docs] def sqrt(self) -> PhysicalQuantity:
"""Calculates the positive square root of the quantity.
Returns
-------
PhysicalQuantity
The square root (`self ** 0.5`).
"""
return self.__pow__(0.5)
[docs] def pow(self, exponent: float) -> PhysicalQuantity:
"""Raises the quantity to the power of an exponent (alias for `__pow__`).
Parameters
----------
exponent : float
The exponent (must be dimensionless scalar).
Returns
-------
PhysicalQuantity
The quantity raised to the specified power.
"""
return self.__pow__(exponent)
[docs] def sin(self) -> float:
"""Calculates the sine of the quantity, assuming it is an angle.
Converts the value to radians before applying `numpy.sin`.
Returns
-------
float
The sine of the angle value in radians.
Raises
-------
UnitError
If the quantity's unit is not an angle type (e.g., rad, deg).
"""
if self.unit.is_angle:
return np.sin(self.value * self.unit.conversion_factor_to(unit_table['rad']))
else:
raise UnitError('Argument of sin must be an angle')
[docs] def cos(self) -> float:
"""Calculates the cosine of the quantity, assuming it is an angle.
Converts the value to radians before applying `numpy.cos`.
Returns
-------
float
The cosine of the angle value in radians.
Raises
-------
UnitError
If the quantity's unit is not an angle type.
"""
if self.unit.is_angle:
return np.cos(self.value * self.unit.conversion_factor_to(unit_table['rad']))
raise UnitError('Argument of cos must be an angle')
[docs] def tan(self) -> float:
"""Calculates the tangent of the quantity, assuming it is an angle.
Converts the value to radians before applying `numpy.tan`.
Returns
-------
float
The tangent of the angle value in radians.
Raises
-------
UnitError
If the quantity's unit is not an angle type.
"""
if self.unit.is_angle:
return np.tan(self.value * self.unit.conversion_factor_to(unit_table['rad']))
raise UnitError('Argument of tan must be an angle')
@property
def to_dict(self) -> dict:
"""Exports the quantity to a serializable dictionary.
Returns
-------
dict
A dictionary with keys 'value' and 'PhysicalUnit' (containing the
unit's dictionary representation from `unit.to_dict`). Numpy arrays
in `value` are converted to lists.
"""
q_dict = {'value': self.value,
'PhysicalUnit': self.unit.to_dict
}
return q_dict
@property
def to_json(self) -> str:
"""Exports the quantity to a JSON string.
Serializes the dictionary from `to_dict` into a JSON string under the top-level
key 'PhysicalQuantity'.
Returns
-------
str
A JSON string representing the PhysicalQuantity.
"""
json_quantity = json.dumps({'PhysicalQuantity': self.to_dict})
return json_quantity
[docs] @staticmethod
def from_dict(quantity_dict: dict) -> PhysicalQuantity:
"""Creates a PhysicalQuantity instance from a dictionary representation.
Parameters
----------
quantity_dict : dict
A dictionary containing 'value' and 'PhysicalUnit' keys. The
'PhysicalUnit' value should be a dictionary suitable for
`PhysicalUnit.from_dict`.
Can optionally be nested under a 'PhysicalQuantity' key.
Returns
-------
PhysicalQuantity
The reconstructed PhysicalQuantity instance.
Raises
-------
ValueError
If the dictionary structure is incorrect.
Notes
-----
This relies on `PhysicalUnit.from_dict` to reconstruct the unit. The unit
must typically be predefined or definable from the dictionary content.
"""
u = PhysicalUnit.from_dict(quantity_dict['PhysicalUnit'])
q = PhysicalQuantity(quantity_dict['value'], u)
return q
[docs] @staticmethod
def from_json(json_quantity: str) -> PhysicalQuantity:
"""Creates a PhysicalQuantity instance from a JSON string.
Parameters
----------
json_quantity : str
A JSON string, typically generated by `to_json`, containing a
'PhysicalQuantity' key whose value is the dictionary representation.
Returns
-------
PhysicalQuantity
The reconstructed PhysicalQuantity instance.
Raises
-------
ValueError
If the JSON string does not contain the expected structure.
"""
quantity_dict = json.loads(json_quantity)
return PhysicalQuantity.from_dict(quantity_dict['PhysicalQuantity'])
# NumPy interoperability
[docs] def __array_ufunc__(self, ufunc, method, *inputs, **kwargs):
"""Implements NumPy Universal Function (ufunc) support."""
# Ensure the method is a standard call
if method != '__call__':
return NotImplemented
# --- Prepare inputs ---
# Convert inputs to values and units
processed_inputs = []
input_units = []
for x in inputs:
if isinstance(x, PhysicalQuantity):
processed_inputs.append(x.value)
input_units.append(x.unit)
elif isinstance(x, (int, float, complex, list, np.ndarray)):
processed_inputs.append(x)
input_units.append(None) # Mark non-quantity inputs
else:
# Cannot handle other types
return NotImplemented
# --- Handle specific ufuncs ---
# Division (np.true_divide)
if ufunc is np.true_divide:
if len(processed_inputs) != 2:
return NotImplemented # Requires 2 arguments
val1, val2 = processed_inputs
unit1, unit2 = input_units
# Calculate result value
# Check for output argument and handle if necessary (ignoring for now)
if 'out' in kwargs:
# For simplicity, we don't support 'out' for now with unit changes
return NotImplemented
result_value = ufunc(val1, val2, **kwargs)
# Determine result unit
if unit1 is not None and unit2 is not None: # quantity / quantity
result_unit = unit1 / unit2
elif unit1 is not None and unit2 is None: # quantity / scalar_or_array
result_unit = unit1
elif unit1 is None and unit2 is not None: # scalar_or_array / quantity
result_unit = 1 / unit2
else: # scalar_or_array / scalar_or_array (should not happen via PhysicalQuantity)
return NotImplemented
# Return result
# Add assertion to check type before accessing property
assert isphysicalunit(result_unit), f"result_unit should be PhysicalUnit, got {type(result_unit)}"
if result_unit.is_dimensionless:
return result_value * result_unit.factor
else:
return self.__class__(result_value, result_unit)
# Add / Subtract (requires compatible units)
elif ufunc in (np.add, np.subtract):
if len(processed_inputs) != 2:
return NotImplemented
val1, val2 = processed_inputs
unit1, unit2 = input_units
if unit1 is None or unit2 is None:
# Cannot add/subtract scalar and quantity directly via ufunc
return NotImplemented
# Ensure units are compatible
if unit1.powers != unit2.powers:
raise UnitError(f"Cannot {ufunc.__name__} quantities with incompatible units: {unit1} and {unit2}")
# Convert second value to units of the first
val2_converted = val2 * unit2.conversion_factor_to(unit1)
result_value = ufunc(val1, val2_converted, **kwargs)
# Result is in the unit of the first operand
return self.__class__(result_value, unit1)
# Multiply
elif ufunc is np.multiply:
if len(processed_inputs) != 2:
return NotImplemented
val1, val2 = processed_inputs
unit1, unit2 = input_units
result_value = ufunc(val1, val2, **kwargs)
# Determine result unit
if unit1 is not None and unit2 is not None: # quantity * quantity
result_unit = unit1 * unit2
elif unit1 is not None and unit2 is None: # quantity * scalar_or_array
result_unit = unit1
elif unit1 is None and unit2 is not None: # scalar_or_array * quantity
result_unit = unit2
else: # scalar_or_array * scalar_or_array
return NotImplemented
# Return result
if result_unit.is_dimensionless:
return result_value * result_unit.factor
else:
return self.__class__(result_value, result_unit)
# Trig functions (sin, cos, tan)
elif ufunc in (np.sin, np.cos, np.tan):
if len(processed_inputs) != 1:
return NotImplemented # Requires 1 argument
val = processed_inputs[0]
unit = input_units[0]
if unit is None:
# Applying trig func to scalar/array without unit
return NotImplemented # Or should we allow np.sin(5)? Let NumPy handle.
if not unit.is_angle:
raise UnitError(f"Argument of {ufunc.__name__} must be an angle, not {unit}")
# Convert to radians
value_rad = val * unit.conversion_factor_to(unit_table['rad'])
# Apply ufunc to value in radians
result_value = ufunc(value_rad, **kwargs)
# Result is dimensionless scalar/array
return result_value
# --- Default: Ufunc not handled ---
return NotImplemented