r"""
Wavelength
----------
The relationship between wavelength and frequency is given by:
.. math::
\lambda = \frac{c}{f}
where:
* :math:`c` is the speed of light (299,792,458 m/s)
* :math:`f` is the frequency in Hz
to_dB
-----
The conversion to decibels is done using:
.. math::
\text{X}_{\text{dB}} = \text{factor} \cdot \log_{10}(x)
The result will have units of dB(input_unit), e.g. dBW, dBK, dBHz, etc.
For dimensionless input, the result will have unit dB.
to_linear
---------
The conversion from decibels to a linear (dimensionless) ratio is done using:
.. math::
x = 10^{\frac{\text{X}_{\text{dB}}}{\text{factor}}}
where:
* :math:`\text{X}_{\text{dB}}` is the value in decibels
* :math:`\text{factor}` is 10 for power quantities, 20 for field quantities
Return Loss to VSWR
-------------------
The conversion from return loss in decibels to voltage standing wave ratio (VSWR) is done using:
.. math::
\text{VSWR} = \frac{1 + |\Gamma|}{1 - |\Gamma|}
where:
* :math:`|\Gamma|` is the magnitude of the reflection coefficient
* :math:`|\Gamma| = 10^{-\frac{\text{RL}}{20}}`
* :math:`\text{RL}` is the return loss in dB
VSWR to Return Loss
-------------------
The conversion from voltage standing wave ratio (VSWR) to return loss in decibels is done using:
.. math::
\text{RL} = -20 \log_{10}\left(\frac{\text{VSWR} - 1}{\text{VSWR} + 1}\right)
where:
* :math:`\text{VSWR}` is the voltage standing wave ratio
* :math:`\text{RL}` is the return loss in dB
"""
from functools import wraps
from inspect import signature
from typing import get_type_hints, get_args, get_origin, Annotated
import astropy.units as u
import astropy.constants as constants
from astropy.units import Quantity
import numpy as np
if not hasattr(u, "dBHz"): # pragma: no cover
u.dBHz = u.dB(u.Hz)
if not hasattr(u, "dBW"): # pragma: no cover
u.dBW = u.dB(u.W)
if not hasattr(u, "dBm"): # pragma: no cover
u.dBm = u.dB(u.mW)
if not hasattr(u, "dBK"): # pragma: no cover
u.dBK = u.dB(u.K)
if not hasattr(u, "dimensionless"): # pragma: no cover
u.dimensionless = u.dimensionless_unscaled
Decibels = Annotated[Quantity, u.dB]
DecibelWatts = Annotated[Quantity, u.dB(u.W)]
DecibelMilliwatts = Annotated[Quantity, u.dB(u.mW)]
DecibelKelvins = Annotated[Quantity, u.dB(u.K)]
Power = Annotated[Quantity, u.W]
PowerDensity = Annotated[Quantity, u.W / u.Hz]
Frequency = Annotated[Quantity, u.Hz]
Wavelength = Annotated[Quantity, u.m]
Dimensionless = Annotated[Quantity, u.dimensionless_unscaled]
Distance = Annotated[Quantity, u.m]
Temperature = Annotated[Quantity, u.K]
Length = Annotated[Quantity, u.m]
DecibelHertz = Annotated[Quantity, u.dB(u.Hz)]
Angle = Annotated[Quantity, u.rad]
SolidAngle = Annotated[Quantity, u.sr]
Time = Annotated[Quantity, u.s]
[docs]
def enforce_units(func):
"""
Decorator to enforce the units specified in function parameter type annotations.
This decorator enforces some unit consistency rules for function parameters that
annotated with one of the ``Annotated`` types in this module:
* The argument must be a ``Quantity`` object.
* The argument must be provided with a compatible unit. For example, a ``Frequency``
argument's units can be ``u.Hz``, ``u.MHz``, ``u.GHz``, etc. but not ``u.m``,
``u.K``, or any other non-frequency unit.
In addition to the above, the value of any ``Annotated`` argument will be converted
automatically to the unit specified in for that type. For example, the ``Angle``
type will be converted to ``u.rad``, even if the argument is provided with a unit of
``u.deg``. This allows functions to flexibly handle compatible units while keeping
tedious unit conversion logic out of the function body.
Parameters
----------
func : callable
The function to wrap.
Returns
-------
callable
The wrapped function with unit enforcement.
Raises
------
UnitConversionError
If any argument has incompatible units.
TypeError
If an ``Annotated`` argument is not an Astropy ``Quantity`` object.
"""
sig = signature(func)
hints = get_type_hints(func, include_extras=True)
@wraps(func)
def wrapper(*args, **kwargs):
bound = sig.bind(*args, **kwargs)
bound.apply_defaults()
for name, value in bound.arguments.items():
hint = hints.get(name)
# Check if hint is Annotated
if hint and get_origin(hint) is Annotated:
_, unit = get_args(hint) # Use _ for quantity_type if not needed
if isinstance(value, Quantity):
# Convert to expected unit
try:
if unit.is_equivalent(u.K):
converted_value = value.to(
unit, equivalencies=u.temperature()
)
else:
converted_value = value.to(unit)
except u.UnitConversionError as e:
raise u.UnitConversionError(
f"Parameter '{name}' requires unit compatible with {unit}, "
f"but got {value.unit}. Original error: {e}"
) from e
# Unit conversion successful
bound.arguments[name] = converted_value
else:
# Handle non-Quantity inputs
raise TypeError(
f"Parameter '{name}' must be provided as an astropy Quantity, "
f"not a raw number."
)
return func(*bound.args, **bound.kwargs)
return wrapper
[docs]
@enforce_units
def wavelength(frequency: Frequency) -> Wavelength:
r"""
Convert frequency to wavelength.
Parameters
----------
frequency : Quantity
Frequency quantity (e.g., in Hz)
Returns
-------
Quantity
Wavelength in meters
Raises
------
UnitConversionError
If the input quantity has incompatible units
"""
return constants.c / frequency.to(u.Hz)
[docs]
@enforce_units
def frequency(wavelength: Wavelength) -> Frequency:
r"""
Convert wavelength to frequency.
Parameters
----------
wavelength : Quantity
Wavelength quantity (e.g., in meters)
Returns
-------
Quantity
Frequency in hertz
Raises
------
UnitConversionError
If the input quantity has incompatible units
"""
return constants.c / wavelength.to(u.m)
[docs]
@enforce_units
def to_dB(x: Dimensionless, *, factor: float = 10.0) -> Decibels:
r"""
Convert dimensionless quantity to decibels.
Note that for referenced logarithmic units, conversions should
be done using the .to(unit) method.
Parameters
----------
x : Dimensionless
value to be converted
factor : float, optional
10 for power quantities, 20 for field quantities
Returns
-------
Decibels
"""
with np.errstate(divide="ignore"): # Suppress warnings for np.log10(0)
return factor * np.log10(x.value) * u.dB
[docs]
@enforce_units
def to_linear(x: Decibels, *, factor: float = 10.0) -> Dimensionless:
"""
Convert decibels to a linear (dimensionless) ratio.
Parameters
----------
x : Decibels
A quantity in decibels
factor : float, optional
10 for power quantities, 20 for field quantities
Returns
-------
Dimensionless
"""
linear_value = np.power(10, x.value / factor)
return linear_value * u.dimensionless
[docs]
@enforce_units
def return_loss_to_vswr(return_loss: Decibels) -> Dimensionless:
r"""
Convert a return loss in decibels to voltage standing wave ratio (VSWR).
Parameters
----------
return_loss : Quantity
Return loss in decibels (>= 0). Use float('inf') for a perfect match
Returns
-------
Dimensionless
VSWR (>= 1)
Raises
------
ValueError
If return_loss is negative
"""
if return_loss.value < 0:
raise ValueError(f"return loss must be >= 0 ({return_loss}).")
if return_loss.value == float("inf"):
return 1.0 * u.dimensionless
gamma = to_linear(-return_loss, factor=20)
return ((1 + gamma) / (1 - gamma)) * u.dimensionless
[docs]
@enforce_units
def vswr_to_return_loss(vswr: Dimensionless) -> Decibels:
r"""
Convert voltage standing wave ratio (VSWR) to return loss in decibels.
Parameters
----------
vswr : Quantity
VSWR value (> 1). Use 1 for a perfect match (infinite return loss)
Returns
-------
Quantity
Return loss in decibels
Raises
------
ValueError
If vswr is less than 1
"""
if vswr < 1.0:
raise ValueError(f"VSWR must be >= 1 ({vswr}).")
gamma = (vswr - 1) / (vswr + 1)
return safe_negate(to_dB(gamma, factor=20))
[docs]
def safe_negate(quantity: Quantity) -> Quantity:
"""
Safely negate a dB or function unit quantity, preserving the unit.
Astropy does not allow direct negation of function units (like dB).
"""
return (-1 * quantity.value) * quantity.unit