Source code for croak.gases

r"""Gas refractive index at a pressure and temperature, from optmat.

A gas medium is dispersive like a solid (see :mod:`croak.materials`), but its
refractive index depends on the gas *density*, and hence on pressure and
temperature, as well as on wavelength. This module provides
:func:`gas_refractive_index`, which builds an ``n(wavelength_m)`` callable for a
gas at a chosen pressure and temperature. That callable has the same shape as
:func:`croak.materials.refractive_index`'s result, so a gas plugs into the whole
dispersion pipeline by registering it with
:func:`croak.materials.register_material` and treating the optical path length as
the "thickness".

Physics
-------
The dispersion of each gas is optmat's recommended model of its per-molecule
polarisability :math:`\alpha(\lambda)` (`optmat <https://optmat.readthedocs.io>`_,
``optmat.gas.models.registry.default_model``); air is optmat's dry air, the
mole-fraction sum of its constituents' models. The index at a number density
:math:`N` follows from the Lorentz–Lorenz relation,

.. math::

    \frac{n^2 - 1}{n^2 + 2} = \frac{N\alpha}{3\varepsilon_0},

and the density from the **real gas**, :math:`N = P/(Z k_\mathrm{B}T)`, with the
compressibility factor :math:`Z` of each gas's reference equation of state
(CoolProp, through optmat; air is Lemmon et al.'s pseudo-pure dry air, and ozone
has none, so it is taken as ideal). :math:`n - 1` is then nearly proportional to
:math:`P/T`, but not exactly: at 20 °C the real gas differs from the ideal by
0.05 % for helium and air at 1 bar, 0.5 % at 10 bar, and up to 15 % for SF₆ at
10 bar. A state where the gas would condense (SF₆ above about 21 bar at 20 °C, for
example) is refused.
"""

from __future__ import annotations

import functools
import math
from collections.abc import Callable

import numpy as np
from numpy.typing import ArrayLike, NDArray
from optmat.conditions import State
from optmat.gas.air import AirModel, air_model
from optmat.gas.gases import Gas
from optmat.gas.models.base import Model
from optmat.gas.models.registry import default_model

__all__ = [
    "GASES",
    "ROOM_TEMPERATURE_K",
    "canonical_name",
    "gas_model",
    "gas_refractive_index",
]

#: Default gas temperature (K) when none is given (ca. 20 °C).
ROOM_TEMPERATURE_K: float = 293.15

_AIR = "Air"
_PASCAL_PER_BAR = 1e5

#: Names of the gases available (case-insensitive): air, then optmat's species.
GASES: tuple[str, ...] = (_AIR, *(gas.value for gas in Gas))

# Names earlier croak versions offered, kept so saved sessions still resolve.
_ALIASES: dict[str, str] = {"Helium": "He"}


[docs] def canonical_name(gas: str) -> str: """Return the current name of a gas given by any case or an earlier name. Parameters ---------- gas : str A gas name. Returns ------- str The :data:`GASES` name ``gas`` refers to (``"He"`` for ``"Helium"``). Raises ------ ValueError If ``gas`` is not a known gas. Examples -------- >>> canonical_name("helium") 'He' """ lowered = gas.lower() for old, new in _ALIASES.items(): if old.lower() == lowered: return new for name in GASES: if name.lower() == lowered: return name raise ValueError(f"unknown gas {gas!r}; available: {', '.join(GASES)}")
[docs] @functools.cache def gas_model(gas: str) -> Model | AirModel: """Return the optmat model of a gas's polarisability. Parameters ---------- gas : str A name in :data:`GASES` (or an earlier name of one), any case. Returns ------- optmat.gas.models.base.Model or optmat.gas.air.AirModel optmat's recommended model of the gas, or its dry-air model, with its ``name``, ``wavelength_range`` (m) and ``identity`` for provenance. Raises ------ ValueError If ``gas`` is not a known gas. Examples -------- >>> gas_model("Helium").name 'fit/He' """ name = canonical_name(gas) return air_model() if name == _AIR else default_model(name)
[docs] def gas_refractive_index( gas: str, pressure_bar: float, temperature_k: float = ROOM_TEMPERATURE_K, ) -> Callable[[ArrayLike], NDArray[np.float64]]: r"""Build an ``n(wavelength_m)`` callable for a gas at given pressure/temperature. The index follows from optmat's polarisability model of the gas through the Lorentz–Lorenz relation at the real-gas density :math:`P/(Z k_\mathrm{B}T)` (see the module docstring). The returned callable has the same contract as :func:`croak.materials.refractive_index`'s result, so it can be registered with :func:`croak.materials.register_material` and used wherever a material is. Parameters ---------- gas : str Gas name (case-insensitive); one of :data:`GASES`. pressure_bar : float Gas pressure in bar, ``>= 0``. ``0.0`` gives vacuum (``n = 1``). temperature_k : float, optional Gas temperature in kelvin, ``> 0`` (default :data:`ROOM_TEMPERATURE_K`, ca. 20 °C). Returns ------- callable Function mapping vacuum wavelength in metres to the real refractive index, ``nan`` at a non-positive or non-finite wavelength (masked by :func:`croak.materials.beta`) and extrapolated beyond the model's stated range. Raises ------ ValueError If ``gas`` is not a known gas, the pressure is negative or not finite, the temperature is not positive and finite, or the gas is not a gas at that state (condensed, or beyond its equation of state's range; ``optmat.exceptions.StateError``, a ``ValueError``). Examples -------- >>> n = gas_refractive_index("Air", 1.0, 273.15) >>> float(n(800e-9)) - 1.0 # doctest: +ELLIPSIS 0.000286... """ model = gas_model(gas) if not (math.isfinite(pressure_bar) and pressure_bar >= 0.0): raise ValueError(f"pressure must be finite and >= 0 bar, got {pressure_bar}") if not (math.isfinite(temperature_k) and temperature_k > 0.0): raise ValueError(f"temperature must be finite and > 0 K, got {temperature_k}") # optmat refuses a zero-pressure state (no density to evaluate); at zero # density the index is exactly 1. state = ( State(pressure_bar * _PASCAL_PER_BAR, temperature_k) if pressure_bar else None ) if state is not None: # Fail here, not on the first use: the equation of state refuses a state # where the gas condenses, and the density depends only on the state. model.refractive_index(1e-6, state, warn=False) def n(wavelength_m: ArrayLike) -> NDArray[np.float64]: lam = np.asarray(wavelength_m, dtype=float) # As for the solids (croak.materials): a grid point at ω ≤ 0 is nan, and # the model is extrapolated without warning beyond its stated range. physical = np.isfinite(lam) & (lam > 0.0) out = np.full(lam.shape, np.nan) if state is None: out[physical] = 1.0 return out index = model.refractive_index(lam[physical], state, warn=False) # The propagation model is lossless: the real part. out[physical] = np.real(index) return out n.__doc__ = ( f"Refractive index of {gas} at {pressure_bar} bar, {temperature_k} K " "as a function of wavelength (m)." ) return n