import math as _math
import warnings
import numpy as _np
from numpy.typing import ArrayLike
from typing import Callable, Union, List
# --------------------------------------------------------------------------------
[docs]
def PrintResult(value: Union[float, ArrayLike], err: Union[float, ArrayLike], name: Union[List[str], str] = "", unit: Union[List[str], str] = "") -> None:
"""
Prints a formatted `mean ± sigma` representation with sigma rounded to two significant figures.
Parameters
----------
value : scalar or array-like
Central value(s).
err : scalar or array-like
Uncertainty value(s), must match `value` shape when array-like.
name : str or list of str, optional
Variable name(s) used as prefix. Defaults to ``""``.
unit : str or list of str, optional
Unit symbol(s) appended to the result. Defaults to ``""``.
Returns
-------
None
Output is printed to stdout.
Raises
------
TypeError
If inputs are not numeric or incompatible with each other.
ValueError
If uncertainty is non-positive or values contain non-finite entries.
"""
def _format_pair(mean_value: float, sigma_value: float, name_value: str, unit_value: str):
if not _np.isfinite(mean_value) or not _np.isfinite(sigma_value):
raise ValueError("'value' and 'err' must be finite numbers.")
if sigma_value <= 0:
raise ValueError("'err' must be positive and non-zero.")
exponent = int(_math.floor(_math.log10(abs(sigma_value))))
factor = 10 ** (exponent - 1)
rounded_sigma = round(sigma_value / factor) * factor
rounded_mean = round(mean_value, -exponent + 1)
digits = max(0, -exponent + 1)
mean_str = f"{rounded_mean:.{digits}f}"
sigma_str = f"{rounded_sigma:.{digits}f}"
value_part = f"({mean_str} ± {sigma_str}) {unit_value}" if unit_value else f"{mean_str} ± {sigma_str}"
if rounded_mean != 0:
rel = _np.abs(rounded_sigma / rounded_mean) * 100
value_part += f" [{rel:.2f}%]"
if name_value:
value_part = f"{name_value} = {value_part}"
return value_part
if _np.isscalar(value) and _np.isscalar(err):
if not isinstance(value, (int, float, _np.integer, _np.floating)):
raise TypeError("'value' must be a real number (int or float).")
if not isinstance(err, (int, float, _np.integer, _np.floating)):
raise TypeError("'err' must be a real number (int or float).")
name_value = name if isinstance(name, str) else ""
unit_value = unit if isinstance(unit, str) else ""
print(_format_pair(float(value), float(err), name_value, unit_value))
else:
value_arr = _np.asarray(value, dtype=float)
err_arr = _np.asarray(err, dtype=float)
if value_arr.shape != err_arr.shape:
raise ValueError("Shapes of 'value' and 'err' must match.")
if value_arr.size == 0:
raise ValueError("'value' and 'err' cannot be empty arrays.")
if not _np.all(_np.isfinite(value_arr)) or not _np.all(_np.isfinite(err_arr)):
raise ValueError("'value' and 'err' must be finite numbers.")
if isinstance(name, str):
names = [name] * value_arr.size
elif isinstance(name, list):
if len(name) != value_arr.size:
raise ValueError("Length of 'name' must match length of 'value'.")
names = name
else:
raise TypeError("'name' must be a string or list of strings.")
if isinstance(unit, str):
units = [unit] * value_arr.size
elif isinstance(unit, list):
if len(unit) != value_arr.size:
raise ValueError("Length of 'unit' must match length of 'value'.")
units = unit
else:
raise TypeError("'unit' must be a string or list of strings.")
for mean_value, sigma_value, name_value, unit_value in zip(value_arr, err_arr, names, units):
print(_format_pair(float(mean_value), float(sigma_value), name_value, unit_value))
[docs]
def latex_table(data: List[ArrayLike], header: List[str], filename: str, caption: str = "", label: str = "", align:str = "c") -> None:
"""
Writes a LaTeX-formatted table to file with caption, label, and custom styling.
Parameters
----------
data : list of array-like
The content of the table, organized as a list of columns (i.e., data[i][j] is value j of column i).
header : list of str
List of column names to appear in the header of the table.
filename : str
Path to the output `.tex` file (e.g., 'table.tex').
caption : str, optional
Caption text of the table.
label : str, optional
Label used for referencing the table in LaTeX.
align : str, optional
Column alignment string (e.g., "lcr"). If a single character ("l", "c", or "r") is given, it is repeated for all columns.
Raises
------
ValueError
If length of `header` does not match number of columns in `data`.
TypeError
If `data` is not a list of numpy arrays, `header` is not a list of strings, or `caption`, `label`, `align` are not scalars.
Notes
-----
- Assumes all elements of `data` and `header` are convertible to string.
- Does not escape LaTeX special characters.
- Assumes `data` is column-oriented (i.e., each sublist is a column).
"""
if not data or len(data) != len(header):
raise ValueError("Length of 'header' must match number of columns in 'data'.")
n_rows = len(data[0])
n_cols = len(header)
# Check that data is a list of NumPy arrays
if not isinstance(data, list):
raise TypeError("'data' must be a list of numpy.array.")
if not all(isinstance(col, _np.ndarray) for col in data):
raise TypeError("All elements in 'data' must be numpy.array.")
if not isinstance(header, list):
raise TypeError("'header' must be a list of numpy.array.")
if not all(isinstance(col, str) for col in header):
raise TypeError("All elements in 'header' must be strings.")
if not _np.isscalar(caption):
raise TypeError("'caption' must be a scalar.")
if not _np.isscalar(label):
raise TypeError("'label' must be a scalar.")
if not _np.isscalar(align):
raise TypeError("'align' must be a scalar.")
if not isinstance(caption, str):
raise TypeError("'caption' must be a string.")
if not isinstance(label, str):
raise TypeError("'label' must be a string.")
if not isinstance(align, str):
raise TypeError("'align' must be a string.")
# Determine the length of each column
lengths = [len(col) for col in data]
max_length = max(lengths)
# If columns have different lengths, pad the shorter ones
if len(set(lengths)) != 1:
warnings.warn("Columns in 'data' have different lengths. Padding shorter columns with empty values.")
for i, col in enumerate(data):
if len(col) < max_length:
pad_value = _np.nan if _np.issubdtype(col.dtype, _np.number) else None
padded_col = _np.concatenate([col, _np.full(max_length - len(col), pad_value, dtype=col.dtype)])
data[i] = padded_col
# Gestione formato colonne
if len(align) == 1:
col_format = align * n_cols
elif len(align) == n_cols:
col_format = align
else:
raise ValueError("Length of 'align' must be 1 or equal to number of columns.")
with open(filename, 'w') as f:
f.write("\\begin{table}[H]\n")
if caption or label:
caption_parts = []
if label:
caption_parts.append(f"\\label{{{label}}}")
if caption:
caption_parts.append(f"\\!\\!{caption}")
line = f"\\caption{{"
if caption:
line += "\\large "
line += " ".join(caption_parts) + "}\n"
f.write(line)
f.write("\\vspace{-0.7\\baselineskip}\n")
f.write("\\centering\n")
f.write(f"\\begin{{tabular}}{{{col_format}}}\n")
f.write("\\hline\\hline\n")
f.write("\\noalign{{\\vskip 1.5pt}}\n")
f.write(" & ".join(header) + " \\\\\n")
f.write("\\hline\n")
f.write("\\noalign{{\\vskip 2pt}}\n")
for i in range(n_rows):
row = [str(data[j][i]) for j in range(n_cols)]
f.write(" & ".join(row) + " \\\\\n")
f.write("\\noalign{\\vskip 1.5pt}\n")
f.write("\\hline\n")
f.write("\\end{tabular}\n")
f.write("\\end{table}\n")
[docs]
def convert(value: Union[float, ArrayLike], from_unit: Union[str, List[str]], to_unit: Union[str, List[str]]) -> Union[float, ArrayLike]:
"""
Converts a physical quantity between units, supporting SI prefixes, non-SI units and
compound units.
Parameters
----------
value : float or or array-like
Numerical value to be converted.
from_unit : str or list of str
Unit of the i_nput quantity (e.g., 'erg', 'km/s', 'eV/Å^3').
to_unit : str or list of str
Desired target unit (e.g., 'J', 'm/s', 'GeV/fm^3').
Raises
------
TypeError
If `value` has invalid type or units are improperly specified.
ValueError
If units cannot be parsed or converted.
ImportError
If `astropy` is not installed.
Returns
-------
float or numpy.ndarray
Value converted to the target unit.
"""
from .._helper import parse_unit
try:
from astropy import units as u
from astropy.units import UnitConversionError
except ImportError:
raise ImportError(
"The 'astropy' package is not installed. "
"Please install it by running 'pip install astropy'."
)
if _np.isscalar(value):
if not isinstance(value, (int, float, _np.integer, _np.floating)):
raise TypeError("'value' must be a real number (int or float).")
parsed_from = parse_unit(from_unit)
parsed_to = parse_unit(to_unit)
quantity = float(value) * u.Unit(parsed_from)
try:
converted = quantity.to(parsed_to)
except UnitConversionError as e:
raise UnitConversionError(f"Cannot convert from '{from_unit}' to '{to_unit}': {e}") from e
return float(converted.value)
else:
# Rigorous input validation
if not isinstance(value, _np.ndarray):
raise TypeError(f"'value' must be a numpy.ndarray, not {type(value).__name__}")
if (not (_np.issubdtype(value.dtype, _np.floating) or _np.issubdtype(value.dtype, _np.integer))) or not _np.all(_np.isreal(value)):
raise TypeError("'value' must contain only real numbers (int or float)")
if not isinstance(from_unit, list) or not all(isinstance(u, str) for u in from_unit):
raise TypeError("'from_unit' must be a list of strings.")
if not isinstance(to_unit, list) or not all(isinstance(u, str) for u in to_unit):
raise TypeError("'to_unit' must be a list of strings.")
if not (len(value) == len(from_unit) == len(to_unit)):
raise ValueError("'value', 'from_unit' and 'to_unit' must have the same length.")
if not _np.all(_np.isfinite(value)):
raise ValueError("'value' contains non-finite values (NaN or inf).")
# Vectorized conversion
value_arr = _np.asarray(value, dtype=float)
if value_arr.shape[0] != len(from_unit) or value_arr.shape[0] != len(to_unit):
raise ValueError("'value', 'from_unit', and 'to_unit' must have the same length.")
converted_values = []
for v, f_unit, t_unit in zip(value_arr, from_unit, to_unit):
parsed_f = parse_unit(f_unit)
parsed_t = parse_unit(t_unit)
q = float(v) * u.Unit(parsed_f)
try:
c = q.to(parsed_t)
except UnitConversionError as e:
raise UnitConversionError(f"Cannot convert from '{f_unit}' to '{t_unit}': {e}") from e
converted_values.append(float(c.value))
return _np.array(converted_values)
[docs]
def genspace(start: float, stop: float, num: int, f: Callable[[Union[float, ArrayLike]], Union[float, ArrayLike]],
endpoint: bool = True) -> ArrayLike:
"""
Generate an array of points with spacing determined by a callable function.
Similar to numpy.linspace, but the spacing between points is defined by the function f(x),
which specifies the density of points.
Parameters
----------
start : float
The starting value of the sequence.
stop : float
The end value of the sequence.
num : int
Number of points to generate. Must be positive.
f : callable
A function f(x) that defines the density of points. Must take a float and return a
positive float. Higher values of f(x) result in denser points around x.
endpoint : bool, optional
If True, stop is the last point. Otherwise, it is excluded. Defaults to True.
Returns
-------
numpy.ndarray
A 1D array of num points from start to stop, spaced according to func.
Examples
--------
>>> import numpy as np
>>> from labtoolbox.special import genspace
>>> # Linear spacing (equivalent to np.linspace)
>>> x = genspace(0, 1, 5, lambda x: 1.0)
>>> print(x) # [0. 0.25 0.5 0.75 1. ]
>>> # Denser points near x=0 with f(x) = 1/x
>>> x = genspace(0.1, 1, 5, lambda x: 1/x)
>>> print(x) # Points closer together near 0.1
"""
from scipy.optimize import newton
from scipy.integrate import quad
# Validate inputs
if not isinstance(start, (int, float)) or not _np.isfinite(start):
raise TypeError("start must be a finite float.")
if not isinstance(stop, (int, float)) or not _np.isfinite(stop):
raise TypeError("stop must be a finite float.")
if not isinstance(num, int):
raise TypeError("num must be an integer.")
if num < 1:
raise ValueError("num must be at least 1.")
if not callable(f):
raise TypeError("f must be a callable function.")
if not isinstance(endpoint, bool):
raise TypeError("endpoint must be a boolean.")
if start == stop:
raise ValueError("start and stop cannot be equal.")
try:
# Normalize func to create cumulative distribution
def integrand(x: float) -> float:
val = f(x)
if not _np.isfinite(val) or val <= 0:
raise ValueError("'f' must return positive finite values.")
return val
# Compute total integral for normalization
total_integral, _ = quad(integrand, start, stop)
if not _np.isfinite(total_integral) or total_integral <= 0:
raise ValueError("Integral of 'f' over [start, stop] must be positive and finite.")
# Cumulative distribution F(x) = ∫_start^x f(t) dt / total_integral
def F(x: float) -> float:
integral, _ = quad(integrand, start, x)
return integral / total_integral
# Generate uniform points in [0, 1]
if endpoint:
u = _np.linspace(0, 1, num)
else:
u = _np.linspace(0, 1 - 1/num, num)
# Invert F to find points
points = _np.zeros(num)
points[0] = start
if num > 1:
for i in range(1, num):
# Use Newton-Raphson to solve F(x) = u[i]
def objective(x: float) -> float:
return F(x) - u[i]
# Initial guess: linear interpolation
x_guess = start + (stop - start) * u[i]
points[i] = newton(objective, x_guess, tol=1e-10, maxiter=100)
if not _np.all(_np.isfinite(points)):
raise ValueError("Generated points contain non-finite values.")
return points
except Exception as e:
raise RuntimeError(f"Error generating points: {e}") from e