from typing import Dict
from typing import List
from typing import Literal
from typing import Optional
from typing import Sequence
from typing import Tuple
from typing import Union
import numpy
import pint
[docs]
def unit_registry() -> pint.UnitRegistry:
return pint.get_application_registry()
[docs]
def units_as_str(units: pint.Unit) -> str:
return f"{units:~}"
[docs]
def convert_units_to_group_reference(
values: Sequence[numpy.ndarray],
units: Sequence[Optional[str]],
reference_units: Optional[Dict[str, str]] = None,
fallback: Optional[Literal["first", "largest", "smallest"]] = "smallest",
) -> Tuple[List[numpy.ndarray], List[str]]:
"""
Convert values so that units sharing the same dimensionality
are expressed in a common (reference) unit.
Priority:
1. Use explicit reference_units mapping per dimensionality
2. Otherwise fallback to strategy ("first", "largest", "smallest")
Example:
units = ["um", "mm", "deg", "rad"]
reference_units = {"[angle]": "deg"}
fallback = "largest"
-> ["mm", "mm", "deg", "deg"]
:param values: sequence of numpy arrays (one per axis)
:param units: sequence of unit strings
:param reference_units: mapping dimensionality -> unit
:param fallback: strategy for unspecified dimensionalities
:returns: (converted_values, normalized_units)
"""
ureg = unit_registry()
normalized_units = normalize_units(units)
groups = _group_units_by_dimensionality(normalized_units)
new_values = list(values)
new_units = list(normalized_units)
if reference_units is None:
reference_units = {}
for dim_key, indices in groups.items():
if dim_key in reference_units:
ref_unit = reference_units[dim_key]
ref_parsed = ureg.parse_units(ref_unit)
elif fallback is not None:
ref_idx = _select_reference_index(indices, normalized_units, ureg, fallback)
ref_unit = normalized_units[ref_idx]
ref_parsed = ureg.parse_units(ref_unit)
else:
continue
for i in indices:
parsed = ureg.parse_units(normalized_units[i])
new_values[i] = (values[i] * parsed).to(ref_parsed).magnitude
new_units[i] = ref_unit
return new_values, new_units
[docs]
def convert_values_to_units(
values: Optional[Sequence[Union[float, Tuple[float, str]]]],
target_units: Sequence[Optional[str]],
) -> Optional[List[float]]:
"""
Convert values to match target units per axis.
:param values: sequence of floats or (value, unit) tuples
:param target_units: units per axis
:returns: values in consistent units
"""
if values is None:
return None
ureg = unit_registry()
result: List[float] = []
normalized_units = normalize_units(target_units)
for value, target_unit in zip(values, normalized_units):
if isinstance(value, tuple):
v, u = value
parsed = ureg.parse_units(u)
target = ureg.parse_units(target_unit)
v = (v * parsed).to(target).magnitude
result.append(float(v))
else:
result.append(float(value))
return result
[docs]
def normalize_units(units: Sequence[Optional[str]]) -> List[str]:
"""
Replace None with 'dimensionless'
"""
return [u if u is not None else "dimensionless" for u in units]
def _group_units_by_dimensionality(
units: Sequence[str],
) -> Dict[str, List[int]]:
"""
Group indices of units by dimensionality.
"""
ureg = unit_registry()
groups: Dict[str, List[int]] = {}
for i, u in enumerate(units):
parsed = ureg.parse_units(u)
key = str(parsed.dimensionality)
groups.setdefault(key, []).append(i)
return groups
def _select_reference_index(
indices: List[int],
units: Sequence[str],
ureg: pint.UnitRegistry,
strategy: Optional[Literal["first", "largest", "smallest"]],
) -> int:
if strategy is None:
raise ValueError(
"No fallback strategy provided and no reference unit specified "
"for this dimensionality"
)
if strategy == "first":
return indices[0]
if strategy == "smallest":
return min(indices, key=lambda i: _get_scale(units[i], ureg))
if strategy == "largest":
return max(indices, key=lambda i: _get_scale(units[i], ureg))
raise ValueError("fallback must be 'first', 'largest', 'smallest', or None")
def _get_scale(unit: str, ureg: pint.UnitRegistry) -> float:
"""
Return scale of unit in base units (used for comparison).
"""
return (1 * ureg.parse_units(unit)).to_base_units().magnitude