Source code for simphony.utils

# Copyright © Simphony Project Contributors
# Licensed under the terms of the MIT License
# (see simphony/__init__.py for details)
"""This package contains handy functions useful across simphony submodules and
to the average user."""

import inspect
import re

import deprecation
import jax.numpy as jnp
import sax
from jax import Array
from jax.typing import ArrayLike
from sax.utils import get_ports
from scipy.constants import c as SPEED_OF_LIGHT
from scipy.interpolate import interp1d

from simphony import __version__

MATH_SUFFIXES = {
    "f": "e-15",
    "p": "e-12",
    "n": "e-9",
    "u": "e-6",
    "m": "e-3",
    "c": "e-2",
    "k": "e3",
    "M": "e6",
    "G": "e9",
    "T": "e12",
}


[docs]def rect(r: ArrayLike, phi: ArrayLike) -> Array: """Convert from polar to rectangular coordinates element-wise. Parameters ---------- r : ArrayLike The real radii of the complex-valued numbers. phi : ArrayLike The real phase of the complex-valued numbers. Returns ------- ArrayLike An array of complex-valued numbers. """ return r * jnp.exp(1j * phi)
[docs]def polar(x: ArrayLike) -> Array: """Convert from rectangular to polar coordinates element-wise. Parameters ---------- x : ArrayLike Array of potentially complex-valued numbers. Returns ------- mag, phi A tuple of arrays where the first is the element-wise magnitude of the argument and the second is the element-wise phase of the argument. """ return jnp.abs(x), jnp.angle(x)
[docs]def add_polar(c1, c2): """Adds two polar coordinates together. Parameters ---------- c1 : (float, float) First polar coordinate c2 : (float, float) Second polar coordinate Returns ------- result : (float, float) The resulting polar coordinate """ r1, phi1 = c1 r2, phi2 = c2 # add the vectors in rectangular form sum = rect(r1, phi1) + rect(r2, phi2) mag = jnp.abs(sum) angle = jnp.angle(sum) # calculate how many times the original vectors wrapped around # then add the biggest amount back to our phase # this simulates the steady-state in time-domain wrapped1 = (phi1 // (2 * jnp.pi)) * (2 * jnp.pi) wrapped2 = (phi2 // (2 * jnp.pi)) * (2 * jnp.pi) biggest = max(wrapped1, wrapped2) return (mag, angle + biggest)
[docs]def mul_polar(c1, c2): """Multiplies two polar coordinates together. Parameters ---------- c1 : (float, float) First polar coordinate c2 : (float, float) Second polar coordinate Returns ------- result : (float, float) The resulting polar coordinate """ r1, phi1 = c1 r2, phi2 = c2 return (r1 * r2, phi1 + phi2)
[docs]def mat_mul_polar(array1: ArrayLike, array2: ArrayLike) -> Array: """Multiplies two polar matrixes together. Parameters ---------- array1 : ndarray The first array to multiply. array2 : ndarray The second array to multiply. Notes ----- The arrays can be one of the following possiblities: * #f x n x n x m1 x m2 x 2 * #f x n x n x m1 x m2 x 1 * #f x n x n x m1 x 2 * #f x n x n x m1 x 1 * #f x n x n x m2 x 2 * #f x n x n x m2 x 1 * f x n x n x 2 * f x n x n where * f is the frequency dimension * n is the number of ports * m1 is the number of TE modes * m2 is the number of TM modes The last number of dimensions is 2 if polar and 1 if rectangular. Complex rectangular data is of the form ``a + bj``. Complex polar data is of the form ``[r, theta]``. """ # array1 and 2 should be identical in dimensions if jnp.shape(array1) != jnp.shape(array2): raise RuntimeError("Arrays must be the same shape to matrix multiply them") # polar: multiply magnitudes, add angles, or convert to rectangular and call this function if jnp.shape(array1)[-1] == 2: real1 = array1[..., 0] imag1 = array1[..., 1] real2 = array2[..., 0] imag2 = array2[..., 1] rect1 = rect(real1, imag1) rect2 = rect(real2, imag2) # return real1*real2+imag1+imag2 return mat_mul_polar(rect1, rect2) # rectangular: simply multiply them return array1 * array2
[docs]def mat_add_polar(array1: ArrayLike, array2: ArrayLike) -> Array: """Adds two polar matrixes together. Parameters ---------- array1 : ndarray The first array to add. array2 : ndarray The second array to add. Notes ----- The arrays can be one of the following possiblities: * #f x n x n x m1 x m2 x 2 * #f x n x n x m1 x m2 x 1 * #f x n x n x m1 x 2 * #f x n x n x m1 x 1 * #f x n x n x m2 x 2 * #f x n x n x m2 x 1 * f x n x n x 2 * f x n x n where * f is the frequency dimension * n is the number of ports * m1 is the number of TE modes * m2 is the number of TM modes The last number of dimensions is 2 if polar and 1 if rectangular. Complex rectangular data is of the form ``a + bj``. Complex polar data is of the form ``[r, theta]``. """ # array1 and 2 should be identical in dimensions if jnp.shape(array1) != jnp.shape(array2): raise RuntimeError("Arrays must be the same shape to matrix multiply them") # polar: convert to rectangular, then return this function if jnp.shape(array1)[-1] == 2: real1 = array1[..., 0] imag1 = array1[..., 1] real2 = array2[..., 0] imag2 = array2[..., 1] rect1 = rect(real1, imag1) rect2 = rect(real2, imag2) return mat_add_polar(rect1, rect2) # rectangular: simply add them return array1 + array2
[docs]def str2float(num: str) -> float: """Converts a number represented as a string to a float. Can include suffixes (such as 'u' for micro, 'k' for kilo, etc.). Parameters ---------- num : str A string representing a number, optionally with a suffix. Returns ------- float The string converted back to its floating point representation. Raises ------ ValueError If the argument is malformed or the suffix is not recognized. Examples -------- >>> str2float('14.5c') 0.145 Values without suffixes get converted to floats normally. >>> str2float('2.53') 2.53 If an unrecognized suffix is present, a ``ValueError`` is raised. >>> str2float('17.3o') ValueError: Suffix 'o' in '17.3o' not recognized. ([-+]?[0-9]+[.]?[0-9]*((?:[eE][-+]?[0-9]+)|[a-zA-Z])?) Some floats are represented in exponential notation instead of suffixes, and we can handle those, too: >>> str2float('15.2e-6') 1.52e-7 >>> str2float('0.4E6') 400000.0 """ matches = re.findall( r"([-+]?[0-9]+(?:[.][0-9]+)?)((?:[eE][-+]?[0-9]+)|(?:[a-zA-Z]))?", num ) if len(matches) > 1: raise ValueError(f"'{num}' is malformed") num, suffix = matches[0] try: if suffix.startswith("e") or suffix.startswith("E"): return float(num + suffix) else: return float(num + (MATH_SUFFIXES[suffix] if suffix != "" else "")) except KeyError as e: raise ValueError(f"Suffix {str(e)} in '{matches[0]}' not recognized.")
[docs]def freq2wl(freq: ArrayLike) -> Array: """Convenience function for converting from frequency to wavelength. Parameters ---------- freq : float The frequency in SI units (Hz). Returns ------- wl : float The wavelength in SI units (m). """ return SPEED_OF_LIGHT / freq
[docs]def wl2freq(wl: ArrayLike) -> Array: """Convenience function for converting from wavelength to frequency. Parameters ---------- wl : float The wavelength in SI units (m). Returns ------- freq : float The frequency in SI units (Hz). """ return SPEED_OF_LIGHT / wl
[docs]def wlum2freq(wl: ArrayLike) -> Array: """Convenience function for converting from wavelength in microns to frequency. Parameters ---------- wl : float The wavelength in microns. Returns ------- freq : float The frequency in SI units (Hz). """ return wl2freq(wl * 1e-6)
[docs]@deprecation.deprecated( deprecated_in="0.7.0", removed_in="0.8.0", current_version=__version__, details="Use the resample function instead", ) def interpolate( resampled: ArrayLike, sampled: ArrayLike, s_parameters: ArrayLike ) -> Array: """Returns the result of a cubic interpolation for a given frequency range. .. deprecated:: 0.7.0 `interpolate` will be removed in simphony 0.8.0, it is replaced by `resample`. Parameters ---------- output_freq : ArrayLike The desired frequency range for a given input to be interpolated to. input_freq : ArrayLike A frequency array, indexed matching the given s_parameters. s_parameters : ArrayLike S-parameters for each frequency given in input_freq. Returns ------- result : Array The values of the interpolated function (fitted to the input s-parameters) evaluated at the ``output_freq`` frequencies. """ func = interp1d(sampled, s_parameters, kind="cubic", axis=0) return func(resampled)
[docs]def xxpp_to_xpxp(xxpp: ArrayLike) -> Array: """Converts a NxN matrix or N shaped vector in xxpp convention to xpxp convention. Parameters ---------- xxpp : ArrayLike A NxN matrix or array of size N in xxpp format. Returns ------- xpxp : Array A NxN matrix or array of size N in xpxp format. """ n = jnp.shape(xxpp)[0] ind = jnp.arange(n).reshape(2, -1).T.flatten() if len(xxpp.shape) == 1: return xxpp[ind] return xxpp[ind][:, ind]
[docs]def xpxp_to_xxpp(xpxp: ArrayLike) -> Array: """Converts a NxN matrix or N shaped vector in xpxp convention to xxpp convention. Parameters ---------- xpxp : ArrayLike A NxN matrix or array of size N in xpxp format. Returns ------- xxpp : Array A NxN matrix or array of size N in xxpp format. """ n = jnp.shape(xpxp)[0] ind = jnp.arange(n).reshape(-1, 2).T.flatten() if len(xpxp.shape) == 1: return xpxp[ind] return xpxp[:, ind][ind]
[docs]def dict_to_matrix(sdict: sax.SDict) -> Array: r"""Converts a dictionary of s-parameters to a matrix of s-parameters. Column and row ordering is determined by ``sax.utils.get_ports``. Parameters ---------- sdict : sax.SDict A dictionary of s-parameters. Returns ------- smat : Array A matrix of s-parameters indexed according to the order in ``sax.utils.get_ports``, shape :math:`(f \times n \times n)`. """ ports = get_ports(sdict) arr = list(sdict.values())[0] arr = jnp.asarray(arr).reshape(-1) smat = jnp.zeros((len(arr), len(ports), len(ports)), dtype=complex) for k, v in sdict.items(): i, j = ports.index(k[0]), ports.index(k[1]) smat = smat.at[:, i, j].set(v) return smat
[docs]def validate_model(model: sax.saxtypes.Model) -> bool: """Validates a model. Parameters ---------- model : Model The model to validate. Returns ------- bool True, if the model is valid. Raises ------ ValueError If the model is invalid. """ if not sax.utils.is_model(model): if not callable(model): raise SyntaxError(f"Model '{model.__name__}' is not callable.") try: sig = inspect.signature(model) except ValueError: raise SyntaxError(f"Model '{model.__name__}' has no function signature.") for param in sig.parameters.values(): if param.default is inspect.Parameter.empty: raise SyntaxError( f"SAX models cannot have any positional arguments (problem found in '{model.__name__}')." ) # if _is_callable_annotation(sig.return_annotation): # model factory # return False return True
[docs]def resample(x: ArrayLike, xp: ArrayLike, sdict: sax.SDict) -> sax.SDict: """Resample an s-dictionary to a new frequency grid. Parameters ---------- x : ArrayLike The x-coordinates at which to evaluate the interpolated values (desired frequency or wavelength grid). xp : ArrayLike The x-coordinates of the data points, must be increasing (existing frequency or wavelength grid). sdict : sax.SDict Dictionary of scattering parameters sampled at ``xp``. Returns ------- sax.SDict Dictionary of scattering parameters resampled at ``x``. Examples -------- S-param files define frequencies, while most simulations are done in wavelength. You can resample the s-parameters to a new frequency grid using this function: .. code-block:: python p = Path("dc_gap=200nm_Lc=0um.sparam") _, df = load_sparams(p) f, sdict = df_to_sdict(df) wl = np.linspace(1.5, 1.6, 1000) s_new = resample(wlum2freq(wl), f, sdict) plt.plot(wl, np.abs(s_new["o1", "o3"]) ** 2) """ new_sdict = {} for k, v in sdict.items(): new_sdict[k] = jnp.interp(x, xp, v) return new_sdict