├── atomic_physics ├── __init__.py ├── atoms │ ├── __init__.py │ └── two_state.py ├── ions │ ├── __init__.py │ ├── sr88.py │ ├── mg25.py │ ├── ca40.py │ ├── ba138.py │ ├── ca43.py │ ├── ba133.py │ ├── ba135.py │ └── ba137.py ├── tests │ ├── __init__.py │ ├── utils.py │ ├── test_examples.py │ ├── test_two_state_atom.py │ ├── test_stim.py │ ├── test_wigner.py │ ├── test_level_data.py │ ├── test_operators.py │ ├── test_mg25.py │ ├── test_spin_half.py │ ├── test_atoms.py │ ├── test_ca43.py │ ├── test_spont.py │ ├── test_rates.py │ ├── test_atom_factory.py │ ├── test_polarization.py │ ├── test_utils.py │ └── test_atom.py ├── examples │ ├── __init__.py │ ├── M1_transitions.py │ ├── breit_rabi.py │ ├── clock_qubits.py │ └── ca_shelving.py ├── wigner.py ├── operators.py ├── polarization.py ├── rate_equations.py └── utils.py ├── .gitignore ├── README.md ├── run_pytype.py ├── CITATION.cff ├── docs ├── _static │ └── css │ │ └── custom.css ├── contributing.rst ├── Makefile ├── make.bat ├── index.rst ├── conf.py ├── api.rst ├── getting_started.rst ├── changes.rst └── definitions_and_conventions.rst ├── .github └── workflows │ ├── test.yml │ ├── package.yml │ └── docs.yml ├── pyproject.toml └── LICENSE /atomic_physics/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /atomic_physics/atoms/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /atomic_physics/ions/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /atomic_physics/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /atomic_physics/examples/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | *.egg-info/ 3 | /dist 4 | docs/_build 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Atomic Physics 2 | 3 | See the [online documentation](https://oxfordiontrapgroup.github.io/atomic_physics/). 4 | 5 | To see what's new in `atomic_physics` checkout the [change log](https://oxfordiontrapgroup.github.io/atomic_physics/changes.html). -------------------------------------------------------------------------------- /run_pytype.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | 4 | 5 | def main(): 6 | if os.name != "posix": 7 | raise RuntimeError( 8 | "pyptype is not supported on Windows, " 9 | "but you should be able to run it in the WSL." 10 | ) 11 | 12 | subprocess.check_call("poetry run pytype -k .".split()) 13 | -------------------------------------------------------------------------------- /CITATION.cff: -------------------------------------------------------------------------------- 1 | cff-version: 1.2.0 2 | message: "If you use this software, please cite it as below." 3 | authors: 4 | - family-names: "Harty" 5 | given-names: "Thomas" 6 | orcid: "https://orcid.org/0000-0003-4077-226X" 7 | title: "Atomic Physics" 8 | version: 1.0.2 9 | # doi: 10.5281/zenodo.1234 10 | # date-released: 2017-12-18 11 | url: "https://github.com/OxfordIonTrapGroup/atomic_physics/" -------------------------------------------------------------------------------- /docs/_static/css/custom.css: -------------------------------------------------------------------------------- 1 | /* Newlines (\a) and spaces (\20) before each parameter */ 2 | .sig-param::before { 3 | content: "\a\20\20\20\20\20\20\20\20\20\20\20\20\20\20\20\20"; 4 | white-space: pre; 5 | } 6 | 7 | /* Newline after the last parameter (so the closing bracket is on a new line) */ 8 | dt em.sig-param:last-of-type::after { 9 | content: "\a"; 10 | white-space: pre; 11 | } 12 | 13 | /* To have blue background of width of the block (instead of width of content) */ 14 | dl.class > dt:first-of-type { 15 | display: block !important; 16 | } -------------------------------------------------------------------------------- /atomic_physics/tests/utils.py: -------------------------------------------------------------------------------- 1 | from sympy.physics.wigner import wigner_3j as _wigner_3j 2 | from sympy.physics.wigner import wigner_6j as _wigner_6j 3 | 4 | 5 | def wigner_3j(j_1, j_2, j_3, m_1, m_2, m_3): 6 | """Temporary work around for https://github.com/sympy/sympy/pull/27288""" 7 | return _wigner_3j(*map(float, (j_1, j_2, j_3, m_1, m_2, m_3))) 8 | 9 | 10 | def wigner_6j(j_1, j_2, j_3, j_4, j_5, j_6, prec=None): 11 | """Temporary work around for https://github.com/sympy/sympy/pull/27288""" 12 | return _wigner_6j(*map(float, (j_1, j_2, j_3, j_4, j_5, j_6)), prec) 13 | -------------------------------------------------------------------------------- /atomic_physics/tests/test_examples.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from matplotlib import pyplot as plt 4 | 5 | 6 | def dummy_show(): 7 | pass 8 | 9 | 10 | plt.show = dummy_show 11 | 12 | 13 | class TestExamples(unittest.TestCase): 14 | def test_examples(self): 15 | """Check that the example code runs without error.""" 16 | import atomic_physics.examples.breit_rabi # noqa: F401 17 | import atomic_physics.examples.ca_shelving # noqa: F401 18 | import atomic_physics.examples.clock_qubits # noqa: F401 19 | import atomic_physics.examples.M1_transitions # noqa: F401 20 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | Pull requests are always welcomed. To make reviews fast and easy, before opening a PR, 5 | please: 6 | 7 | * Please make sure that all new code is thoroughly tested (see existing tests for inspiration)! 8 | * Please make sure all new code is well documented, including any useful additions to :ref:`definitions`. 9 | * Update the package version number in ``pyproject.toml`` 10 | * Update the documentation and :ref:`changes` as required 11 | * Check formatting: ``poe fmt`` 12 | * Run lints: ``poe lint --fix`` 13 | * Run test suite: ``poe test`` 14 | * Check type annotations (Linux only): ``poe types`` 15 | * Check the documentation builds correctly (Linux only): ``poe docs`` 16 | 17 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= -W --keep-going -n 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /atomic_physics/examples/M1_transitions.py: -------------------------------------------------------------------------------- 1 | import scipy.constants as consts 2 | 3 | from atomic_physics.ions.ca43 import Ca43 4 | 5 | uB = consts.physical_constants["Bohr magneton"][0] 6 | 7 | 8 | ion = Ca43.filter_levels(level_filter=(Ca43.ground_level,))(magnetic_field=146.0942e-4) 9 | 10 | R = ion.get_magnetic_dipoles() / uB 11 | 12 | for M3 in range(-3, +3 + 1): 13 | for q in [-1, 0, 1]: 14 | F4 = ion.get_state_for_F(Ca43.ground_level, F=4, M_F=M3 - q) 15 | F3 = ion.get_state_for_F(Ca43.ground_level, F=3, M_F=M3) 16 | Rnm = R[ 17 | ion.get_state_for_F(Ca43.ground_level, F=3, M_F=M3), 18 | ion.get_state_for_F(Ca43.ground_level, F=4, M_F=M3 - q), 19 | ] 20 | print("Rnm for F=3, M={} -> F=4," "M={}: {:.6f}".format(M3, M3 - q, Rnm)) 21 | -------------------------------------------------------------------------------- /atomic_physics/examples/breit_rabi.py: -------------------------------------------------------------------------------- 1 | """Produces Breit-Rabi plots for Calcium 43""" 2 | 3 | import matplotlib.pyplot as plt 4 | import numpy as np 5 | 6 | from atomic_physics.ions.ca43 import Ca43 7 | 8 | factory = Ca43.filter_levels(level_filter=(Ca43.ground_level,)) 9 | idim = int(np.rint(2 * Ca43.nuclear_spin + 1)) 10 | jdim = int(np.rint(2 * 1 / 2 + 1)) 11 | 12 | field_ax = np.arange(0.01, 30000, 200) # B fields (Gauss) 13 | energies = np.zeros((len(field_ax), idim * jdim)) 14 | 15 | for idx, magnetic_field in enumerate(field_ax): 16 | ion = factory(magnetic_field * 1e-4) 17 | energies[idx, :] = ion.state_energies 18 | 19 | plt.figure() 20 | for idx in range(idim * jdim): 21 | plt.plot(field_ax, energies[:, idx] / (2 * np.pi * 1e6)) 22 | 23 | plt.ylabel("Frequency (MHz)") 24 | plt.xlabel("B field (G)") 25 | plt.grid() 26 | plt.show() 27 | -------------------------------------------------------------------------------- /atomic_physics/tests/test_two_state_atom.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import numpy as np 4 | import scipy.constants as consts 5 | 6 | from atomic_physics.atoms.two_state import TwoStateAtom, field_for_frequency 7 | 8 | 9 | class TestTwoStateAtom(unittest.TestCase): 10 | """Test for the two-state atom class.""" 11 | 12 | def test_frequency(self): 13 | w_0 = 2 * np.pi * 100e6 14 | magnetic_field = field_for_frequency(w_0) 15 | atom = TwoStateAtom(magnetic_field=magnetic_field) 16 | 17 | np.testing.assert_allclose( 18 | atom.level_data[TwoStateAtom.ground_level].g_J, 19 | -consts.physical_constants["electron g factor"][0], 20 | ) 21 | np.testing.assert_allclose( 22 | atom.get_transition_frequency_for_states( 23 | (TwoStateAtom.upper_state, TwoStateAtom.lower_state) 24 | ), 25 | w_0, 26 | ) 27 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | workflow_dispatch: 8 | pull_request: 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-22.04 13 | strategy: 14 | matrix: 15 | python: ["3.10", "3.12"] 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | 20 | - uses: actions/setup-python@v5 21 | with: 22 | python-version: "${{ matrix.python }}" 23 | 24 | - name: Install poetry 25 | run: python -m pip install poetry==2.1.3 poethepoet==0.34.0 26 | 27 | - name: Install dependencies 28 | run: poetry install 29 | 30 | - name: Check formatting 31 | run: poe fmt-test 32 | 33 | - name: Check syntax 34 | run: poe lint 35 | 36 | - name: Run tests 37 | run: poe test 38 | 39 | - name: Check doc build 40 | run: poe docs 41 | 42 | - name: Check type annotations 43 | run: poe types 44 | -------------------------------------------------------------------------------- /.github/workflows/package.yml: -------------------------------------------------------------------------------- 1 | name: Package 2 | 3 | on: 4 | release: 5 | types: [published] 6 | workflow_dispatch: 7 | pull_request: 8 | 9 | jobs: 10 | build_and_upload_wheel: 11 | name: Build and upload wheel 12 | runs-on: ubuntu-22.04 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - uses: actions/setup-python@v5 17 | with: 18 | python-version: "3.10" 19 | 20 | - name: Install Poetry 21 | run: | 22 | pipx install poetry==2.1.3 23 | pipx inject poetry "poetry-dynamic-versioning[plugin]==1.8.2" 24 | 25 | - run: poetry build 26 | - name: Test wheel 27 | if: github.event_name != 'release' 28 | run: | 29 | pipx install twine 30 | twine check dist/* 31 | - name: Upload wheel 32 | if: github.event_name == 'release' 33 | uses: pypa/gh-action-pypi-publish@release/v1 34 | with: 35 | user: __token__ 36 | password: ${{ secrets.ATOMICPYPI }} 37 | -------------------------------------------------------------------------------- /atomic_physics/tests/test_stim.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from atomic_physics.core import Laser 4 | from atomic_physics.ions.ca43 import Ca43 5 | from atomic_physics.rate_equations import Rates 6 | 7 | 8 | class TestStim(unittest.TestCase): 9 | def test_multi_transition(self): 10 | """Test with lasers on multiple transitions (see #15)""" 11 | ion = Ca43(magnetic_field=146e-4) 12 | rates = Rates(ion) 13 | Lasers = ( 14 | Laser("397", polarization=0, intensity=1, detuning=0), 15 | Laser("866", polarization=0, intensity=1, detuning=0), 16 | ) 17 | rates.get_transitions_matrix(Lasers) 18 | 19 | def test_multi_laser(self): 20 | """Test with multiple lasers on one transition""" 21 | ion = Ca43(magnetic_field=146e-4) 22 | rates = Rates(ion) 23 | Lasers = ( 24 | Laser("397", polarization=0, intensity=1, detuning=0), 25 | Laser("397", polarization=+1, intensity=1, detuning=0), 26 | ) 27 | rates.get_transitions_matrix(Lasers) 28 | -------------------------------------------------------------------------------- /atomic_physics/tests/test_wigner.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import numpy as np 4 | 5 | import atomic_physics.wigner as ap_wigner 6 | 7 | # from sympy.physics import wigner 8 | from . import utils as sp_wigner # HACK: waiting for new sympy release 9 | 10 | 11 | class TestWigner(unittest.TestCase): 12 | def test_wigner(self): 13 | """Cross-check our Wigner 3j symbol calculation against the sympy 14 | implementation. 15 | """ 16 | j = [0, 0.5, 1.0, 1.5, 2.0, 2.5, 3] 17 | for j1 in j: 18 | for j2 in j: 19 | for j3 in j: 20 | for m1 in np.arange(-j1, j1 + 1): 21 | for m2 in np.arange(-j2, j2 + 1): 22 | for m3 in np.arange(-j3, j3 + 1): 23 | wigner_ap = ap_wigner.wigner3j(j1, j2, j3, m1, m2, m3) 24 | wigner_sp = sp_wigner.wigner_3j(j1, j2, j3, m1, m2, m3) 25 | self.assertTrue(np.abs(wigner_ap - wigner_sp) < 1e-15) 26 | 27 | 28 | if __name__ == "__main__": 29 | unittest.main() 30 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Atomic Physics 2 | ============== 3 | 4 | ``atomic_physics`` is a lightweight python library for calculations based on atomic 5 | structure. Its functionality includes: 6 | 7 | * accurate atomic structure data for a number of common atom / ion species 8 | * calculating state energies 9 | * electric and magnetic interaction matrix elements and Rabi frequencies 10 | * rate equations simulations 11 | 12 | Overview 13 | ======== 14 | 15 | The main source of documentation is the :ref:`api` reference, which has documentation for 16 | all classes in ``atomic_physics``. 17 | 18 | ``atomic_physics`` aims to provide extensive, user-friendly documentation. If you can't 19 | easily find the information you need to use it, or if it's not obvious how functions 20 | work / what conventions and definitions are used, we consider that a bug so please 21 | open an issue! 22 | 23 | Contents 24 | ======== 25 | 26 | 27 | .. toctree:: 28 | :maxdepth: 2 29 | 30 | getting_started.rst 31 | definitions_and_conventions.rst 32 | changes.rst 33 | contributing.rst 34 | api.rst 35 | 36 | Indices and tables 37 | ================== 38 | 39 | * :ref:`genindex` 40 | * :ref:`modindex` 41 | * :ref:`search` -------------------------------------------------------------------------------- /atomic_physics/tests/test_level_data.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import numpy as np 4 | from scipy import constants as consts 5 | 6 | from atomic_physics.core import Level, LevelData 7 | 8 | 9 | class TestLevelData(unittest.TestCase): 10 | """Tests for :class:`atomic_physics.core.LevelData`.""" 11 | 12 | def test_g_J(self): 13 | """ 14 | Test that ``LevelData`` calculates g factors correctly. 15 | """ 16 | # L, S, J 17 | levels = ( 18 | (0, 1 / 2, 1 / 2), 19 | (1, 1 / 2, 1 / 2), 20 | (1, 1 / 2, 3 / 2), 21 | (2, 1 / 2, 5 / 2), 22 | (2, 7 / 2, 11 / 2), 23 | (2, 7 / 2, 3 / 2), 24 | ) 25 | 26 | def Lande(L, S, J): 27 | g_L = 1 28 | g_S = -consts.physical_constants["electron g factor"][0] 29 | 30 | return g_L * (J * (J + 1) - S * (S + 1) + L * (L + 1)) / ( 31 | 2 * J * (J + 1) 32 | ) + g_S * (J * (J + 1) + S * (S + 1) - L * (L + 1)) / (2 * J * (J + 1)) 33 | 34 | for L, S, J in levels: 35 | level = Level(n=0, L=L, S=S, J=J) 36 | data = LevelData(level=level, g_I=0, Ahfs=0, Bhfs=0) 37 | 38 | np.testing.assert_allclose(data.g_J, Lande(L=L, S=S, J=J), rtol=5e-3) 39 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # For the full list of built-in configuration values, see the documentation: 4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 5 | 6 | # -- Project information ----------------------------------------------------- 7 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 8 | project = "atomic-physics" 9 | author = "Thomas Harty" 10 | release = "2.0" 11 | 12 | # -- General configuration --------------------------------------------------- 13 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 14 | 15 | extensions = [ 16 | "sphinx_mdinclude", 17 | "sphinx.ext.autodoc", 18 | "sphinx.ext.napoleon", 19 | "sphinx.ext.doctest", 20 | ] 21 | 22 | templates_path = ["_templates"] 23 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 24 | 25 | 26 | # -- Options for HTML output ------------------------------------------------- 27 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 28 | 29 | html_theme = "sphinx_rtd_theme" 30 | html_static_path = ["_static"] 31 | html_css_files = [ 32 | "css/custom.css", 33 | ] 34 | 35 | nitpick_ignore = [("py:class", "numpy.ndarray")] 36 | -------------------------------------------------------------------------------- /atomic_physics/examples/clock_qubits.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from atomic_physics.ions.ca43 import Ca43 4 | from atomic_physics.utils import d2f_dB2, field_insensitive_point 5 | 6 | factory = Ca43.filter_levels(level_filter=(Ca43.ground_level,)) 7 | 8 | print("Field-independent points:") 9 | for M3 in range(-3, +3 + 1): 10 | for q in [-1, 0, 1]: 11 | B0 = field_insensitive_point( 12 | factory, 13 | level_0=Ca43.ground_level, 14 | F_0=4, 15 | M_F_0=M3 - q, 16 | level_1=Ca43.ground_level, 17 | F_1=3, 18 | M_F_1=M3, 19 | ) 20 | if B0 is not None: 21 | ion = factory(B0) 22 | F4 = ion.get_state_for_F(Ca43.ground_level, F=4, M_F=M3 - q) 23 | F3 = ion.get_state_for_F(Ca43.ground_level, F=3, M_F=M3) 24 | f0 = ion.get_transition_frequency_for_states((F4, F3)) 25 | d2fdB2 = d2f_dB2(atom_factory=factory, magnetic_field=B0, states=(F4, F3)) 26 | print( 27 | "4, {} --> 3, {}: {:.6f} GHz @ {:.5f} G ({:.3e} Hz/G^2)".format( 28 | M3 - q, 29 | M3, 30 | f0 / (2 * np.pi * 1e9), 31 | B0 * 1e4, 32 | d2fdB2 / (2 * np.pi) * 1e-8, 33 | ) 34 | ) 35 | else: 36 | print("4, {} --> 3, {}: none found".format(M3 - q, M3)) 37 | -------------------------------------------------------------------------------- /atomic_physics/atoms/two_state.py: -------------------------------------------------------------------------------- 1 | r"""Ideal spin 1/2 atom with two states.""" 2 | 3 | import scipy.constants as consts 4 | 5 | from atomic_physics.core import AtomFactory, Level, LevelData 6 | 7 | 8 | def field_for_frequency(frequency: float) -> float: 9 | """Returns the B-field needed to produce a given transition frequency. 10 | 11 | :param frequency: the desired transition frequency (rad/s). 12 | :return: the required magnetic field (T). 13 | """ 14 | # E = h * f = gJ * uB * B 15 | uB = consts.physical_constants["Bohr magneton"][0] 16 | gJ = -consts.physical_constants["electron g factor"][0] 17 | B = consts.hbar * frequency / (gJ * uB) 18 | return B 19 | 20 | 21 | class TwoStateAtomFactory(AtomFactory): 22 | r""":class:`~atomic_physics.core.AtomFactory` for ideal spin 1/2 atoms. 23 | 24 | Attributes: 25 | ground_level: the only level within the :class:`TwoStateAtomFactory`. 26 | upper_state: index of the M=+1/2 (higher-energy) state. 27 | lower_state: index of the M=-1/2 (lower-energy) state. 28 | """ 29 | 30 | ground_level: Level = Level(n=0, S=1 / 2, L=0, J=1 / 2) 31 | upper_state = 0 32 | lower_state = 1 33 | 34 | def __init__(self): 35 | super().__init__( 36 | nuclear_spin=0.0, 37 | level_data=(LevelData(level=self.ground_level, Ahfs=0, Bhfs=0),), 38 | transitions={}, 39 | ) 40 | 41 | 42 | TwoStateAtom = TwoStateAtomFactory() 43 | """Ideal spin 1/2 atom with two states.""" 44 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "atomic_physics" 3 | version = "2.0.5" 4 | description = "Lightweight python library for calculations based on atomic structure" 5 | authors = ["hartytp "] 6 | license = "Apache-2.0" 7 | 8 | [tool.poetry.dependencies] 9 | python = "^3.10" 10 | numpy = ">=1.22.0,<3.0" 11 | scipy = "^1.7.3" 12 | 13 | [tool.poetry.dev-dependencies] 14 | pytest = "^8.3.5" 15 | poethepoet = "^0.12.1" 16 | sympy = "^1.9" 17 | ruff = "^0.7.3" 18 | matplotlib = "^3.5.1" 19 | pytype = {version = "^2024.10.11", markers = "os_name == 'posix'"} 20 | sphinx = "^6.0" 21 | sphinx-rtd-theme = "^1.2.0" 22 | sphinx-mdinclude = "^0.5.3" 23 | 24 | [build-system] 25 | requires = ["poetry-core>=1.0.0"] 26 | build-backend = "poetry.core.masonry.api" 27 | 28 | [tool.poe.tasks] 29 | test = "pytest -Werror" 30 | fmt-test = "ruff format --check ." 31 | fmt = "ruff format" 32 | lint = "ruff check" 33 | types = { script = "run_pytype:main" } 34 | docs = { cwd = "./docs", cmd = "make html doctest" } 35 | docs-clean = { cwd = "./docs", cmd = "make clean" } 36 | 37 | [tool.ruff] 38 | exclude = [ 39 | ".git", 40 | "__pycache__", 41 | ".venv", 42 | ] 43 | 44 | indent-width = 4 45 | line-length = 88 46 | target-version = "py310" 47 | 48 | [tool.ruff.lint.per-file-ignores] 49 | # Allow unused imports in __init__ files for re-exporting 50 | "__init__.py" = ["F401"] 51 | 52 | [tool.ruff.lint] 53 | # Sort imports 54 | extend-select = ["I"] 55 | 56 | [tool.pytest.ini_options] 57 | testpaths = ["atomic_physics/tests"] 58 | -------------------------------------------------------------------------------- /atomic_physics/examples/ca_shelving.py: -------------------------------------------------------------------------------- 1 | """Simple rate equations example of 393 shelving in 43Ca+.""" 2 | 3 | import matplotlib.pyplot as plt 4 | import numpy as np 5 | from scipy.linalg import expm 6 | 7 | from atomic_physics.core import Laser 8 | from atomic_physics.ions.ca43 import Ca43 9 | from atomic_physics.rate_equations import Rates 10 | 11 | t_ax = np.linspace(0, 100e-6, 100) # Scan the duration of the "shelving" pulse 12 | intensity = 0.02 # 393 intensity 13 | 14 | ion = Ca43(magnetic_field=146e-4) 15 | 16 | # Ion starts in the F=4, M=+4 "stretched" state within the 4S1/2 ground-level 17 | stretch = ion.get_state_for_F(Ca43.ground_level, F=4, M_F=+4) 18 | Vi = np.zeros((ion.num_states, 1)) 19 | Vi[stretch] = 1 20 | 21 | # Tune the 393nm laser to resonance with the 22 | # 4S1/2(F=4, M_F=+4) <> 4P3/2(F=5, M_F=+5) transition 23 | detuning = ion.get_transition_frequency_for_states( 24 | (stretch, ion.get_state_for_F(Ca43.P32, F=5, M_F=+5)) 25 | ) 26 | lasers = ( 27 | Laser("393", polarization=+1, intensity=intensity, detuning=detuning), 28 | ) # resonant 393 sigma+ 29 | 30 | rates = Rates(ion) 31 | transitions = rates.get_transitions_matrix(lasers) 32 | 33 | shelved = np.zeros(len(t_ax)) # Population in the 3D5/2 level at the end 34 | for idx, t in np.ndenumerate(t_ax): 35 | Vf = expm(transitions * t) @ Vi # NB use of matrix operations here! 36 | shelved[idx] = np.sum(Vf[ion.get_slice_for_level(Ca43.shelf)]) 37 | 38 | plt.plot(t_ax * 1e6, shelved) 39 | plt.ylabel("Shelved Population") 40 | plt.xlabel("Shelving time (us)") 41 | plt.grid() 42 | plt.show() 43 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | .. _api: 2 | 3 | atomic-physics API 4 | ================== 5 | 6 | core 7 | ~~~~~~ 8 | .. automodule:: atomic_physics.core 9 | :members: 10 | 11 | Rate Equations 12 | ~~~~~~~~~~~~~~ 13 | .. automodule:: atomic_physics.rate_equations 14 | :members: 15 | 16 | Helper Utilities 17 | ~~~~~~~~~~~~~~~~ 18 | .. automodule:: atomic_physics.utils 19 | :members: 20 | 21 | Operators 22 | ~~~~~~~~~ 23 | .. automodule:: atomic_physics.operators 24 | :members: 25 | 26 | Polarization 27 | ~~~~~~~~~~~~ 28 | .. automodule:: atomic_physics.polarization 29 | :members: 30 | 31 | .. _atoms: 32 | 33 | Atoms 34 | ~~~~~ 35 | .. automodule:: atomic_physics.atoms 36 | :members: 37 | 38 | Two State Atom 39 | ++++++++++++++ 40 | .. automodule:: atomic_physics.atoms.two_state 41 | :members: 42 | 43 | .. _ions: 44 | 45 | Ions 46 | ~~~~ 47 | .. automodule:: atomic_physics.ions 48 | :members: 49 | 50 | Barium 133 51 | ++++++++++ 52 | .. automodule:: atomic_physics.ions.ba133 53 | :members: 54 | 55 | Barium 135 56 | ++++++++++ 57 | .. automodule:: atomic_physics.ions.ba135 58 | :members: 59 | 60 | Barium 137 61 | ++++++++++ 62 | .. automodule:: atomic_physics.ions.ba137 63 | :members: 64 | 65 | Barium 138 66 | ++++++++++ 67 | .. automodule:: atomic_physics.ions.ba138 68 | :members: 69 | 70 | Calcium 40 71 | ++++++++++ 72 | .. automodule:: atomic_physics.ions.ca40 73 | :members: 74 | 75 | Calcium 43 76 | ++++++++++ 77 | .. automodule:: atomic_physics.ions.ca43 78 | :members: 79 | 80 | Magnesium 25 81 | ++++++++++++ 82 | .. automodule:: atomic_physics.ions.mg25 83 | :members: 84 | 85 | Strontium 88 86 | ++++++++++++ 87 | .. automodule:: atomic_physics.ions.sr88 88 | :members: 89 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Build and deploy documentation 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | workflow_dispatch: 8 | 9 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 10 | permissions: 11 | contents: read 12 | pages: write 13 | id-token: write 14 | 15 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 16 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 17 | concurrency: 18 | group: "pages" 19 | cancel-in-progress: false 20 | 21 | jobs: 22 | build: 23 | runs-on: ubuntu-22.04 24 | steps: 25 | - name: Checkout 26 | uses: actions/checkout@v4 27 | 28 | - uses: actions/setup-python@v5 29 | with: 30 | python-version: "3.10" 31 | 32 | - name: Install poetry 33 | run: python -m pip install poetry==2.1.3 poethepoet==0.34.0 34 | 35 | - name: Install dependencies 36 | run: poetry install 37 | 38 | - name: Setup Pages 39 | id: pages 40 | uses: actions/configure-pages@v4 41 | 42 | - name: Build docs 43 | run: | 44 | cd docs 45 | poetry run make html 46 | 47 | - name: print files 48 | run: ls -R 49 | - name: Upload artifact 50 | uses: actions/upload-pages-artifact@v3 51 | with: 52 | path: ./docs/_build/html 53 | 54 | deploy: 55 | environment: 56 | name: github-pages 57 | url: ${{ steps.deployment.outputs.page_url }} 58 | runs-on: ubuntu-22.04 59 | needs: build 60 | steps: 61 | - name: Deploy to GitHub Pages 62 | id: deployment 63 | uses: actions/deploy-pages@v4 64 | -------------------------------------------------------------------------------- /atomic_physics/tests/test_operators.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import numpy as np 4 | 5 | from atomic_physics.operators import ( 6 | AngularMomentumLoweringOp, 7 | AngularMomentumProjectionOp, 8 | AngularMomentumRaisingOp, 9 | expectation_value, 10 | ) 11 | 12 | 13 | class TestOperators(unittest.TestCase): 14 | def test_angular_momentum_ops(self): 15 | J = 5 16 | dim = 2 * J + 1 17 | focks = [] 18 | for idx, M in enumerate(range(-5, 5 + 1)): 19 | focks.append(np.zeros(dim)) 20 | focks[idx][idx] = 1 21 | 22 | for idx, M in enumerate(range(-5, 5 + 1)): 23 | np.testing.assert_allclose( 24 | AngularMomentumRaisingOp(j=J) @ focks[idx], 25 | np.sqrt((J - M) * (J + M + 1)) * focks[min(idx + 1, dim - 1)], 26 | ) 27 | np.testing.assert_allclose( 28 | AngularMomentumLoweringOp(j=J) @ focks[idx], 29 | np.sqrt((J + M) * (J - M + 1)) * focks[max(idx - 1, 0)], 30 | ) 31 | np.testing.assert_allclose( 32 | AngularMomentumProjectionOp(j=J) @ focks[idx], M * focks[idx] 33 | ) 34 | 35 | np.testing.assert_allclose( 36 | AngularMomentumRaisingOp(J).T, AngularMomentumLoweringOp(J) 37 | ) 38 | 39 | def test_expectation_value(self): 40 | J = 5 41 | dim = 2 * J + 1 42 | focks = np.diag(np.ones(dim)) 43 | 44 | np.testing.assert_allclose( 45 | expectation_value(focks, AngularMomentumRaisingOp(j=J)), 46 | 0.0, 47 | ) 48 | np.testing.assert_allclose( 49 | expectation_value(focks, AngularMomentumLoweringOp(j=J)), 50 | 0.0, 51 | ) 52 | np.testing.assert_allclose( 53 | expectation_value(focks, AngularMomentumProjectionOp(j=J)), 54 | np.arange(-J, +J + 1), 55 | ) 56 | -------------------------------------------------------------------------------- /atomic_physics/tests/test_mg25.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import numpy as np 4 | 5 | from atomic_physics.ions.mg25 import Mg25 6 | from atomic_physics.utils import field_insensitive_point 7 | 8 | 9 | class TestMg25(unittest.TestCase): 10 | """Tests of Mg25 atomic structure data. 11 | 12 | References: 13 | [1] - R. Srinivas, Laser-free trapped-ion quantum logic with a radiofrequency 14 | magnetic field gradient, PhD Thesis, University of Colorado (2016) 15 | """ 16 | 17 | def test_s12_clock(self): 18 | """Compare data for the field-insensitive S1/2 3,1 -> S1/2 2,1 transition at 19 | 212.8 G to [1]. 20 | """ 21 | factory = Mg25.filter_levels(level_filter=(Mg25.S12,)) 22 | B0 = field_insensitive_point( 23 | factory, 24 | level_0=Mg25.S12, 25 | F_0=3, 26 | M_F_0=+1, 27 | level_1=Mg25.S12, 28 | F_1=2, 29 | M_F_1=+1, 30 | magnetic_field_guess=212.8e-4, 31 | ) 32 | np.testing.assert_allclose(B0, 212.8e-4, atol=1e-5) 33 | 34 | ion = factory(magnetic_field=B0) 35 | ref = ( 36 | [3, 2, 1.326456], 37 | [2, 2, 1.460516], 38 | [1, 2, 1.573543], 39 | [2, 1, 1.573432], 40 | [1, 1, 1.686459], 41 | [0, 1, 1.786044], 42 | [0, -1, 1.975445], 43 | ) 44 | for M_3, M_2, ref_freq in ref: 45 | freq = ion.get_transition_frequency_for_states( 46 | ( 47 | ion.get_state_for_F(Mg25.S12, F=3, M_F=M_3), 48 | ion.get_state_for_F(Mg25.S12, F=2, M_F=M_2), 49 | ), 50 | ) 51 | 52 | # FIXME: this does not agree to the level of precision I would expect 53 | # atol should be <1e3 54 | np.testing.assert_allclose(freq / (2 * np.pi), ref_freq * 1e9, atol=3e3) 55 | -------------------------------------------------------------------------------- /atomic_physics/tests/test_spin_half.py: -------------------------------------------------------------------------------- 1 | """Test Spin 1/2 nuclei""" 2 | 3 | import unittest 4 | 5 | import numpy as np 6 | import scipy.constants as consts 7 | 8 | from atomic_physics.core import Level 9 | from atomic_physics.ions.ba133 import Ba133 10 | 11 | 12 | def Lande_g(level: Level): 13 | """Returns the Lande g factor for a level.""" 14 | gL = 1 15 | gS = -consts.physical_constants["electron g factor"][0] 16 | 17 | S = level.S 18 | J = level.J 19 | L = level.L 20 | 21 | gJ = gL * (J * (J + 1) - S * (S + 1) + L * (L + 1)) / (2 * J * (J + 1)) + gS * ( 22 | J * (J + 1) + S * (S + 1) - L * (L + 1) 23 | ) / (2 * J * (J + 1)) 24 | return gJ 25 | 26 | 27 | def _gF(F, J, nuclear_spin, gJ): 28 | return ( 29 | gJ 30 | * (F * (F + 1) + J * (J + 1) - nuclear_spin * (nuclear_spin + 1)) 31 | / (2 * F * (F + 1)) 32 | ) 33 | 34 | 35 | class TestSpinHalf(unittest.TestCase): 36 | """Test for spin-half nuclei""" 37 | 38 | def test_hyperfine(self): 39 | level = Ba133.ground_level 40 | factory = Ba133.filter_levels(level_filter=(level,)) 41 | ion = factory(0.1e-4) 42 | 43 | E_0_0 = ion.state_energies[ion.get_state_for_F(level, F=0, M_F=0)] 44 | E_1_0 = ion.state_energies[ion.get_state_for_F(level, F=1, M_F=0)] 45 | 46 | Ahfs = 2 * np.pi * 9925.45355459e6 47 | 48 | self.assertAlmostEqual(abs(E_0_0 - E_1_0) / 1e6, Ahfs / 1e6, 4) 49 | 50 | def test_zeeman(self): 51 | level = Ba133.ground_level 52 | factory = Ba133.filter_levels(level_filter=(level,)) 53 | B = 10e-4 54 | ion = factory(B) 55 | 56 | E_1_0 = ion.state_energies[ion.get_state_for_F(level, F=1, M_F=0)] 57 | E_1_1 = ion.state_energies[ion.get_state_for_F(level, F=1, M_F=1)] 58 | 59 | gJ = Lande_g(level) 60 | gF = _gF(1, 1 / 2, 1 / 2, gJ) 61 | muB = consts.physical_constants["Bohr magneton"][0] 62 | E_Zeeman = gF * muB * B / consts.hbar 63 | 64 | self.assertAlmostEqual(abs(E_1_0 - E_1_1) / 1e6, E_Zeeman / 1e6, 0) 65 | -------------------------------------------------------------------------------- /atomic_physics/tests/test_atoms.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import numpy as np 4 | from scipy import constants as consts 5 | 6 | from atomic_physics.atoms.two_state import TwoStateAtom 7 | from atomic_physics.ions.ba133 import Ba133 8 | from atomic_physics.ions.ba135 import Ba135 9 | from atomic_physics.ions.ba137 import Ba137 10 | from atomic_physics.ions.ba138 import Ba138 11 | from atomic_physics.ions.ca40 import Ca40 12 | from atomic_physics.ions.ca43 import Ca43 13 | from atomic_physics.ions.mg25 import Mg25 14 | from atomic_physics.ions.sr88 import Sr88 15 | 16 | 17 | class TestAtoms(unittest.TestCase): 18 | """Basic smoke testing for all atom/ion class definitions.""" 19 | 20 | def test_atoms(self): 21 | atoms = ( 22 | Ba133, 23 | Ba135, 24 | Ba137, 25 | Ba138, 26 | Ca40, 27 | Ca43, 28 | Mg25, 29 | Sr88, 30 | TwoStateAtom, 31 | ) 32 | 33 | # check we can construct the atom without error 34 | for factory in atoms: 35 | atom = factory(magnetic_field=1.0) 36 | 37 | # we have a convention of naming atomic transitions by their wavelength 38 | # in nanometres so check those match the frequency data 39 | for transition_name, transition in atom.transitions.items(): 40 | np.testing.assert_allclose( 41 | consts.c / (transition.frequency / (2 * np.pi)) * 1e9, 42 | float(transition_name), 43 | atol=1, 44 | ) 45 | 46 | # check the pre-defined levels exist 47 | atoms_d = (Ba133, Ba135, Ba137, Ba138, Ca40, Ca43, Sr88) 48 | 49 | level_names = ("S12", "P12", "P32", "D32", "D52", "ground_level", "shelf") 50 | for factory in atoms_d: 51 | atom = factory(1.0) 52 | levels = [getattr(factory, level_name) for level_name in level_names] 53 | assert set(atom.levels) == set(levels) 54 | 55 | atoms_no_d = (Mg25,) 56 | level_names = ( 57 | "S12", 58 | "P12", 59 | "P32", 60 | "ground_level", 61 | ) 62 | for factory in atoms_no_d: 63 | atom = factory(1.0) 64 | levels = [getattr(factory, level_name) for level_name in level_names] 65 | assert set(atom.levels) == set(levels) 66 | 67 | atom = TwoStateAtom(magnetic_field=1.0) 68 | assert set(atom.levels) == set((TwoStateAtom.ground_level,)) 69 | assert TwoStateAtom.upper_state == 0 70 | assert TwoStateAtom.lower_state == 1 71 | -------------------------------------------------------------------------------- /atomic_physics/wigner.py: -------------------------------------------------------------------------------- 1 | """Wigner 3J symbols using the Racah formula.""" 2 | 3 | from functools import lru_cache 4 | 5 | import numpy as np 6 | 7 | _max_fact = 12 # 12 for int32, 20 for int64 8 | _fact_store = np.ones(_max_fact + 1, dtype=np.int32) 9 | 10 | 11 | for n in np.arange(_max_fact, dtype=np.int32): 12 | _fact_store[n + 1] = _fact_store[n] * np.int32(n + 1) 13 | 14 | 15 | def _fact(n: int | float): 16 | """Returns n factorial.""" 17 | assert n >= 0, str(n) 18 | return _fact_store[int(np.rint(n))] 19 | 20 | 21 | def wigner3j(j1: float, j2: float, j3: float, m1: float, m2: float, m3: float): 22 | """Returns the Wigner 3J symbol.""" 23 | 24 | # selection rules 25 | jT = np.int32(j1 + j2 + j3) 26 | if m1 + m2 + m3 != 0: 27 | return 0 28 | if abs(m1) > j1 or abs(m2) > j2 or abs(m3) > j3: 29 | return 0 30 | if j3 < np.abs(j1 - j2) or j3 > j1 + j2: 31 | return 0 32 | if jT != j1 + j2 + j3: 33 | return 0 34 | if not any([m1, m2]) and jT % 2 != 0: 35 | return 0 36 | 37 | # Use permutation relations to minimize required cache size: 38 | # - permute so that j1 <= j2 <= j3 39 | # - m1 >= 0 40 | sign = 0 41 | if j1 > j2: 42 | j1, j2 = j2, j1 43 | m1, m2 = m2, m1 44 | sign += 1 45 | if j2 > j3: 46 | j2, j3 = j3, j2 47 | m2, m3 = m3, m2 48 | sign += 1 49 | if m1 < 0: 50 | m1 = -m1 51 | m2 = -m2 52 | m3 = -m3 53 | sign += 1 54 | sign = (-1) ** (jT * sign) 55 | return sign * _wigner3j(j1, j2, j3, m1, m2, m3) 56 | 57 | 58 | @lru_cache(maxsize=512) 59 | def _wigner3j(j1: float, j2: float, j3: float, m1: float, m2: float, m3: float): 60 | sign = (-1) ** (np.abs(j1 - j2 - m3)) 61 | tri = np.sqrt( 62 | (_fact(j1 + j2 - j3) * _fact(j1 + j3 - j2) * _fact(j2 + j3 - j1)) 63 | / _fact(j1 + j2 + j3 + 1) 64 | ) 65 | pre = np.sqrt( 66 | np.prod( 67 | [_fact(j + m) * _fact(j - m) for (j, m) in [(j1, m1), (j2, m2), (j3, m3)]] 68 | ) 69 | ) 70 | 71 | kmax = int(np.rint(min([j1 + j2 - j3, j1 - m1, j2 + m2]))) 72 | kmin = int(np.rint(max([-(j3 - j2 + m1), -(j3 - j1 - m2), 0]))) 73 | 74 | fact = 0 75 | for k in range(kmin, kmax + 1): 76 | fact += (-1) ** k / ( 77 | _fact(k) 78 | * _fact(j1 + j2 - j3 - k) 79 | * _fact(j1 - m1 - k) 80 | * _fact(j2 + m2 - k) 81 | * _fact(j3 - j2 + m1 + k) 82 | * _fact(j3 - j1 - m2 + k) 83 | ) 84 | return sign * tri * pre * fact 85 | -------------------------------------------------------------------------------- /docs/getting_started.rst: -------------------------------------------------------------------------------- 1 | .. _getting_started: 2 | 3 | Getting Started 4 | =============== 5 | 6 | Installation 7 | ~~~~~~~~~~~~ 8 | 9 | Install from `pypi` with 10 | 11 | .. code-block:: bash 12 | 13 | pip install atomic_physics 14 | 15 | or add to your poetry project with 16 | 17 | .. code-block:: bash 18 | 19 | poetry add atomic_physics 20 | 21 | Basic usage 22 | ~~~~~~~~~~~ 23 | 24 | The heart of the ``atomic_physics`` package is the :class:`~atomic_physics.core.Atom`, 25 | which represents a particular atomic species at a given magnetic field. 26 | 27 | :class:`~atomic_physics.core.AtomFactory` provides a flexible means of constructing 28 | :class:`~atomic_physics.core.Atom`\s. A number of 29 | :class:`~atomic_physics.core.AtomFactory`\s are available in the :ref:`atoms` and 30 | :ref:`ions` modules, providing pre-configured atom definitions based on accurate atomic 31 | physics data. 32 | 33 | The :class:`~atomic_physics.rate_equations.Rates` class provides a simple interface 34 | for performing rate equations simulations. 35 | 36 | Example Usage 37 | ~~~~~~~~~~~~~ 38 | 39 | As an example, we use the rate equations interface to simulate electron shelving in 40 | 43Ca+ - optical pumping from the ground-level ``F=4, M=+4`` "stretched state" to 41 | the 3D5/2 level using a 393nm laser. 42 | 43 | .. testcode:: 44 | 45 | """Simple rate equations example of 393 shelving in 43Ca+.""" 46 | 47 | import matplotlib.pyplot as plt 48 | import numpy as np 49 | from scipy.linalg import expm 50 | 51 | from atomic_physics.core import Laser 52 | from atomic_physics.ions.ca43 import Ca43 53 | from atomic_physics.rate_equations import Rates 54 | 55 | t_ax = np.linspace(0, 100e-6, 100) 56 | intensity = 0.02 # 393 intensity 57 | 58 | ion = Ca43(magnetic_field=146e-4) 59 | stretch = ion.get_state_for_F(Ca43.ground_level, F=4, M_F=+4) 60 | 61 | rates = Rates(ion) 62 | delta = ion.get_transition_frequency_for_states( 63 | (stretch, ion.get_state_for_F(Ca43.P32, F=5, M_F=+5)) 64 | ) 65 | lasers = ( 66 | Laser("393", polarization=+1, intensity=intensity, detuning=delta), 67 | ) # resonant 393 sigma+ 68 | trans = rates.get_transitions_matrix(lasers) 69 | 70 | Vi = np.zeros((ion.num_states, 1)) # initial state 71 | Vi[stretch] = 1 # start in F=4, M=+4 72 | shelved = np.zeros(len(t_ax)) 73 | for idx, t in np.ndenumerate(t_ax): 74 | Vf = expm(trans * t) @ Vi 75 | shelved[idx] = np.sum(Vf[ion.get_slice_for_level(Ca43.shelf)]) 76 | 77 | plt.plot(t_ax * 1e6, shelved) 78 | plt.ylabel("Shelved Population") 79 | plt.xlabel("Shelving time (us)") 80 | plt.grid() 81 | plt.show() 82 | -------------------------------------------------------------------------------- /atomic_physics/tests/test_ca43.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import numpy as np 4 | 5 | from atomic_physics.ions.ca43 import Ca43 6 | from atomic_physics.utils import field_insensitive_point 7 | 8 | 9 | class TestCa43(unittest.TestCase): 10 | """Tests for calcium 43 atomic structure. 11 | 12 | References: 13 | [1] J. Benhelm, et al., PHYSICAL REVIEW A 75, 032506 (2007) 14 | [2] T. Harty DPhil Thesis (2013) 15 | """ 16 | 17 | def test_s12_d52_clock(self): 18 | """Compare values we compute for the for field-insensitive 19 | S1/2 4,4 -> D5/2 4,3 transition at 3.38 G and 4.96 G with [1]. 20 | """ 21 | factory = Ca43.filter_levels(level_filter=(Ca43.S12, Ca43.D52)) 22 | # Start with the field-insensitive transition at around 3G 23 | B0 = field_insensitive_point( 24 | factory, 25 | level_0=Ca43.S12, 26 | F_0=4, 27 | M_F_0=+4, 28 | level_1=Ca43.D52, 29 | F_1=4, 30 | M_F_1=+3, 31 | magnetic_field_guess=3.38e-4, 32 | ) 33 | np.testing.assert_allclose(B0, 3.38e-4, atol=1e-6) 34 | 35 | # Field-insensitive transition at around 5G 36 | B0 = field_insensitive_point( 37 | factory, 38 | level_0=Ca43.S12, 39 | F_0=4, 40 | M_F_0=+4, 41 | level_1=Ca43.D52, 42 | F_1=4, 43 | M_F_1=+3, 44 | magnetic_field_guess=5e-4, 45 | ) 46 | np.testing.assert_allclose(B0, 4.96e-4, atol=1e-6) 47 | 48 | def test_s12_40_31_clock(self): 49 | """Compare values we compute for the field-insensitive S1/2 4,0 -> S1/2 3,1 50 | transition at 146.094G to [1]. 51 | """ 52 | factory = Ca43.filter_levels(level_filter=(Ca43.S12,)) 53 | B0 = field_insensitive_point( 54 | factory, 55 | level_0=Ca43.S12, 56 | F_0=4, 57 | M_F_0=0, 58 | level_1=Ca43.S12, 59 | F_1=3, 60 | M_F_1=+1, 61 | magnetic_field_guess=140e-4, 62 | ) 63 | np.testing.assert_allclose(B0, 146.0942e-4, atol=1e-8) 64 | 65 | def test_s12_41_31_clock(self): 66 | """Compare values we compute for field-insensitive S1/2 4,1 -> S1/2 3,1 67 | transition at 288G to [1]. 68 | """ 69 | factory = Ca43.filter_levels(level_filter=(Ca43.S12,)) 70 | B0 = field_insensitive_point( 71 | factory, 72 | level_0=Ca43.S12, 73 | F_0=4, 74 | M_F_0=+1, 75 | level_1=Ca43.S12, 76 | F_1=3, 77 | M_F_1=+1, 78 | magnetic_field_guess=288e-4, 79 | ) 80 | np.testing.assert_allclose(B0, 287.7827e-4, atol=1e-8) 81 | -------------------------------------------------------------------------------- /atomic_physics/operators.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | 4 | def AngularMomentumRaisingOp(j: float) -> np.ndarray: 5 | r"""Angular momentum raising operator (:math:`J_+`) represented in the basis of 6 | angular momentum eigenstates. 7 | 8 | Basis states are labelled in order of increasing :math:`M_J`. 9 | 10 | The returned operator is defined so that: 11 | 12 | .. math:: 13 | 14 | J_{+\left(n, m\right)} &:= \left\\ 15 | & = \delta\left(n,m+1\right)\sqrt{(J-m)(J+m+1)} 16 | 17 | This operator is related to the spherical basis operator :math:`J_{+1}` by: 18 | 19 | .. math:: 20 | 21 | J_{+1} = -\frac{1}{\sqrt{2}} J_+ 22 | 23 | """ 24 | Mj = np.arange(-j, j) 25 | return np.diag(np.sqrt((j - Mj) * (j + Mj + 1)), -1) 26 | 27 | 28 | def AngularMomentumLoweringOp(j: float) -> np.ndarray: 29 | r"""Angular momentum lowering operator (:math:`J_-`) represented in the basis of 30 | angular momentum eigenstates. 31 | 32 | Basis states are labelled in order of increasing :math:`M_J`. 33 | 34 | The returned operator is defined so that: 35 | 36 | .. math:: 37 | 38 | J_{-\left(n, m\right)} &:= \left\\ 39 | & = \delta\left(n,m-1\right)\sqrt{(J+m)(J-m+1)} 40 | 41 | This operator is related to the spherical basis operator :math:`J_{-1}` by: 42 | 43 | .. math:: 44 | 45 | J_{-1} = +\frac{1}{\sqrt{2}} J_- 46 | 47 | """ 48 | return AngularMomentumRaisingOp(j).T 49 | 50 | 51 | def AngularMomentumProjectionOp(j: float) -> np.ndarray: 52 | r"""Angular momentum projection operation represented in the basis of 53 | angular momentum eigenstates. 54 | 55 | Basis states are labelled in order of increasing :math:`M_J`. 56 | 57 | The returned operator is defined so that: 58 | 59 | .. math:: 60 | 61 | J_{z\left(n, m\right)} &:= \left\\ 62 | & = m\delta\left(n,m\right) 63 | """ 64 | Mj = np.arange(-j, j + 1) 65 | return np.diag(Mj) 66 | 67 | 68 | def expectation_value(state_vectors: np.ndarray, operator: np.ndarray) -> np.ndarray: 69 | """Calculates the expectation value of an operator for a given set of states in a 70 | given basis. 71 | 72 | :param state_vectors: array of shape ``(num_basis_states, num_states)`` where 73 | ``num_basis_states`` is the number of states in the basis that ``operator`` is 74 | represented in and ``num_states`` is the number of states to calculate the 75 | expectation value for. The vector ``state_vector[:, state_index]`` should give 76 | the representation of state ``state_index`` in the basis. 77 | :param operator: operator represented in the basis given by ``state_vectors``. 78 | :return: a vector, giving the expectation value of ``operator`` for each state in 79 | ``state_vectors``. 80 | """ 81 | return np.diag(state_vectors.conj().T @ operator @ state_vectors) 82 | -------------------------------------------------------------------------------- /atomic_physics/ions/sr88.py: -------------------------------------------------------------------------------- 1 | r""":math:`^{88}\mathrm{Sr}^+` 2 | 3 | References:: 4 | 5 | * [1] A. Kramida, NIST Atomic Spectra Database (ver. 5.9) (2021) 6 | * [2] P. Dubé, Metrologia (2015) 7 | * [3] V. Letchumanan, Phys. Rev. A (2005) 8 | 9 | """ 10 | 11 | import numpy as np 12 | 13 | from atomic_physics.core import AtomFactory, Level, LevelData, Transition 14 | 15 | 16 | class Sr88Factory(AtomFactory): 17 | r""":class:`~atomic_physics.core.AtomFactory` for :math:`^{88}\mathrm{Sr}^+`. 18 | 19 | Attributes: 20 | S12: the :math:`\left|n=5, S=1/2, L=0, J=1/2\right>` level. 21 | P12: the :math:`\left|n=5, S=1/2, L=1, J=1/2\right>` level. 22 | P32: the :math:`\left|n=5, S=1/2, L=1, J=3/2\right>` level. 23 | D32: the :math:`\left|n=4, S=1/2, L=2, J=3/2\right>` level. 24 | D52: the :math:`\left|n=4, S=1/2, L=2, J=5/2\right>` level. 25 | 26 | ground_level: alias for the :math:`\left|n=5, S=1/2, L=0, J=1/2\right>` ground 27 | level. 28 | shelf: alias for the :math:`\left|n=4, S=1/2, L=2, J=5/2\right>` "shelf" level. 29 | """ 30 | 31 | S12: Level = Level(n=5, S=1 / 2, L=0, J=1 / 2) 32 | P12: Level = Level(n=5, S=1 / 2, L=1, J=1 / 2) 33 | P32: Level = Level(n=5, S=1 / 2, L=1, J=3 / 2) 34 | D32: Level = Level(n=4, S=1 / 2, L=2, J=3 / 2) 35 | D52: Level = Level(n=4, S=1 / 2, L=2, J=5 / 2) 36 | 37 | ground_level: Level = S12 38 | shelf: Level = D52 39 | 40 | def __init__(self): 41 | level_data = ( 42 | LevelData(level=self.S12, Ahfs=0, Bhfs=0), 43 | LevelData(level=self.P12, Ahfs=0, Bhfs=0), 44 | LevelData(level=self.P32, Ahfs=0, Bhfs=0), 45 | LevelData(level=self.D32, Ahfs=0, Bhfs=0), 46 | LevelData(level=self.D52, Ahfs=0, Bhfs=0), 47 | ) 48 | 49 | transitions = { 50 | "422": Transition( 51 | lower=self.S12, 52 | upper=self.P12, 53 | einstein_A=127.9e6, # [1] 54 | frequency=2 * np.pi * 711162972859365.0, # [1] 55 | ), 56 | "408": Transition( 57 | lower=self.S12, 58 | upper=self.P32, 59 | einstein_A=141e6, # [1] 60 | frequency=2 * np.pi * 735197363032326.0, # [1] 61 | ), 62 | "1092": Transition( 63 | lower=self.D32, 64 | upper=self.P12, 65 | einstein_A=7.46e6, # [1] 66 | frequency=2 * np.pi * 274664149123480.0, # [1] 67 | ), 68 | "1004": Transition( 69 | lower=self.D32, 70 | upper=self.P32, 71 | einstein_A=1.0e6, # [1] 72 | frequency=2 * np.pi * 298697611773804.0, # [1] 73 | ), 74 | "1033": Transition( 75 | lower=self.D52, 76 | upper=self.P32, 77 | einstein_A=8.7e6, # [1] 78 | frequency=2 * np.pi * 290290973185754.0, # [1] 79 | ), 80 | "674": Transition( 81 | lower=self.S12, 82 | upper=self.D52, 83 | einstein_A=2.55885, # [3] 84 | frequency=2 * np.pi * 444779044095485.27, # [2] 85 | ), 86 | "687": Transition( 87 | lower=self.S12, 88 | upper=self.D32, 89 | einstein_A=2.299, # [1] 90 | frequency=2 * np.pi * 436495331872197.0, # [1] 91 | ), 92 | } 93 | 94 | super().__init__( 95 | nuclear_spin=0.0, level_data=level_data, transitions=transitions 96 | ) 97 | 98 | 99 | Sr88 = Sr88Factory() 100 | r""" :math:`^{88}\mathrm{Sr}^+` atomic structure. """ 101 | -------------------------------------------------------------------------------- /atomic_physics/ions/mg25.py: -------------------------------------------------------------------------------- 1 | r""":math:`^{25}\mathrm{Mg}^+` 2 | 3 | References:: 4 | 5 | * [1] W. M. Itano and D. J. Wineland, Precision measurement of the 6 | ground-state hyperfine constant of Mg+, PRA, 24, 3 (1981) 7 | * [2] W. H. Yuan et. al., Precision measurement of the light shift of 8 | 25Mg+ions, Phys. Rev. A, 98, 5 (2018) 9 | * [3] G. Clos et. al., Decoherence-Assisted Spectroscopy of a Single 10 | Mg+ Ion, Phys. Rev. Lett., 112, 11 (2014) 11 | * [4] M. Kaur et. al., Radiative transition properties of singly charged 12 | magnesium, calcium, strontium and barium ions, Atomic Data and Nuclear 13 | Data Tables, 137 (2021) 14 | * [5] Z. T. Xu et. al., Precision measurement of the 25Mg+ 15 | ground-state hyperfine constant, Phys. Rev. A, 96, 5, (2017) 16 | * [6] J. Nguyen, The Linewidth and Hyperfine A Constant of the 2P1/2 State 17 | of a Magnesium Ion Confined in a Linear Paul Trap, Thesis, 18 | McMaster University (2009) http://hdl.handle.net/11375/17398 19 | * [7] N. J. Stone, Table of nuclear magnetic dipole and electric 20 | quadrupole moments, Atomic Data and Nuclear Data Tables, Volume 90, 21 | Issue 1 (2005) 22 | * [8] L. Toppozini, Trapped-Mg+ Apparatus for Control and Structure Studies, 23 | Thesis, McMaster University (2006) http://hdl.handle.net/11375/21342 24 | 25 | """ 26 | 27 | import numpy as np 28 | import scipy.constants as consts 29 | 30 | from atomic_physics.core import AtomFactory, Level, LevelData, Transition 31 | 32 | 33 | class Mg25Factory(AtomFactory): 34 | r""":class:`~atomic_physics.core.AtomFactory` for :math:`^{25}\mathrm{Mg}^+`. 35 | 36 | Attributes: 37 | S12: the :math:`\left|n=3, S=1/2, L=0, J=1/2\right>` level. 38 | P12: the :math:`\left|n=3, S=1/2, L=1, J=1/2\right>` level. 39 | P32: the :math:`\left|n=3, S=1/2, L=1, J=3/2\right>` level. 40 | ground_level: alias for the :math:`\left|n=3, S=1/2, L=0, J=1/2\right>` ground 41 | level. 42 | """ 43 | 44 | S12: Level = Level(n=3, S=1 / 2, L=0, J=1 / 2) 45 | P12: Level = Level(n=3, S=1 / 2, L=1, J=1 / 2) 46 | P32: Level = Level(n=3, S=1 / 2, L=1, J=3 / 2) 47 | 48 | ground_level: Level = S12 49 | 50 | def __init__(self): 51 | level_data = ( 52 | LevelData( 53 | level=self.S12, 54 | Ahfs=-596.2542487e6 * consts.h, # [5] (or —596.254376(54)e6 [1]) 55 | Bhfs=0, 56 | g_J=2.002, # [1] (approximate) 57 | g_I=(2 / 5) * -0.85545, # [7] 58 | ), 59 | LevelData( 60 | level=self.P12, 61 | Ahfs=102.16e6 * consts.h, # [6] 62 | Bhfs=0, 63 | g_I=(2 / 5) * -0.85545, # [7] 64 | ), 65 | LevelData( 66 | level=self.P32, 67 | Ahfs=-19.0972e6 * consts.h, # [8] 68 | Bhfs=22.3413e6 * consts.h, # [8] 69 | g_I=(2 / 5) * -0.85545, # [7] 70 | ), 71 | ) 72 | 73 | transitions = { 74 | "280": Transition( 75 | lower=self.S12, 76 | upper=self.P12, 77 | einstein_A=5.58e8, # [4] 78 | frequency=1069.339957e12 * 2 * np.pi, # [3] 79 | ), 80 | "279": Transition( 81 | lower=self.S12, 82 | upper=self.P32, 83 | einstein_A=2.60e8, # [4] 84 | frequency=1072.084547e12 85 | * 2 86 | * np.pi, # [3] (or 1072084547e6 * 2 * np.pi [2]) 87 | ), 88 | } 89 | 90 | super().__init__( 91 | nuclear_spin=5 / 2, level_data=level_data, transitions=transitions 92 | ) 93 | 94 | 95 | Mg25 = Mg25Factory() 96 | r""" :math:`^{25}\mathrm{Mg}^+` atomic structure. """ 97 | -------------------------------------------------------------------------------- /atomic_physics/ions/ca40.py: -------------------------------------------------------------------------------- 1 | r""":math:`^{40}\mathrm{Ca}^+` 2 | 3 | References:: 4 | 5 | * [1] A. Kramida, At. Data Nucl. Data Tables 133-134, 101322 (2020) 6 | * [2] T. P. Harty, DPhil Thesis (2013) 7 | * [3] M. Chwalla et all, PRL 102, 023002 (2009) 8 | 9 | """ 10 | 11 | import numpy as np 12 | 13 | from atomic_physics.core import AtomFactory, Level, LevelData, Transition 14 | 15 | # level aliases 16 | ground_level = S12 = Level 17 | P12 = Level 18 | P32 = Level 19 | D32 = Level 20 | shelf = D52 = Level 21 | 22 | 23 | class Ca40Factory(AtomFactory): 24 | r""":class:`~atomic_physics.core.AtomFactory` for :math:`^{40}\mathrm{Ca}^+`. 25 | 26 | Attributes: 27 | S12: the :math:`\left|n=4, S=1/2, L=0, J=1/2\right>` level. 28 | P12: the :math:`\left|n=4, S=1/2, L=1, J=1/2\right>` level. 29 | P32: the :math:`\left|n=4, S=1/2, L=1, J=3/2\right>` level. 30 | D32: the :math:`\left|n=3, S=1/2, L=2, J=3/2\right>` level. 31 | D52: the :math:`\left|n=3, S=1/2, L=2, J=5/2\right>` level. 32 | ground_level: alias for the :math:`\left|n=4, S=1/2, L=0, J=1/2\right>` ground 33 | level. 34 | shelf: alias for the :math:`\left|n=3, S=1/2, L=2, J=5/2\right>` "shelf" level. 35 | """ 36 | 37 | S12: Level = Level(n=4, S=1 / 2, L=0, J=1 / 2) 38 | P12: Level = Level(n=4, S=1 / 2, L=1, J=1 / 2) 39 | P32: Level = Level(n=4, S=1 / 2, L=1, J=3 / 2) 40 | D32: Level = Level(n=3, S=1 / 2, L=2, J=3 / 2) 41 | D52: Level = Level(n=3, S=1 / 2, L=2, J=5 / 2) 42 | 43 | ground_level: Level = S12 44 | shelf: Level = D52 45 | 46 | def __init__(self): 47 | level_data = ( 48 | LevelData(level=self.S12, g_J=2.00225664, Ahfs=0, Bhfs=0), # [2] 49 | LevelData(level=self.P12, Ahfs=0, Bhfs=0), 50 | LevelData(level=self.P32, Ahfs=0, Bhfs=0), 51 | LevelData(level=self.D32, Ahfs=0, Bhfs=0), 52 | LevelData(level=self.D52, g_J=1.2003340, Ahfs=0, Bhfs=0), # [3] 53 | ) 54 | 55 | transitions = { 56 | "397": Transition( 57 | lower=self.S12, 58 | upper=self.P12, 59 | einstein_A=132e6, # [?] 60 | frequency=2 * np.pi * 755222765771e3, # [1] 61 | ), 62 | "393": Transition( 63 | lower=self.S12, 64 | upper=self.P32, 65 | einstein_A=135e6, # [?] 66 | frequency=2 * np.pi * 761905012599e3, # [1] 67 | ), 68 | "866": Transition( 69 | lower=self.D32, 70 | upper=self.P12, 71 | einstein_A=8.4e6, # [?] 72 | frequency=2 * np.pi * 346000235016e3, # [1] 73 | ), 74 | "850": Transition( 75 | lower=self.D32, 76 | upper=self.P32, 77 | einstein_A=0.955e6, # [?] 78 | frequency=2 * np.pi * 352682481844e3, # [1] 79 | ), 80 | "854": Transition( 81 | lower=self.D52, 82 | upper=self.P32, 83 | einstein_A=8.48e6, # [?] 84 | frequency=2 * np.pi * 350862882823e3, # [1] 85 | ), 86 | "729": Transition( 87 | lower=self.S12, 88 | upper=self.D52, 89 | einstein_A=0.856, # [?] 90 | frequency=411042129776.4017e3 * 2 * np.pi, # [1] 91 | ), 92 | "733": Transition( 93 | lower=self.S12, 94 | upper=self.D32, 95 | einstein_A=0.850, # [?] 96 | frequency=409222530754.868e3 * 2 * np.pi, # [1] 97 | ), 98 | } 99 | 100 | super().__init__( 101 | nuclear_spin=0.0, level_data=level_data, transitions=transitions 102 | ) 103 | 104 | 105 | Ca40 = Ca40Factory() 106 | r""" :math:`^{40}\mathrm{Ca}^+` atomic structure. """ 107 | -------------------------------------------------------------------------------- /atomic_physics/ions/ba138.py: -------------------------------------------------------------------------------- 1 | r""":math:`^{138}\mathrm{Ba}^+` 2 | 3 | Where no references are given next to the transition frequencies, they 4 | were calculated based on transition frequencies between other levels. 5 | For example the frequency of the 1762 nm transition f(1762) was found 6 | from f(1762)=f(455)-f(614). 7 | 8 | References:: 9 | 10 | * [1] A. Kramida, NIST Atomic Spectra Database (ver. 5.9) (2021) 11 | * [2] Zhiqiang Zhang, K. J. Arnold, S. R. Chanu, R. Kaewuam, 12 | M. S. Safronova, and M. D. Barrett Phys. Rev. A 101, 062515 (2020) 13 | * [3] N. Yu, W. Nagourney, and H. Dehmelt, Phys. Rev. Lett. 78, 4898 (1997) 14 | * [4] K. H. Knoll et al., PRA54 1199 (1996) 15 | * [5] O. Poulson & P.J. Ramanujam, PRA 14 1463 (1976) 16 | * [6] N. Kurz et al., PRA 82 030501 (2010) 17 | 18 | """ 19 | 20 | import numpy as np 21 | 22 | from atomic_physics.core import AtomFactory, Level, LevelData, Transition 23 | 24 | 25 | class Ba138Factory(AtomFactory): 26 | r""":class:`~atomic_physics.core.AtomFactory` for :math:`^{138}\mathrm{Ba}^+`. 27 | 28 | Attributes: 29 | S12: the :math:`\left|n=6, S=1/2, L=0, J=1/2\right>` level. 30 | P12: the :math:`\left|n=6, S=1/2, L=1, J=1/2\right>` level. 31 | P32: the :math:`\left|n=6, S=1/2, L=1, J=3/2\right>` level. 32 | D32: the :math:`\left|n=5, S=1/2, L=2, J=3/2\right>` level. 33 | D52: the :math:`\left|n=5, S=1/2, L=2, J=5/2\right>` level. 34 | ground_level: alias for the :math:`\left|n=6, S=1/2, L=0, J=1/2\right>` ground 35 | level. 36 | shelf: alias for the :math:`\left|n=5, S=1/2, L=2, J=5/2\right>` "shelf" level. 37 | """ 38 | 39 | S12: Level = Level(n=6, S=1 / 2, L=0, J=1 / 2) 40 | P12: Level = Level(n=6, S=1 / 2, L=1, J=1 / 2) 41 | P32: Level = Level(n=6, S=1 / 2, L=1, J=3 / 2) 42 | D32: Level = Level(n=5, S=1 / 2, L=2, J=3 / 2) 43 | D52: Level = Level(n=5, S=1 / 2, L=2, J=5 / 2) 44 | 45 | ground_level: Level = S12 46 | shelf: Level = D52 47 | 48 | def __init__(self): 49 | level_data = ( 50 | LevelData(level=self.S12, g_J=2.0024922, Ahfs=0, Bhfs=0), # [4] 51 | LevelData(level=self.P12, g_J=0.672, Ahfs=0, Bhfs=0), # [5] 52 | LevelData(level=self.P32, g_J=1.328, Ahfs=0, Bhfs=0), # [5] 53 | LevelData(level=self.D32, g_J=0.7993278, Ahfs=0, Bhfs=0), # [4] 54 | LevelData(level=self.D52, g_J=1.2020, Ahfs=0, Bhfs=0), # [6] 55 | ) 56 | 57 | transitions = { 58 | "493": Transition( 59 | lower=self.S12, 60 | upper=self.P12, 61 | einstein_A=9.53e7, # [1] 62 | frequency=2 * np.pi * 607426317510693.9, # [1] 63 | ), 64 | "455": Transition( 65 | lower=self.S12, 66 | upper=self.P32, 67 | einstein_A=1.11e8, # [1] 68 | frequency=2 * np.pi * 658116515416903.1, # [1] 69 | ), 70 | "650": Transition( 71 | lower=self.D32, 72 | upper=self.P12, 73 | einstein_A=3.1e7, # [1] 74 | frequency=2 * np.pi * 461311910409872.25, # [1] 75 | ), 76 | "585": Transition( 77 | lower=self.D32, 78 | upper=self.P32, 79 | einstein_A=6.0e6, # [1] 80 | frequency=2 * np.pi * 512002108316081.56, # [1] 81 | ), 82 | "614": Transition( 83 | lower=self.D52, 84 | upper=self.P32, 85 | einstein_A=4.12e7, # [1] 86 | frequency=2 * np.pi * 487990081496342.56, # [1] 87 | ), 88 | "1762": Transition( 89 | lower=self.S12, 90 | upper=self.D52, 91 | einstein_A=1 / 30.14, # [2] 92 | frequency=2 * np.pi * 170126433920560.6, 93 | ), 94 | "2051": Transition( 95 | lower=self.S12, 96 | upper=self.D32, 97 | einstein_A=12.5e-3, # [3] 98 | frequency=2 * np.pi * 146114407100821.62, 99 | ), 100 | } 101 | 102 | super().__init__( 103 | nuclear_spin=0.0, level_data=level_data, transitions=transitions 104 | ) 105 | 106 | 107 | Ba138 = Ba138Factory() 108 | r""" :math:`^{138}\mathrm{Ba}^+` atomic structure. """ 109 | -------------------------------------------------------------------------------- /atomic_physics/tests/test_spont.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import numpy as np 4 | 5 | from atomic_physics.ions.ca43 import Ca43 6 | 7 | from .utils import wigner_3j, wigner_6j 8 | 9 | 10 | class TestGamma(unittest.TestCase): 11 | def test_LF(self): 12 | """Check that, in the low-field, our scattering rates match a more direct 13 | calculation. 14 | """ 15 | ion = Ca43(magnetic_field=1e-8) 16 | Gamma_ion = np.abs(ion.get_electric_multipoles()) ** 2 17 | Idim = int(np.rint(2 * ion.nuclear_spin + 1)) 18 | Gamma = np.zeros((ion.num_states, ion.num_states)) 19 | 20 | for name, transition in ion.transitions.items(): 21 | A = transition.einstein_A 22 | lower = transition.lower 23 | upper = transition.upper 24 | Ju = upper.J 25 | Jl = lower.J 26 | Jdim_l = int(np.rint(2 * Jl + 1)) 27 | l_dim = Idim * Jdim_l 28 | 29 | dJ = Ju - Jl 30 | dL = upper.L - lower.L 31 | if dJ in [-1, 0, +1] and dL in [-1, 0, +1]: 32 | order = 1 33 | elif abs(dJ) in [0, 1, 2] and abs(dL) in [0, 1, 2]: 34 | order = 2 35 | else: 36 | raise ValueError("Unsupported transition order {}".format(order)) 37 | 38 | subspace = np.r_[ 39 | ion.get_slice_for_level(lower), ion.get_slice_for_level(upper) 40 | ] 41 | 42 | for l_ind in list(subspace[:l_dim]): 43 | for u_ind in list(subspace[l_dim:]): 44 | Fl = ion.F[l_ind] 45 | Fu = ion.F[u_ind] 46 | Ml = ion.M[l_ind] 47 | Mu = ion.M[u_ind] 48 | q = Mu - Ml 49 | if q not in range(-order, order + 1): 50 | continue 51 | 52 | Gamma[l_ind, u_ind] = A * ( 53 | (2 * Ju + 1) 54 | * (2 * Fl + 1) 55 | * (2 * Fu + 1) 56 | * (wigner_3j(Fu, order, Fl, -Mu, q, Ml)) ** 2 57 | * (wigner_6j(Ju, ion.nuclear_spin, Fu, Fl, order, Jl) ** 2) 58 | ) 59 | 60 | subspace = np.ix_(subspace, subspace) 61 | scale = np.max(np.max(np.abs(Gamma[subspace]))) 62 | eps = np.max(np.max(np.abs(Gamma[subspace] - Gamma_ion[subspace]))) 63 | self.assertLess(eps / scale, 1e-4) 64 | 65 | def test_HF(self): 66 | """Check that, in the high-field, our scattering rates match a more 67 | direct calculation.""" 68 | ion = Ca43(magnetic_field=1000) 69 | Gamma_ion = np.abs(ion.get_electric_multipoles()) ** 2 70 | Idim = int(np.rint(2 * ion.nuclear_spin + 1)) 71 | Gamma = np.zeros((ion.num_states, ion.num_states)) 72 | 73 | for _, transition in ion.transitions.items(): 74 | A = transition.einstein_A 75 | lower = transition.lower 76 | upper = transition.upper 77 | Ju = upper.J 78 | Jl = lower.J 79 | Jdim_l = int(np.rint(2 * Jl + 1)) 80 | l_dim = Idim * Jdim_l 81 | 82 | dJ = Ju - Jl 83 | dL = upper.L - lower.L 84 | if dJ in [-1, 0, +1] and dL in [-1, 0, +1]: 85 | order = 1 86 | elif abs(dJ) in [0, 1, 2] and abs(dL) in [0, 1, 2]: 87 | order = 2 88 | else: 89 | raise ValueError("Unsupported transition order {}".format(order)) 90 | 91 | subspace = np.r_[ 92 | ion.get_slice_for_level(lower), ion.get_slice_for_level(upper) 93 | ] 94 | 95 | for l_ind in list(subspace[:l_dim]): 96 | for u_ind in list(subspace[l_dim:]): 97 | if ion.M_I[l_ind] != ion.M_I[u_ind]: 98 | continue 99 | M_l = ion.M_J[l_ind] 100 | M_u = ion.M_J[u_ind] 101 | q = M_u - M_l 102 | if q not in range(-order, order + 1): 103 | continue 104 | Gamma[l_ind, u_ind] = ( 105 | A * (2 * Ju + 1) * (wigner_3j(Ju, order, Jl, -M_u, q, M_l)) ** 2 106 | ) 107 | 108 | subspace = np.ix_(subspace, subspace) 109 | scale = np.max(np.max(np.abs(Gamma[subspace]))) 110 | eps = np.max(np.max(np.abs(Gamma[subspace] - Gamma_ion[subspace]))) 111 | self.assertTrue(eps / scale < 1e-4) 112 | 113 | 114 | if __name__ == "__main__": 115 | unittest.main() 116 | -------------------------------------------------------------------------------- /atomic_physics/ions/ca43.py: -------------------------------------------------------------------------------- 1 | r""":math:`^{43}\mathrm{Ca}^+` 2 | 3 | References:: 4 | 5 | * [1] F. Arbes, et al., Zeitschrift fur Physik D: Atoms, Molecules and 6 | Clusters, 31, 27 (1994) 7 | * [2] G. Tommaseo, et al., The European Physical Journal D, 25 (2003) 8 | * [3] T. P. Harty, et al. Phys. Rev. Lett 113, 220501 (2014) 9 | * [4] W. Nortershauser, et al., The European Physical Journal D, 2 (1998) 10 | * [5] J. Benhelm, et al., PHYSICAL REVIEW A 75, 032506 (2007) 11 | * [6] A. Kramida, At. Data Nucl. Data Tables 133-134, 101322 (2020) 12 | 13 | """ 14 | 15 | import numpy as np 16 | import scipy.constants as consts 17 | 18 | from atomic_physics.core import AtomFactory, Level, LevelData, Transition 19 | 20 | 21 | class Ca43Factory(AtomFactory): 22 | r""":class:`~atomic_physics.core.AtomFactory` for :math:`^{43}\mathrm{Ca}^+`. 23 | 24 | Attributes: 25 | S12: the :math:`\left|n=4, S=1/2, L=0, J=1/2\right>` level. 26 | P12: the :math:`\left|n=4, S=1/2, L=1, J=1/2\right>` level. 27 | P32: the :math:`\left|n=4, S=1/2, L=1, J=3/2\right>` level. 28 | D32: the :math:`\left|n=3, S=1/2, L=2, J=3/2\right>` level. 29 | D52: the :math:`\left|n=3, S=1/2, L=2, J=5/2\right>` level. 30 | ground_level: alias for the :math:`\left|n=4, S=1/2, L=0, J=1/2\right>` ground 31 | level. 32 | shelf: alias for the :math:`\left|n=3, S=1/2, L=2, J=5/2\right>` "shelf" level. 33 | """ 34 | 35 | S12: Level = Level(n=4, S=1 / 2, L=0, J=1 / 2) 36 | P12: Level = Level(n=4, S=1 / 2, L=1, J=1 / 2) 37 | P32: Level = Level(n=4, S=1 / 2, L=1, J=3 / 2) 38 | D32: Level = Level(n=3, S=1 / 2, L=2, J=3 / 2) 39 | D52: Level = Level(n=3, S=1 / 2, L=2, J=5 / 2) 40 | 41 | ground_level: Level = S12 42 | shelf: Level = D52 43 | 44 | def __init__(self): 45 | level_data = ( 46 | LevelData( 47 | level=self.S12, 48 | Ahfs=-3225.60828640e6 * consts.h / 4, # [1] 49 | Bhfs=0, 50 | g_J=2.00225664, # [2] 51 | g_I=(2 / 7) * -1.315348, # [3] 52 | ), 53 | LevelData( 54 | level=self.P12, 55 | Ahfs=-145.4e6 * consts.h, 56 | Bhfs=0, 57 | g_I=(2 / 7) * -1.315348, # [4] # [3] 58 | ), 59 | LevelData( 60 | level=self.P32, 61 | Ahfs=-31.4e6 * consts.h, # [4] 62 | Bhfs=-6.9e6 * consts.h, # [4] 63 | g_I=(2 / 7) * -1.315348, # [3] 64 | ), 65 | LevelData( 66 | level=self.D32, 67 | Ahfs=-47.3e6 * consts.h, # [4] 68 | Bhfs=-3.7e6 * consts.h, # [4] 69 | g_I=(2 / 7) * -1.315348, # [3] 70 | ), 71 | LevelData( 72 | level=self.D52, 73 | Ahfs=-3.8931e6 * consts.h, # [5] 74 | Bhfs=-4.241e6 * consts.h, # [5] 75 | g_J=1.2003, 76 | g_I=(2 / 7) * -1.315348, # [3] 77 | ), 78 | ) 79 | 80 | transitions = { 81 | "397": Transition( 82 | lower=self.S12, 83 | upper=self.P12, 84 | einstein_A=132e6, # [?] 85 | frequency=2 * np.pi * 755223443.81e6, # [6] 86 | ), 87 | "393": Transition( 88 | lower=self.S12, 89 | upper=self.P32, 90 | einstein_A=135e6, # [?] 91 | frequency=2 * np.pi * 761905691.40e6, # [6] 92 | ), 93 | "866": Transition( 94 | lower=self.D32, 95 | upper=self.P12, 96 | einstein_A=8.4e6, # [?] 97 | frequency=2 * np.pi * 345996772.78e6, # [6] 98 | ), 99 | "850": Transition( 100 | lower=self.D32, 101 | upper=self.P32, 102 | einstein_A=0.955e6, # [?] 103 | frequency=2 * np.pi * 352679020.37e6, # [6] 104 | ), 105 | "854": Transition( 106 | lower=self.D52, 107 | upper=self.P32, 108 | einstein_A=8.48e6, # [?] 109 | frequency=350859426.91e6 * 2 * np.pi, # [6] 110 | ), 111 | "729": Transition( 112 | lower=self.S12, 113 | upper=self.D52, 114 | einstein_A=0.856, # [?] 115 | frequency=411046264.4881e6 * 2 * np.pi, # [6] 116 | ), 117 | "733": Transition( 118 | lower=self.S12, 119 | upper=self.D32, 120 | einstein_A=0.850, # [?]# 121 | frequency=409226671.03e6 * 2 * np.pi, # [6] 122 | ), 123 | } 124 | 125 | super().__init__( 126 | nuclear_spin=7 / 2, level_data=level_data, transitions=transitions 127 | ) 128 | 129 | 130 | Ca43 = Ca43Factory() 131 | r""" :math:`^{43}\mathrm{Ca}^+` atomic structure. """ 132 | -------------------------------------------------------------------------------- /atomic_physics/ions/ba133.py: -------------------------------------------------------------------------------- 1 | r""":math:`^{133}\mathrm{Ba}^+` 2 | 3 | The transition frequencies are calculated based on the :math:`^{138}\mathrm{Ba}^+` 4 | frequencies from [1] and the isotope shifts in the second reference listed next to 5 | the frequency. Where no references are given, the transition frequencies 6 | were calculated based on transition frequencies between other levels. 7 | For example the frequency of the 1762 nm transition f(1762) was found 8 | from f(1762)=f(455)-f(614). 9 | 10 | References:: 11 | 12 | * [1] A. Kramida, NIST Atomic Spectra Database (ver. 5.9) (2021) 13 | * [2] Zhiqiang Zhang, K. J. Arnold, S. R. Chanu, R. Kaewuam, 14 | M. S. Safronova, and M. D. Barrett Phys. Rev. A 101, 062515 (2020) 15 | * [3] N. Yu, W. Nagourney, and H. Dehmelt, Phys. Rev. Lett. 78, 4898 (1997) 16 | * [4] N. J. Stone, Table of nuclear magnetic dipole and electric 17 | quadrupole moments, Atomic Data and Nuclear Data Tables, Volume 90, Issue 1 (2005) 18 | * [5] H. Knab, K. H. Knöll, F. Scheerer and G. Werth, Zeitschrift für 19 | Physik D Atoms, Molecules and Clusters volume 25, pages205–208 (1993) 20 | * [6] David Hucul, Justin E. Christensen, Eric R. Hudson, and 21 | Wesley C. Campbell, Phys. Rev. Lett. 119, 100501 (2017) 22 | * [7] - J.E. Christensen, D. Hucul, W.C. Campbell et al., npj Quantum Inf 6, 35 23 | (2020). 24 | 25 | """ 26 | 27 | import numpy as np 28 | import scipy.constants as consts 29 | 30 | from atomic_physics.core import AtomFactory, Level, LevelData, Transition 31 | 32 | 33 | class Ba133Factory(AtomFactory): 34 | r""":class:`~atomic_physics.core.AtomFactory` for :math:`^{133}\mathrm{Ba}^+`. 35 | 36 | Attributes: 37 | S12: the :math:`\left|n=6, S=1/2, L=0, J=1/2\right>` level. 38 | P12: the :math:`\left|n=6, S=1/2, L=1, J=1/2\right>` level. 39 | P32: the :math:`\left|n=6, S=1/2, L=1, J=3/2\right>` level. 40 | D32: the :math:`\left|n=5, S=1/2, L=2, J=3/2\right>` level. 41 | D52: the :math:`\left|n=5, S=1/2, L=2, J=5/2\right>` level. 42 | ground_level: alias for the :math:`\left|n=6, S=1/2, L=0, J=1/2\right>` ground 43 | level. 44 | shelf: alias for the :math:`\left|n=5, S=1/2, L=2, J=5/2\right>` "shelf" level. 45 | """ 46 | 47 | S12: Level = Level(n=6, S=1 / 2, L=0, J=1 / 2) 48 | P12: Level = Level(n=6, S=1 / 2, L=1, J=1 / 2) 49 | P32: Level = Level(n=6, S=1 / 2, L=1, J=3 / 2) 50 | D32: Level = Level(n=5, S=1 / 2, L=2, J=3 / 2) 51 | D52: Level = Level(n=5, S=1 / 2, L=2, J=5 / 2) 52 | 53 | ground_level: Level = S12 54 | shelf: Level = D52 55 | 56 | def __init__(self): 57 | level_data = ( 58 | LevelData( 59 | level=self.ground_level, 60 | Ahfs=-9925.45355459e6 * consts.h, # [6] 61 | Bhfs=0, 62 | g_J=2.0024906, # [5] 63 | g_I=(2 / 1) * -0.77167, # [4] 64 | ), 65 | LevelData( 66 | level=self.P12, 67 | Ahfs=-1840e6 * consts.h, # [6] 68 | Bhfs=0, 69 | g_I=(2 / 1) * -0.77167, # [4] 70 | ), 71 | LevelData( 72 | level=self.P32, 73 | Ahfs=-311.5e6 * consts.h, # [7] 74 | Bhfs=0, 75 | g_I=(2 / 1) * -0.77167, # [4] 76 | ), 77 | LevelData( 78 | level=self.D32, 79 | Ahfs=-468.5e6 * consts.h, # [6] 80 | Bhfs=0, 81 | g_I=(2 / 1) * -0.77167, # [4] 82 | ), 83 | LevelData( 84 | level=self.D52, 85 | Ahfs=83e6 * consts.h / 3, # [7] 86 | Bhfs=0, 87 | g_I=(2 / 1) * -0.77167, # [4] 88 | ), 89 | ) 90 | 91 | transitions = { 92 | "493": Transition( 93 | lower=self.S12, 94 | upper=self.P12, 95 | einstein_A=9.53e7, # [1] 96 | frequency=2 * np.pi * 607426317511066.9, # [1], [6] 97 | ), 98 | "455": Transition( 99 | lower=self.S12, 100 | upper=self.P32, 101 | einstein_A=1.11e8, # [1] 102 | frequency=2 * np.pi * 658116515417261.1, # [1], [7] 103 | ), 104 | "650": Transition( 105 | lower=self.D32, 106 | upper=self.P12, 107 | einstein_A=3.1e7, # [1] 108 | frequency=2 * np.pi * 461311910410070.25, # [1], [6] 109 | ), 110 | "585": Transition( 111 | lower=self.D32, 112 | upper=self.P32, 113 | einstein_A=6.0e6, # [1] 114 | frequency=2 * np.pi * 512002108316264.5, 115 | ), 116 | "614": Transition( 117 | lower=self.D52, 118 | upper=self.P32, 119 | einstein_A=4.12e7, # [1] 120 | frequency=2 * np.pi * 487990081496558.56, # [1], [7] 121 | ), 122 | "1762": Transition( 123 | lower=self.S12, 124 | upper=self.D52, 125 | einstein_A=1 / 30.14, # [2] 126 | frequency=2 * np.pi * 170126433920702.56, 127 | ), 128 | "2051": Transition( 129 | lower=self.S12, 130 | upper=self.D32, 131 | einstein_A=12.5e-3, # [3] 132 | frequency=2 * np.pi * 146114407100996.62, 133 | ), 134 | } 135 | 136 | super().__init__( 137 | nuclear_spin=1 / 2, level_data=level_data, transitions=transitions 138 | ) 139 | 140 | 141 | Ba133 = Ba133Factory() 142 | r""" :math:`^{133}\mathrm{Ba}^+` atomic structure. """ 143 | -------------------------------------------------------------------------------- /atomic_physics/tests/test_rates.py: -------------------------------------------------------------------------------- 1 | """Test Rates Calculations""" 2 | 3 | import unittest 4 | 5 | import numpy as np 6 | 7 | from atomic_physics.core import Laser 8 | from atomic_physics.ions.ca43 import Ca43 9 | from atomic_physics.rate_equations import Rates 10 | 11 | 12 | def _steady_state_population(intensity: float): 13 | "Steady state population in the P-state for resonant intensity /I0" 14 | return intensity / (2 * intensity + 1) 15 | 16 | 17 | class TestTSS(unittest.TestCase): 18 | """Two-state system tests. 19 | 20 | The closed stretch cycling transition is used to make a two state system. 21 | """ 22 | 23 | def test_rates_relations(self): 24 | """Test the spontaneous rates satisfy relations in net rates 25 | 26 | This relation is used in the steady states tests. 27 | """ 28 | intensity_list = [1e-3, 1e-1, 0.3, 1, 1.0, 2, 10.0, 1.2e4] 29 | 30 | factory = Ca43.filter_levels(level_filter=(Ca43.ground_level, Ca43.P32)) 31 | ion = factory(magnetic_field=5e-4) 32 | s_idx = ion.get_state_for_F(Ca43.ground_level, F=4, M_F=+4) 33 | p_idx = ion.get_state_for_F(Ca43.P32, F=5, M_F=+5) 34 | 35 | rates = Rates(ion) 36 | detuning = ion.get_transition_frequency_for_states((s_idx, p_idx)) 37 | for intensity in intensity_list: 38 | Lasers = ( 39 | Laser("393", polarization=+1, intensity=intensity, detuning=detuning), 40 | ) # resonant 41 | trans = rates.get_transitions_matrix(Lasers) 42 | 43 | spont = rates.get_spont_matrix() 44 | r = spont[p_idx, p_idx] / (trans[p_idx, p_idx] + trans[p_idx, s_idx]) 45 | self.assertAlmostEqual(r, 1.0, places=7) 46 | 47 | def test_steady_state(self): 48 | """Check that the steady-state solution is found correctly.""" 49 | ion = Ca43(magnetic_field=5e-4) 50 | rates = Rates(ion) 51 | lasers = ( 52 | Laser("397", polarization=+1, intensity=1, detuning=0), 53 | Laser("397", polarization=-1, intensity=1, detuning=0), 54 | Laser("866", polarization=+1, intensity=1, detuning=0), 55 | Laser("866", polarization=-1, intensity=1, detuning=0), 56 | ) 57 | transitions = rates.get_transitions_matrix(lasers) 58 | steady_state = rates.get_steady_state_populations(transitions) 59 | np.testing.assert_allclose(transitions @ steady_state, 0, atol=2e-8) 60 | 61 | def test_steady_state_intensity(self): 62 | """Test the steady state intensity scaling""" 63 | 64 | # use both integers and floats 65 | intensity_list = [1e-3, 1e-1, 0.3, 1, 1.0, 2, 10.0, 1.2e4] 66 | 67 | factory = Ca43.filter_levels(level_filter=(Ca43.ground_level, Ca43.P32)) 68 | ion = factory(magnetic_field=5e-4) 69 | 70 | s_idx = ion.get_state_for_F(Ca43.ground_level, F=4, M_F=+4) 71 | p_idx = ion.get_state_for_F(Ca43.P32, F=5, M_F=+5) 72 | 73 | rates = Rates(ion) 74 | detuning = ion.get_transition_frequency_for_states((s_idx, p_idx)) 75 | 76 | for intensity in intensity_list: 77 | Lasers = ( 78 | Laser("393", polarization=+1, intensity=intensity, detuning=detuning), 79 | ) # resonant 80 | trans = rates.get_transitions_matrix(Lasers) 81 | 82 | Np_ss = _steady_state_population(intensity) 83 | # transition rates normalised by A coefficient 84 | dNp_dt = trans[p_idx, p_idx] * Np_ss + trans[p_idx, s_idx] * (1 - Np_ss) 85 | dNp_dt = dNp_dt / (trans[p_idx, p_idx] + trans[p_idx, s_idx]) 86 | self.assertAlmostEqual(0.0, dNp_dt, places=7) 87 | dNs_dt = trans[s_idx, p_idx] * Np_ss + trans[s_idx, s_idx] * (1 - Np_ss) 88 | dNs_dt = dNs_dt / (trans[s_idx, p_idx] + trans[s_idx, s_idx]) 89 | self.assertAlmostEqual(0.0, dNs_dt, places=7) 90 | 91 | def test_steady_state_detuning(self): 92 | """Test steady state detuning dependence""" 93 | 94 | # assume 1 saturation intensity 95 | factory = Ca43.filter_levels(level_filter=(Ca43.ground_level, Ca43.P32)) 96 | ion = factory(magnetic_field=5e-4) 97 | 98 | s_idx = ion.get_state_for_F(Ca43.ground_level, F=4, M_F=+4) 99 | p_idx = ion.get_state_for_F(Ca43.P32, F=5, M_F=+5) 100 | 101 | rates = Rates(ion) 102 | detuning = ion.get_transition_frequency_for_states((s_idx, p_idx)) 103 | 104 | Lasers = ( 105 | Laser("393", polarization=+1, intensity=1.0, detuning=detuning), 106 | ) # resonant 107 | trans = rates.get_transitions_matrix(Lasers) 108 | line_width = abs(trans[p_idx, p_idx] + trans[p_idx, s_idx]) 109 | 110 | # detuning scan relative to linewidth 111 | norm_detuning = [-1e4, 2.3e1, 2, -4, 0.5, 0] 112 | for det in norm_detuning: 113 | I_eff = 1 / (4 * det**2 + 1) 114 | Np_ss = _steady_state_population(I_eff) 115 | 116 | Lasers = ( 117 | Laser( 118 | "393", 119 | polarization=+1, 120 | intensity=1.0, 121 | detuning=detuning + line_width * det, 122 | ), 123 | ) 124 | trans = rates.get_transitions_matrix(Lasers) 125 | 126 | # transition rates normalised by A coefficient 127 | dNp_dt = trans[p_idx, p_idx] * Np_ss + trans[p_idx, s_idx] * (1 - Np_ss) 128 | dNp_dt = dNp_dt / (trans[p_idx, p_idx] + trans[p_idx, s_idx]) 129 | self.assertAlmostEqual(0.0, dNp_dt, places=7) 130 | dNs_dt = trans[s_idx, p_idx] * Np_ss + trans[s_idx, s_idx] * (1 - Np_ss) 131 | dNs_dt = dNs_dt / (trans[s_idx, p_idx] + trans[s_idx, s_idx]) 132 | self.assertAlmostEqual(0.0, dNs_dt, places=7) 133 | 134 | 135 | if __name__ == "__main__": 136 | unittest.main() 137 | -------------------------------------------------------------------------------- /atomic_physics/ions/ba135.py: -------------------------------------------------------------------------------- 1 | r""":math:`^{135}\mathrm{Ba}^+` 2 | 3 | The transition frequencies are calculated based on the :math:`^{138}\mathrm{Ba}^+` 4 | frequencies from [1] and the isotope shifts in the second reference listed next to 5 | the frequency. Where no references are given, the transition frequencies 6 | were calculated based on transition frequencies between other levels. 7 | For example the frequency of the 1762 nm transition f(1762) was found 8 | from f(1762)=f(455)-f(614). 9 | 10 | References:: 11 | 12 | * [1] A. Kramida, NIST Atomic Spectra Database (ver. 5.9) (2021) 13 | * [2] Zhiqiang Zhang, K. J. Arnold, S. R. Chanu, R. Kaewuam, 14 | M. S. Safronova, and M. D. Barrett Phys. Rev. A 101, 062515 (2020) 15 | * [3] N. Yu, W. Nagourney, and H. Dehmelt, Phys. Rev. Lett. 78, 4898 (1997) 16 | * [4] N. J. Stone, Table of nuclear magnetic dipole and electric 17 | quadrupole moments, Atomic Data and Nuclear Data Tables, Volume 90, Issue 1 (2005) 18 | * [5] H. Knab, K. H. Knöll, F. Scheerer and G. Werth, Zeitschrift für 19 | Physik D Atoms, Molecules and Clusters volume 25, pages205–208 (1993) 20 | * [6] W. Becker, G. Werth, Zeitschrift für Physik A Atoms and Nuclei, 21 | Volume 311, Issue 1-2, pp. 41-47 (1983) 22 | * [7] P Villemoes et al, J. Phys. B: At. Mol. Opt. Phys. 26 4289 (1993) 23 | * [8] Roger E. Silverans, Gustaaf Borghs, Peter De Bisschop, and 24 | Marleen Van Hove, Phys. Rev. A 33, 2117 (1986) 25 | * [9] K. Wendt, S. A. Ahmad, F. Buchinger, A. C. Mueller, R. Neugart, and 26 | E. -W. Otten, Zeitschrift für Physik A Atoms and Nuclei volume 318, 27 | pages 125–129 (1984) 28 | 29 | """ 30 | 31 | import numpy as np 32 | import scipy.constants as consts 33 | 34 | from atomic_physics.core import AtomFactory, Level, LevelData, Transition 35 | 36 | 37 | class Ba135Factory(AtomFactory): 38 | r""":class:`~atomic_physics.core.AtomFactory` for :math:`^{135}\mathrm{Ba}^+`. 39 | 40 | Attributes: 41 | S12: the :math:`\left|n=6, S=1/2, L=0, J=1/2\right>` level. 42 | P12: the :math:`\left|n=6, S=1/2, L=1, J=1/2\right>` level. 43 | P32: the :math:`\left|n=6, S=1/2, L=1, J=3/2\right>` level. 44 | D32: the :math:`\left|n=5, S=1/2, L=2, J=3/2\right>` level. 45 | D52: the :math:`\left|n=5, S=1/2, L=2, J=5/2\right>` level. 46 | ground_level: alias for the :math:`\left|n=6, S=1/2, L=0, J=1/2\right>` ground 47 | level. 48 | shelf: alias for the :math:`\left|n=5, S=1/2, L=2, J=5/2\right>` "shelf" level. 49 | """ 50 | 51 | S12: Level = Level(n=6, S=1 / 2, L=0, J=1 / 2) 52 | P12: Level = Level(n=6, S=1 / 2, L=1, J=1 / 2) 53 | P32: Level = Level(n=6, S=1 / 2, L=1, J=3 / 2) 54 | D32: Level = Level(n=5, S=1 / 2, L=2, J=3 / 2) 55 | D52: Level = Level(n=5, S=1 / 2, L=2, J=5 / 2) 56 | 57 | ground_level: Level = S12 58 | shelf: Level = D52 59 | 60 | def __init__(self): 61 | level_data = ( 62 | LevelData( 63 | level=self.ground_level, 64 | Ahfs=3591.67011745e6 * consts.h, # [6] 65 | Bhfs=0, 66 | g_J=2.0024906, # [5] 67 | g_I=(2 / 3) * 0.83794, # [4] 68 | ), 69 | LevelData( 70 | level=self.P12, 71 | Ahfs=664.6e6 * consts.h, # [7] 72 | Bhfs=0, 73 | g_I=(2 / 3) * 0.83794, # [4] 74 | ), 75 | LevelData( 76 | level=self.P32, 77 | Ahfs=113.0e6 * consts.h, # [7] 78 | Bhfs=59.0e6 * consts.h, # [7] 79 | g_I=(2 / 3) * 0.83794, # [4] 80 | ), 81 | LevelData( 82 | level=self.D32, 83 | Ahfs=169.5898e6 * consts.h, # [8] 84 | Bhfs=28.9528 * consts.h, # [8] 85 | g_I=(2 / 3) * 0.83794, # [4] 86 | ), 87 | LevelData( 88 | level=self.D52, 89 | Ahfs=-10.735e6 * consts.h, # [8] 90 | Bhfs=38.692e6 * consts.h, # [8] 91 | g_I=(2 / 3) * 0.83794, # [4] 92 | ), 93 | ) 94 | 95 | transitions = { 96 | "493": Transition( 97 | lower=self.S12, 98 | upper=self.P12, 99 | einstein_A=9.53e7, # [1] 100 | frequency=2 * np.pi * 607426317511042.5, # [1], [9] 101 | ), 102 | "455": Transition( 103 | lower=self.S12, 104 | upper=self.P32, 105 | einstein_A=1.11e8, # [1] 106 | frequency=2 * np.pi * 658116515417266.0, 107 | ), 108 | "650": Transition( 109 | lower=self.D32, 110 | upper=self.P12, 111 | einstein_A=3.1e7, # [1] 112 | frequency=2 * np.pi * 461311910409954.94, # [1], [7] 113 | ), 114 | "585": Transition( 115 | lower=self.D32, 116 | upper=self.P32, 117 | einstein_A=6.0e6, # [1] 118 | frequency=2 * np.pi * 512002108316178.56, # [1], [7] 119 | ), 120 | "614": Transition( 121 | lower=self.D52, 122 | upper=self.P32, 123 | einstein_A=4.12e7, # [1] 124 | frequency=2 * np.pi * 487990081496443.94, # [1], [7] 125 | ), 126 | "1762": Transition( 127 | lower=self.S12, 128 | upper=self.D52, 129 | einstein_A=1 / 30.14, # [2] 130 | frequency=2 * np.pi * 170126433920822.06, 131 | ), 132 | "2051": Transition( 133 | lower=self.S12, 134 | upper=self.D32, 135 | einstein_A=12.5e-3, # [3] 136 | frequency=2 * np.pi * 146114407101087.56, 137 | ), 138 | } 139 | 140 | super().__init__( 141 | nuclear_spin=3 / 2, level_data=level_data, transitions=transitions 142 | ) 143 | 144 | 145 | Ba135 = Ba135Factory() 146 | r""" :math:`^{135}\mathrm{Ba}^+` atomic structure. """ 147 | -------------------------------------------------------------------------------- /docs/changes.rst: -------------------------------------------------------------------------------- 1 | .. _changes: 2 | 3 | Change Log 4 | ========== 5 | 6 | v2.0.3 7 | ~~~~~~ 8 | 9 | Refactor of how we define ions and atoms. We now define a subclass of ``AtomFactory`` for each atom / ion definition. Pre-defined levels are now attributes of these classes. This change is partly motivated by making it easier to write species-agnostic code (makes it easier to write things like ``factory.S12`` rather than having to access module-level attributes as well as the factory object). 10 | 11 | v2.0 12 | ~~~~ 13 | 14 | This version gives the ``atomic_physics`` API a much needed tidy up. 15 | 16 | A major design goal for the tidy up was to make things more obvious for the user. This 17 | comes at the expense of some function names now being quite a bit more verbose, but 18 | that feels like a worthwhile price to pay for clarity! 19 | 20 | Misc: 21 | 22 | * We now have a documentation build. Closes `#32 `_ 23 | * States are now ordered in *decreasing* energy order, not increasing! This change allows 24 | the Pauli matrices to take on their customary meanings and signs, for example with 25 | :math:`\sigma_+` being the raising operator and energies being represented by 26 | :math:`+\frac{1}{2}\omega\sigma_z`. 27 | * Formatting and linting moved from flake8 and black to ruff 28 | * CI now checks type annotations using pytype 29 | * Fix assorted type annotation and docstring bugs 30 | * Significantly expanded test coverage 31 | * Significantly expanded documentation 32 | * Added helper functions to convert between different polarization representations 33 | * Added a helper function to calculate the Rayleigh range of a beam 34 | * Added a new ``polarizations`` module for representing and manipulating polarizations. 35 | * Added a new ``RFDrive`` class. This is a bit heavyweight for just calculating 36 | AC Zeeman shifts (which is all we use it for at present) but it is mainly intended 37 | for a future optical bloch equations solver. 38 | * Added a simple ``TwoStateAtom`` class to help making simple tests and simulations. 39 | 40 | Bug fixes: 41 | 42 | * Calculate derivatives properly in transition sensitivity calculations. Closes 43 | `#24 `_ 44 | * Fix indexing in AC Zeeman shift calculation. Closes 45 | `#78 `_ 46 | * Fix incorrect transition frequencies for calcium 47 | 48 | API refactor: 49 | 50 | * Named tuples have been replaced with data classes 51 | * We no longer export classes at the module level. Replace `import atomic_physics as ap` 52 | with `from atomic_physics.core import Atom` 53 | * General push to avoid "partially constructed objects" - i.e. objects where we 54 | can't set all the fields at construction time so rely on mutating them in 55 | non-obvious ways over the object's lifetime. This makes the code easier to follow 56 | and removes the need for a bunch of checks to see if fields have been initialised. 57 | * General push to make variable and function names more explicit, even at the cost 58 | of increased verbosity (optimizing for least surprise not fewest keystrokes!). 59 | Closes `#30 `_ 60 | * ``LevelData`` now only contains the atomic structure data; information about the 61 | energy-ordering of states is now in a separate ``LevelStates`` object. 62 | * ``Atom.slice`` has been renamed to ``Atom.get_slice_for_level``. This avoids shadowing the name of a built-in python type. 63 | * ``Atom.detuning`` has been renamed to ``Atom.get_transition_frequency_for_states``. This method 64 | supports an additional ``relative`` keyword to calculate absolute transition 65 | frequencies. Closes 66 | `#29 `_ 67 | * ``Atom.index`` has been split into ``get_states_for_M``, ``get_state_for_F``, 68 | ``get_state_for_MI_MJ``. This avoids having one function which does lots of 69 | different jobs and has a return type which depends in non-obvious ways on the 70 | input parameters. 71 | * ``Atom.level`` has been renamed ``get_level_for_state`` 72 | * added a new ``Atom.get_transition_for_levels`` helper function 73 | * ``Atom.population`` has been removed as it wasn't particularly useful 74 | * ``Atom.I0`` has been renamed ``Atom.get_saturation_intensity`` 75 | * ``Atom.P0`` has been renamed ``intensity_to_power`` 76 | * ``Laser.q`` has been renamed to ``Laser.polarization`` 77 | * ``Laser.I`` has been renamed to ``Laser.intensity`` 78 | * ``Atom.B`` has been renamed to ``Atom.magnetic_field`` 79 | * ``Atom.I`` has been renamed to ``Atom.nuclear_spin`` 80 | * ``Laser.delta`` has been renamed to ``Laser.detuning`` 81 | * ``RateEquations.get_spont`` has been renamed to ``RateEquations.get_spont_matrix``. 82 | * ``RateEquations.get_stim`` has been renamed to ``RateEquations.get_stim_matrix``. 83 | * ``RateEquations.get_transitions`` has been renamed to ``RateEquations.get_transitions_matrix``. 84 | * ``RateEquations.steady_state`` has been renamed to ``RateEquations.get_steady_state_populations``. 85 | * ``RateEquations.get_steady_state_populations`` now only takes a transitions matrix 86 | as an input, not a transitions matrix or a set of lasers (supporting multiple input 87 | types saved a little boiler plate at the expense of making things complex/confusing). 88 | * angular frequencies are denoted ``w`` not ``f`` 89 | * Methods where we don't care about the orderings of states / levels now take a 90 | tuple of states / levels rather than asking for an "upper" and "lower" one 91 | * The AC Zeeman shift code now takes a ``RFDrive`` object 92 | * Polarizations are new represented by Jones vectors rather than +-1 or 0. The old 93 | system was relatively easy once one understood it, but only worked for the simple 94 | case of rate equations. Anticipating doing more complex things like optical bloch 95 | equations, I've started moving us over to Jones vectors. 96 | * Add ``operators.expectation_value`` helper method. 97 | * Add ``Atom.levels`` field. 98 | * Add new ``Atom.get_states_for_level`` method. 99 | * ``utils.field_insensitive_point`` now works with values of ``F`` and ``M_F`` instead of state indices. 100 | -------------------------------------------------------------------------------- /atomic_physics/ions/ba137.py: -------------------------------------------------------------------------------- 1 | r""":math:`^{137}\mathrm{Ba}^+` 2 | 3 | The transition frequencies are calculated based on the :math:`^{138}\mathrm{Ba}^+` 4 | frequencies from [1] and the isotope shifts in the second reference listed next to 5 | the frequency. Where no references are given, the transition frequencies 6 | were calculated based on transition frequencies between other levels. 7 | For example the frequency of the 1762 nm transition f(1762) was found 8 | from f(1762)=f(455)-f(614). 9 | 10 | References:: 11 | 12 | * [1] A. Kramida, NIST Atomic Spectra Database (ver. 5.9) (2021) 13 | * [2] Zhiqiang Zhang, K. J. Arnold, S. R. Chanu, R. Kaewuam, 14 | M. S. Safronova, and M. D. Barrett Phys. Rev. A 101, 062515 (2020) 15 | * [3] N. Yu, W. Nagourney, and H. Dehmelt, Phys. Rev. Lett. 78, 4898 (1997) 16 | * [4] N. J. Stone, Table of nuclear magnetic dipole and electric 17 | quadrupole moments, Atomic Data and Nuclear Data Tables, Volume 90, Issue 1 (2005) 18 | * [5] H. Knab, K. H. Knöll, F. Scheerer and G. Werth, Zeitschrift für 19 | Physik D Atoms, Molecules and Clusters volume 25, pages 205–208 (1993) 20 | * [6] R. Blatt and G. Werth, Phys. Rev. A 25, 1476 (1982) 21 | * [7] P Villemoes et al, J. Phys. B: At. Mol. Opt. Phys. 26 4289 (1993) 22 | * [8] Nicholas C. Lewty, Boon Leng Chuah, Radu Cazan, B. K. Sahoo, and 23 | M. D. Barrett, Opt. Express 21, 7131-7132 (2013) 24 | * [9] Nicholas C. Lewty, Boon Leng Chuah, Radu Cazan, Murray D. Barrett, 25 | and B. K. Sahoo, Phys. Rev. A 88, 012518 (2013) 26 | * [10] K. Wendt, S. A. Ahmad, F. Buchinger, A. C. Mueller, R. Neugart, and 27 | E. -W. Otten, Zeitschrift für Physik A Atoms and Nuclei volume 318, 28 | pages 125–129 (1984) 29 | 30 | """ 31 | 32 | import numpy as np 33 | import scipy.constants as consts 34 | 35 | from atomic_physics.core import AtomFactory, Level, LevelData, Transition 36 | 37 | 38 | class Ba137Factory(AtomFactory): 39 | r""":class:`~atomic_physics.core.AtomFactory` for :math:`^{137}\mathrm{Ba}^+`. 40 | 41 | Attributes: 42 | S12: the :math:`\left|n=6, S=1/2, L=0, J=1/2\right>` level. 43 | P12: the :math:`\left|n=6, S=1/2, L=1, J=1/2\right>` level. 44 | P32: the :math:`\left|n=6, S=1/2, L=1, J=3/2\right>` level. 45 | D32: the :math:`\left|n=5, S=1/2, L=2, J=3/2\right>` level. 46 | D52: the :math:`\left|n=5, S=1/2, L=2, J=5/2\right>` level. 47 | ground_level: alias for the :math:`\left|n=6, S=1/2, L=0, J=1/2\right>` ground 48 | level. 49 | shelf: alias for the :math:`\left|n=5, S=1/2, L=2, J=5/2\right>` "shelf" level. 50 | """ 51 | 52 | S12: Level = Level(n=6, S=1 / 2, L=0, J=1 / 2) 53 | P12: Level = Level(n=6, S=1 / 2, L=1, J=1 / 2) 54 | P32: Level = Level(n=6, S=1 / 2, L=1, J=3 / 2) 55 | D32: Level = Level(n=5, S=1 / 2, L=2, J=3 / 2) 56 | D52: Level = Level(n=5, S=1 / 2, L=2, J=5 / 2) 57 | 58 | ground_level: Level = S12 59 | shelf: Level = D52 60 | 61 | def __init__(self): 62 | level_data = ( 63 | LevelData( 64 | level=self.S12, 65 | Ahfs=4018.87083384e6 * consts.h, # [6] 66 | Bhfs=0, 67 | g_J=2.0024906, # [5] 68 | g_I=(2 / 3) * 0.93737, # [4] 69 | ), 70 | LevelData( 71 | level=self.P12, 72 | Ahfs=743.7e6 * consts.h, # [7] 73 | Bhfs=0, 74 | g_I=(2 / 3) * 0.93737, # [4] 75 | ), 76 | LevelData( 77 | level=self.P32, 78 | Ahfs=127.2e6 * consts.h, # [7] 79 | Bhfs=92.5e6 * consts.h, # [7] 80 | g_I=(2 / 3) * 0.93737, # [4] 81 | ), 82 | LevelData( 83 | level=self.D32, 84 | Ahfs=189.731101e6 * consts.h, # [8] 85 | Bhfs=44.536612e6 * consts.h, # [8] 86 | g_I=(2 / 3) * 0.93737, # [4] 87 | ), 88 | LevelData( 89 | level=self.D52, 90 | Ahfs=-12.029234e6 * consts.h, # [9] 91 | Bhfs=59.52552e6 * consts.h, # [9] 92 | g_I=(2 / 3) * 0.93737, # [4] 93 | ), 94 | ) 95 | 96 | transitions = { 97 | "493": Transition( 98 | lower=self.S12, 99 | upper=self.P12, 100 | einstein_A=9.53e7, # [1] 101 | frequency=2 * np.pi * 607426317510965.0, # [1], [10] 102 | ), 103 | "455": Transition( 104 | lower=self.S12, 105 | upper=self.P32, 106 | einstein_A=1.11e8, # [1] 107 | frequency=2 * np.pi * 658116515417166.6, 108 | ), 109 | "650": Transition( 110 | lower=self.D32, 111 | upper=self.P12, 112 | einstein_A=3.1e7, # [1] 113 | frequency=2 * np.pi * 461311910409885.25, # [1], [7] 114 | ), 115 | "585": Transition( 116 | lower=self.D32, 117 | upper=self.P32, 118 | einstein_A=6.0e6, # [1] 119 | frequency=2 * np.pi * 512002108316086.9, # [1], [7] 120 | ), 121 | "614": Transition( 122 | lower=self.D52, 123 | upper=self.P32, 124 | einstein_A=4.12e7, # [1] 125 | frequency=2 * np.pi * 487990081496344.9, # [1], [7] 126 | ), 127 | "1762": Transition( 128 | lower=self.S12, 129 | upper=self.D52, 130 | einstein_A=1 / 30.14, # [2] 131 | frequency=2 * np.pi * 170126433920821.75, 132 | ), 133 | "2051": Transition( 134 | lower=self.S12, 135 | upper=self.D32, 136 | einstein_A=12.5e-3, # [3] 137 | frequency=2 * np.pi * 146114407101079.75, 138 | ), 139 | } 140 | 141 | super().__init__( 142 | nuclear_spin=3 / 2, level_data=level_data, transitions=transitions 143 | ) 144 | 145 | 146 | Ba137 = Ba137Factory() 147 | r""" :math:`^{137}\mathrm{Ba}^+` atomic structure. """ 148 | -------------------------------------------------------------------------------- /atomic_physics/polarization.py: -------------------------------------------------------------------------------- 1 | r"""Tools for working with polarization vectors. 2 | 3 | See :ref:`definitions` for definitions and further discussion about representation of 4 | polarization within ``atomic-physics``. 5 | """ 6 | 7 | from typing import TYPE_CHECKING 8 | 9 | import numpy as np 10 | 11 | if TYPE_CHECKING: 12 | from atomic_physics.core import Atom 13 | 14 | 15 | def cartesian_to_spherical(cartesian: np.ndarray) -> np.ndarray: 16 | """Converts a vector in Cartesian coordinates to the spherical basis. 17 | 18 | :param cartesian: input array in Cartesian coordinates. 19 | :return: the input array converted to the spherical basis. 20 | """ 21 | if len(cartesian) != 3: 22 | raise ValueError(f"Expected a vector of length 3, got {cartesian.shape}") 23 | 24 | x = cartesian[0] 25 | y = cartesian[1] 26 | z = cartesian[2] 27 | 28 | A_p = -1 / np.sqrt(2) * (x + 1j * y) 29 | A_m = +1 / np.sqrt(2) * (x - 1j * y) 30 | A_0 = z 31 | 32 | return np.array((A_m, A_0, A_p)) 33 | 34 | 35 | def spherical_to_cartesian(spherical: np.ndarray) -> np.ndarray: 36 | """Converts a vector in the spherical basis to Cartesian coordinates. 37 | 38 | :param cartesian: input array in the spherical basis. 39 | :return: the input array converted to Cartesian coordinates. 40 | """ 41 | if len(spherical) != 3: 42 | raise ValueError(f"Expected a vector of length 3, got {spherical.shape}") 43 | 44 | A_m = spherical[0] 45 | A_0 = spherical[1] 46 | A_p = spherical[2] 47 | 48 | x = 1 / np.sqrt(2) * (A_m - A_p) 49 | y = 1 / np.sqrt(2) * 1j * (A_m + A_p) 50 | z = A_0 51 | 52 | return np.array((x, y, z)) 53 | 54 | 55 | def spherical_dot(vec_a: np.ndarray, vec_b: np.ndarray) -> np.ndarray: 56 | """Returns the dot product between two vectors in the spherical basis.""" 57 | return (-vec_a[0] * vec_b[2]) + (vec_a[1] * vec_b[1]) + (-vec_a[2] * vec_b[0]) 58 | 59 | 60 | def retarder_jones(phi: float) -> np.ndarray: 61 | r"""Returns the Jones matrix for a field propagating along the z-axis passing 62 | through a retarder, whose fast axis is aligned along :math:`\hat{\mathbf{x}}`. 63 | 64 | :param phi: the phase shift added by the retarder. 65 | :returns: the Jones matrix. 66 | """ 67 | return np.array( 68 | [ 69 | [np.exp(+0.5 * 1j * phi), 0, 0.0], 70 | [0, np.exp(-0.5 * 1j * phi), 0.0], 71 | [0.0, 0.0, 1.0], 72 | ] 73 | ) 74 | 75 | 76 | def rotate_jones_matrix(input_matrix: np.ndarray, theta: float) -> np.ndarray: 77 | """Rotates a Jones matrix about the z-axis (x-y plane rotation). 78 | 79 | the Jones matrix for a component rotated by an angle ``theta`` about the z-axis 80 | is given by ``R(theta) @ J @ R(-theta)``. 81 | 82 | :param input_array: Jones matrix to be rotated. 83 | :param theta: rotation angle (radians). 84 | :returns: the rotated Jones matrix. 85 | """ 86 | R_theta = np.array( 87 | [ 88 | [np.cos(theta), -np.sin(theta), 0.0], 89 | [np.sin(theta), np.cos(theta), 0.0], 90 | [0.0, 0.0, 1.0], 91 | ] 92 | ) 93 | R_m_theta = np.array( 94 | [ 95 | [np.cos(theta), np.sin(theta), 0.0], 96 | [-np.sin(theta), np.cos(theta), 0.0], 97 | [0.0, 0.0, 1.0], 98 | ] 99 | ) 100 | return R_theta @ input_matrix @ R_m_theta 101 | 102 | 103 | def half_wave_plate() -> np.ndarray: 104 | r"""Returns the Jones matrix for a field propagating along the z-axis passing 105 | through a half-wave plate, whose fast axis is aligned along :math:`\hat{\mathbf{x}}`. 106 | """ 107 | return retarder_jones(phi=np.pi) 108 | 109 | 110 | def quarter_wave_plate() -> np.ndarray: 111 | r"""Returns the Jones matrix for a field propagating along the z-axis passing 112 | through a quarter-wave plate, whose fast axis is aligned along 113 | :math:`\hat{\mathbf{x}}`. 114 | """ 115 | return retarder_jones(phi=np.pi / 2) 116 | 117 | 118 | def dM_for_transition(atom: "Atom", states: tuple[int, int]) -> int: 119 | r"""Returns the change in magnetic quantum number for a given transition. 120 | 121 | :math:`\delta M := M_{\mathrm{upper}} - M_{\mathrm{lower}}` where 122 | :math:`M_{\mathrm{upper}}` (:math:`M_{\mathrm{lower}}`) is 123 | the magnetic quantum number for the state with greater (lower) energy. 124 | 125 | :param atom: the atom. 126 | :param states: tuple containing indices of the two states involved in the transition. 127 | :return: the change in magnetic quantum number. 128 | """ 129 | if len(states) != 2: 130 | raise ValueError(f"Expected 2 state indices, got {len(states)}.") 131 | 132 | # take advantage of the fact that states are ordered by energy 133 | upper = min(states) 134 | lower = max(states) 135 | return int(np.rint(atom.M[upper] - atom.M[lower])) 136 | 137 | 138 | X_POLARIZATION: np.ndarray = np.array([1, 0, 0]) 139 | """Jones vector for a field with linear polarization along the x-axis. """ 140 | 141 | 142 | Y_POLARIZATION: np.ndarray = np.array([0, 1, 0]) 143 | """Jones vector for a field with linear polarization along the y-axis. """ 144 | 145 | Z_POLARIZATION: np.ndarray = np.array([0, 0, 1]) 146 | """Jones vector for a field with linear polarization along the z-axis. """ 147 | 148 | 149 | PI_POLARIZATION = np.array([0, 0, 1]) 150 | r"""Jones vector for π polarization. 151 | 152 | π-polarized radiation drives transitions where the magnetic quantum number is the same 153 | in both states (:math:`M_{\mathrm{upper}} = M_{\mathrm{lower}}`). 154 | """ 155 | 156 | 157 | SIGMA_MINUS_POLARIZATION = spherical_to_cartesian(np.array([0, 0, 1])) 158 | r"""Jones vector for σ- polarization. 159 | 160 | σ- polarized radiation drives transitions where the magnetic quantum number in the 161 | state with higher energy is 1 lower than in the state with lower energy ( 162 | :math:`M_{\mathrm{upper}} = M_{\mathrm{lower}} - 1`). 163 | """ 164 | 165 | SIGMA_PLUS_POLARIZATION = spherical_to_cartesian(np.array([1, 0, 0])) 166 | r"""Jones vector for σ+ polarization. 167 | 168 | σ+ polarized radiation drives transitions where the magnetic quantum number in the 169 | state with higher energy is 1 greater than in the state with lower energy ( 170 | :math:`M_{\mathrm{upper}} = M_{\mathrm{lower}} + 1`). 171 | """ 172 | -------------------------------------------------------------------------------- /atomic_physics/tests/test_atom_factory.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import numpy as np 4 | 5 | from atomic_physics.ions.ca43 import Ca43 6 | 7 | 8 | class TestAtomFactory(unittest.TestCase): 9 | """Tests for :class:`atomic_physics.core.AtomFactory`.""" 10 | 11 | def test_num_states(self): 12 | ion = Ca43(magnetic_field=146.0942e-4) 13 | num_states = 0 14 | for level in ion.level_data.keys(): 15 | num_states += (2 * ion.nuclear_spin + 1) * (2 * level.J + 1) 16 | assert num_states == Ca43.num_states 17 | 18 | def test_sorting(self): 19 | """ 20 | Test that the atom factory sorts the levels into energy ordering correctly. 21 | """ 22 | ion = Ca43(magnetic_field=1.0) 23 | levels_sorted = sorted( 24 | ion.level_states.items(), key=lambda item: item[1].frequency 25 | ) 26 | 27 | assert levels_sorted[0][0] == Ca43.S12 28 | assert levels_sorted[1][0] == Ca43.D32 29 | assert levels_sorted[2][0] == Ca43.D52 30 | assert levels_sorted[3][0] == Ca43.P12 31 | assert levels_sorted[4][0] == Ca43.P32 32 | 33 | # check all levels have the right energy 34 | assert levels_sorted[0][1].frequency == 0 # S 1/2 is the ground level 35 | np.testing.assert_allclose( 36 | levels_sorted[3][1].frequency - levels_sorted[1][1].frequency, 37 | ion.transitions["866"].frequency, 38 | rtol=1e-3, 39 | ) 40 | np.testing.assert_allclose( 41 | levels_sorted[1][1].frequency, ion.transitions["733"].frequency, rtol=1e-3 42 | ) 43 | np.testing.assert_allclose( 44 | levels_sorted[2][1].frequency, ion.transitions["729"].frequency, rtol=1e-3 45 | ) 46 | np.testing.assert_allclose( 47 | levels_sorted[3][1].frequency, ion.transitions["397"].frequency, rtol=1e-3 48 | ) 49 | np.testing.assert_allclose( 50 | levels_sorted[4][1].frequency, ion.transitions["393"].frequency, rtol=1e-3 51 | ) 52 | 53 | I_dim = 2 * ion.nuclear_spin + 1 54 | for level, states in ion.level_states.items(): 55 | J_dim = 2 * level.J + 1 56 | assert states.num_states == np.rint(I_dim * J_dim) 57 | assert states.stop_index == states.start_index + states.num_states 58 | 59 | # states should be in reverse energy order 60 | assert ion.level_states[Ca43.P32].start_index == 0 61 | assert ( 62 | ion.level_states[Ca43.P12].start_index 63 | == ion.level_states[Ca43.P32].stop_index 64 | ) 65 | assert ( 66 | ion.level_states[Ca43.D52].start_index 67 | == ion.level_states[Ca43.P12].stop_index 68 | ) 69 | assert ( 70 | ion.level_states[Ca43.D32].start_index 71 | == ion.level_states[Ca43.D52].stop_index 72 | ) 73 | assert ( 74 | ion.level_states[Ca43.S12].start_index 75 | == ion.level_states[Ca43.D32].stop_index 76 | ) 77 | 78 | def test_filtering(self): 79 | """Test that the level filtering works correctly.""" 80 | levels = (Ca43.S12, Ca43.D32, Ca43.P12, Ca43.P32) 81 | factory = Ca43.filter_levels(level_filter=levels) 82 | ion = factory(magnetic_field=1.0) 83 | assert len(ion.levels) == len(set(ion.levels)) 84 | assert set(ion.levels) == set(levels) 85 | 86 | levels_sorted = sorted( 87 | ion.level_states.items(), key=lambda item: item[1].frequency 88 | ) 89 | 90 | assert levels_sorted[0][0] == Ca43.S12 91 | assert levels_sorted[1][0] == Ca43.D32 92 | assert levels_sorted[2][0] == Ca43.P12 93 | assert levels_sorted[3][0] == Ca43.P32 94 | 95 | assert levels_sorted[0][1].frequency == 0 # S 1/2 is the ground level 96 | np.testing.assert_allclose( 97 | levels_sorted[3][1].frequency - levels_sorted[1][1].frequency, 98 | ion.transitions["866"].frequency, 99 | rtol=2e-2, 100 | ) 101 | np.testing.assert_allclose( 102 | levels_sorted[1][1].frequency, ion.transitions["733"].frequency, rtol=2e-2 103 | ) 104 | np.testing.assert_allclose( 105 | levels_sorted[2][1].frequency, ion.transitions["397"].frequency, rtol=2e-2 106 | ) 107 | np.testing.assert_allclose( 108 | levels_sorted[3][1].frequency, ion.transitions["393"].frequency, rtol=2e-2 109 | ) 110 | 111 | # states should be in reverse energy order 112 | assert ion.level_states[Ca43.P32].start_index == 0 113 | assert ( 114 | ion.level_states[Ca43.P12].start_index 115 | == ion.level_states[Ca43.P32].stop_index 116 | ) 117 | assert ( 118 | ion.level_states[Ca43.D32].start_index 119 | == ion.level_states[Ca43.P12].stop_index 120 | ) 121 | assert ( 122 | ion.level_states[Ca43.S12].start_index 123 | == ion.level_states[Ca43.D32].stop_index 124 | ) 125 | 126 | levels = (Ca43.D32, Ca43.P12, Ca43.P32) 127 | 128 | factory = factory.filter_levels(level_filter=levels) 129 | ion = factory(magnetic_field=1.0) 130 | 131 | assert len(ion.levels) == len(set(ion.levels)) 132 | assert set(ion.levels) == set(levels) 133 | 134 | levels_sorted = sorted( 135 | ion.level_states.items(), key=lambda item: item[1].frequency 136 | ) 137 | 138 | assert levels_sorted[0][0] == Ca43.D32 139 | assert levels_sorted[1][0] == Ca43.P12 140 | assert levels_sorted[2][0] == Ca43.P32 141 | 142 | assert levels_sorted[0][1].frequency == 0 143 | np.testing.assert_allclose( 144 | levels_sorted[1][1].frequency, ion.transitions["866"].frequency, rtol=1e-3 145 | ) 146 | np.testing.assert_allclose( 147 | levels_sorted[2][1].frequency, ion.transitions["850"].frequency, rtol=1e-3 148 | ) 149 | 150 | # states should be in reverse energy order 151 | assert ion.level_states[Ca43.P32].start_index == 0 152 | assert ( 153 | ion.level_states[Ca43.P12].start_index 154 | == ion.level_states[Ca43.P32].stop_index 155 | ) 156 | assert ( 157 | ion.level_states[Ca43.D32].start_index 158 | == ion.level_states[Ca43.P12].stop_index 159 | ) 160 | 161 | levels = (Ca43.D32,) 162 | factory = factory.filter_levels(level_filter=levels) 163 | ion = factory(magnetic_field=1.0) 164 | 165 | assert len(ion.levels) == len(set(ion.levels)) 166 | assert set(ion.levels) == set(levels) 167 | 168 | assert ion.level_states[Ca43.D32].frequency == 0 169 | assert ion.level_states[Ca43.D32].start_index == 0 170 | -------------------------------------------------------------------------------- /atomic_physics/rate_equations.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from atomic_physics.core import Atom, Laser 4 | 5 | 6 | class Rates: 7 | """Rate equations calculations. 8 | 9 | See the :ref:`rates` section of the documentation for details. 10 | 11 | Example usage: 12 | 13 | .. testcode:: 14 | 15 | # Electron shelving simulation in 43Ca+ 16 | import matplotlib.pyplot as plt 17 | import numpy as np 18 | from scipy.linalg import expm 19 | 20 | from atomic_physics.core import Laser 21 | from atomic_physics.ions.ca43 import Ca43 22 | from atomic_physics.rate_equations import Rates 23 | 24 | t_ax = np.linspace(0, 100e-6, 100) # Scan the duration of the "shelving" pulse 25 | intensity = 0.02 # 393 intensity 26 | 27 | ion = Ca43(magnetic_field=146e-4) 28 | 29 | # Ion starts in the F=4, M=+4 "stretched" state within the 4S1/2 ground-level 30 | stretch = ion.get_state_for_F(Ca43.ground_level, F=4, M_F=+4) 31 | Vi = np.zeros((ion.num_states, 1)) 32 | Vi[stretch] = 1 33 | 34 | # Tune the 393nm laser to resonance with the 35 | # 4S1/2(F=4, M_F=+4) <> 4P3/2(F=5, M_F=+5) transition 36 | detuning = ion.get_transition_frequency_for_states( 37 | (stretch, ion.get_state_for_F(Ca43.P32, F=5, M_F=+5)) 38 | ) 39 | lasers = ( 40 | Laser("393", polarization=+1, intensity=intensity, detuning=detuning), 41 | ) # resonant 393 sigma+ 42 | 43 | rates = Rates(ion) 44 | transitions = rates.get_transitions_matrix(lasers) 45 | 46 | shelved = np.zeros(len(t_ax)) # Population in the 3D5/2 level at the end 47 | for idx, t in np.ndenumerate(t_ax): 48 | Vf = expm(transitions * t) @ Vi # NB use of matrix operations here! 49 | shelved[idx] = np.sum(Vf[ion.get_slice_for_level(Ca43.shelf)]) 50 | 51 | plt.plot(t_ax * 1e6, shelved) 52 | plt.ylabel("Shelved Population") 53 | plt.xlabel("Shelving time (us)") 54 | plt.grid() 55 | plt.show() 56 | 57 | """ 58 | 59 | def __init__(self, atom: Atom): 60 | self.atom = atom 61 | 62 | def get_spont_matrix(self) -> np.ndarray: 63 | """Returns the spontaneous emission matrix.""" 64 | scattering_rates = np.abs(self.atom.get_electric_multipoles()) ** 2 65 | total_rates = np.sum(scattering_rates, 0) 66 | 67 | for ii in range(scattering_rates.shape[0]): 68 | scattering_rates[ii, ii] = -total_rates[ii] 69 | 70 | return scattering_rates 71 | 72 | def get_stim_matrix(self, lasers: tuple[Laser, ...]) -> np.ndarray: 73 | """Returns the stimulated emission matrix for a set of lasers.""" 74 | scattering_rates = np.abs(self.atom.get_electric_multipoles()) ** 2 75 | total_rates = np.sum(scattering_rates, 0) 76 | 77 | stim = np.zeros(scattering_rates.shape) 78 | 79 | for transition in self.atom.transitions.keys(): 80 | _lasers = [laser for laser in lasers if laser.transition == transition] 81 | if _lasers == []: 82 | continue 83 | 84 | lower = self.atom.transitions[transition].lower 85 | upper = self.atom.transitions[transition].upper 86 | lower_states = self.atom.get_slice_for_level(lower) 87 | upper_states = self.atom.get_slice_for_level(upper) 88 | n_lower = self.atom.level_states[lower].num_states 89 | n_upper = self.atom.level_states[upper].num_states 90 | 91 | dJ = upper.J - lower.J 92 | dL = upper.L - lower.L 93 | if dJ in [-1, 0, +1] and dL in [-1, 0, +1]: 94 | order = 1 95 | elif abs(dJ) in [0, 1, 2] and abs(dL) in [0, 1, 2]: 96 | order = 2 97 | else: 98 | raise ValueError( 99 | "Unsupported transition order. \n" 100 | "Only 1st and 2nd order transitions are " 101 | "supported. [abs(dL) & abs(dJ) <2]\n" 102 | "Got dJ={} and dL={}".format(dJ, dL) 103 | ) 104 | 105 | Mu = self.atom.M[upper_states] 106 | Ml = self.atom.M[lower_states] 107 | Mu = np.repeat(Mu, n_lower).reshape(n_upper, n_lower).T 108 | Ml = np.repeat(Ml, n_upper).reshape(n_lower, n_upper) 109 | 110 | # Transition detunings 111 | El = self.atom.state_energies[lower_states] 112 | Eu = self.atom.state_energies[upper_states] 113 | El = np.repeat(El, n_upper).reshape(n_lower, n_upper) 114 | Eu = np.repeat(Eu, n_lower).reshape(n_upper, n_lower).T 115 | delta_lu = Eu - El 116 | 117 | # Total scattering rate out of each state 118 | total_rates_subs = total_rates[upper_states] 119 | total_rates_subs = ( 120 | np.repeat(total_rates_subs, n_lower).reshape(n_upper, n_lower).T 121 | ) 122 | total_rates_2 = np.power(total_rates_subs, 2) 123 | 124 | scattering_rates_subs = scattering_rates[lower_states, upper_states] 125 | R = np.zeros((n_lower, n_upper)) 126 | for q in range(-order, order + 1): 127 | Q = np.zeros((n_lower, n_upper)) 128 | # q := Mu - Ml 129 | Q[Ml == (Mu - q)] = 1 130 | for laser in [laser for laser in _lasers if laser.polarization == q]: 131 | delta = delta_lu - laser.detuning 132 | R += ( 133 | total_rates_2 134 | / (4 * np.power(delta, 2) + total_rates_2) 135 | * laser.intensity 136 | * (Q * scattering_rates_subs) 137 | ) 138 | assert (R >= 0).all() 139 | 140 | stim[lower_states, upper_states] = R 141 | stim[upper_states, lower_states] = R.T 142 | 143 | stim_j = np.sum(stim, 0) 144 | for ii in range(self.atom.num_states): 145 | stim[ii, ii] = -stim_j[ii] 146 | return stim 147 | 148 | def get_transitions_matrix(self, lasers: tuple[Laser, ...]) -> np.ndarray: 149 | """Returns the complete (spontaneous + stimulated emissions) transitions matrix 150 | for a given set of lasers. 151 | """ 152 | return self.get_spont_matrix() + self.get_stim_matrix(lasers) 153 | 154 | def get_steady_state_populations(self, transitions: np.ndarray) -> np.ndarray: 155 | """Returns the steady-state population vector for a given transitions matrix. 156 | 157 | :param transitions: transitions matrix. 158 | :returns: steady-state population vector. 159 | """ 160 | t_pr = np.copy(transitions) 161 | t_pr[0, :] = 1 162 | b = np.zeros(self.atom.num_states) 163 | b[0] = 1 164 | Vf, _, _, _ = np.linalg.lstsq(t_pr, b, rcond=None) 165 | return Vf 166 | -------------------------------------------------------------------------------- /atomic_physics/tests/test_polarization.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import numpy as np 4 | 5 | from atomic_physics.ions.ca43 import Ca43 6 | from atomic_physics.polarization import ( 7 | PI_POLARIZATION, 8 | SIGMA_MINUS_POLARIZATION, 9 | SIGMA_PLUS_POLARIZATION, 10 | X_POLARIZATION, 11 | Y_POLARIZATION, 12 | Z_POLARIZATION, 13 | cartesian_to_spherical, 14 | dM_for_transition, 15 | half_wave_plate, 16 | quarter_wave_plate, 17 | rotate_jones_matrix, 18 | spherical_dot, 19 | spherical_to_cartesian, 20 | ) 21 | 22 | 23 | class TestPolarization(unittest.TestCase): 24 | def test_conversions(self): 25 | """Check conversions between Cartesian coordinates and the spherical basis.""" 26 | for cartesian in ( 27 | np.array((2.4, 0, 0)), 28 | np.array((0, 1.432, 0)), 29 | np.array((0, 0, 343)), 30 | ): 31 | np.testing.assert_allclose( 32 | cartesian, spherical_to_cartesian(cartesian_to_spherical(cartesian)) 33 | ) 34 | 35 | def test_spherical_dot(self): 36 | """Test that the spherical dot product gives the same result as the dot product 37 | in Cartesian coordinates. 38 | """ 39 | vectors = ( 40 | (np.array([1, 0, 0]), np.array([1, 0, 0])), 41 | (np.array([0, 1, 0]), np.array([0, 1, 0])), 42 | (np.array([0, 0, 1]), np.array([1, 0, 1])), 43 | (np.array([1, 0, 0]), np.array([0, 1, 0])), 44 | (np.array([1, 0, 1]), np.array([1, 0, 1])), 45 | (np.array([1, 1, 1]), np.array([1, 0, 1])), 46 | ) 47 | for a, b in vectors: 48 | a_spher = cartesian_to_spherical(a) 49 | b_spher = cartesian_to_spherical(b) 50 | np.testing.assert_allclose(np.dot(a, b), spherical_dot(a_spher, b_spher)) 51 | 52 | def test_dM_for_transition(self): 53 | """Check ``dM_for_transition``.""" 54 | ion = Ca43(magnetic_field=146e-4) 55 | 56 | dM = dM_for_transition( 57 | atom=ion, 58 | states=( 59 | ion.get_state_for_F(level=Ca43.D52, F=3, M_F=+1), 60 | ion.get_state_for_F(level=Ca43.D52, F=4, M_F=+1), 61 | ), 62 | ) 63 | assert np.isclose(dM, 0) 64 | 65 | dM = dM_for_transition( 66 | atom=ion, 67 | states=( 68 | ion.get_state_for_F(level=Ca43.D52, F=3, M_F=0), # state 67 69 | ion.get_state_for_F(level=Ca43.D52, F=4, M_F=+1), # state 74 70 | ), 71 | ) 72 | assert np.isclose(dM, -1) 73 | 74 | dM = dM_for_transition( 75 | atom=ion, 76 | states=( 77 | ion.get_state_for_F(level=Ca43.D52, F=4, M_F=+1), # state 74 78 | ion.get_state_for_F(level=Ca43.D52, F=3, M_F=0), # state 67 79 | ), 80 | ) 81 | assert np.isclose(dM, -1) 82 | 83 | def test_basis_vectors(self): 84 | """Check the pre-defined basis vectors are available and defined correctly.""" 85 | np.testing.assert_allclose(X_POLARIZATION, np.array([1.0, 0.0, 0.0])) 86 | np.testing.assert_allclose(Y_POLARIZATION, np.array([0, 1.0, 0.0])) 87 | np.testing.assert_allclose(Z_POLARIZATION, np.array([0, 0.0, 1.0])) 88 | 89 | np.testing.assert_allclose( 90 | SIGMA_MINUS_POLARIZATION, 91 | 1 / np.sqrt(2) * (-X_POLARIZATION + 1j * Y_POLARIZATION), 92 | ) 93 | np.testing.assert_allclose( 94 | SIGMA_PLUS_POLARIZATION, 95 | +1 / np.sqrt(2) * (X_POLARIZATION + 1j * Y_POLARIZATION), 96 | ) 97 | np.testing.assert_allclose(Z_POLARIZATION, PI_POLARIZATION) 98 | 99 | def test_orthonormality(self): 100 | """Check the relationships between basis vectors in the spherical basis.""" 101 | e_p = np.array([0, 0, 1]) 102 | e_m = np.array([1, 0, 0]) 103 | e_0 = np.array([0, 1, 0]) 104 | 105 | np.testing.assert_allclose(spherical_dot(e_p, e_p), 0) 106 | np.testing.assert_allclose(spherical_dot(e_m, e_m), 0) 107 | np.testing.assert_allclose(spherical_dot(e_p, e_m), -1) 108 | np.testing.assert_allclose(spherical_dot(e_m, e_p), -1) 109 | np.testing.assert_allclose(spherical_dot(e_0, e_0), 1) 110 | np.testing.assert_allclose(spherical_dot(e_0, e_p), 0) 111 | np.testing.assert_allclose(spherical_dot(e_0, e_m), 0) 112 | np.testing.assert_allclose( 113 | spherical_to_cartesian(e_p), -spherical_to_cartesian(e_m).conj() 114 | ) 115 | np.testing.assert_allclose( 116 | spherical_to_cartesian(e_m), -spherical_to_cartesian(e_p).conj() 117 | ) 118 | 119 | def test_jones_transformations(self): 120 | """Test functions which transform Jones matrices""" 121 | qwp = rotate_jones_matrix(quarter_wave_plate(), np.pi / 4) 122 | np.testing.assert_allclose( 123 | qwp @ X_POLARIZATION, 124 | SIGMA_PLUS_POLARIZATION, 125 | ) 126 | qwp = rotate_jones_matrix(quarter_wave_plate(), np.pi + np.pi / 4) 127 | np.testing.assert_allclose( 128 | qwp @ X_POLARIZATION, 129 | SIGMA_PLUS_POLARIZATION, 130 | ) 131 | qwp = rotate_jones_matrix(quarter_wave_plate(), -np.pi / 4) 132 | np.testing.assert_allclose( 133 | qwp @ Y_POLARIZATION, 134 | -1j * SIGMA_PLUS_POLARIZATION, 135 | ) 136 | qwp = rotate_jones_matrix(quarter_wave_plate(), np.pi - np.pi / 4) 137 | np.testing.assert_allclose( 138 | qwp @ Y_POLARIZATION, 139 | -1j * SIGMA_PLUS_POLARIZATION, 140 | atol=1e-15, 141 | ) 142 | 143 | qwp = rotate_jones_matrix(quarter_wave_plate(), -np.pi / 4) 144 | np.testing.assert_allclose( 145 | qwp @ X_POLARIZATION, 146 | -1 * SIGMA_MINUS_POLARIZATION, 147 | ) 148 | qwp = rotate_jones_matrix(quarter_wave_plate(), np.pi - np.pi / 4) 149 | np.testing.assert_allclose( 150 | qwp @ X_POLARIZATION, 151 | -1 * SIGMA_MINUS_POLARIZATION, 152 | atol=1e-15, 153 | ) 154 | qwp = rotate_jones_matrix(quarter_wave_plate(), +np.pi / 4) 155 | np.testing.assert_allclose( 156 | qwp @ Y_POLARIZATION, 157 | -1j * SIGMA_MINUS_POLARIZATION, 158 | ) 159 | qwp = rotate_jones_matrix(quarter_wave_plate(), np.pi + np.pi / 4) 160 | np.testing.assert_allclose( 161 | qwp @ Y_POLARIZATION, 162 | -1j * SIGMA_MINUS_POLARIZATION, 163 | atol=1e-15, 164 | ) 165 | 166 | hwp = half_wave_plate() 167 | np.testing.assert_allclose(hwp @ X_POLARIZATION, 1j * X_POLARIZATION) 168 | np.testing.assert_allclose(hwp @ Y_POLARIZATION, -1j * Y_POLARIZATION) 169 | 170 | hwp = rotate_jones_matrix(half_wave_plate(), np.pi) 171 | np.testing.assert_allclose(hwp, half_wave_plate(), atol=1e-15) 172 | 173 | hwp = rotate_jones_matrix(half_wave_plate(), np.pi / 2) 174 | np.testing.assert_allclose( 175 | hwp @ X_POLARIZATION, -1j * X_POLARIZATION, atol=1e-15 176 | ) 177 | np.testing.assert_allclose( 178 | hwp @ Y_POLARIZATION, 1j * Y_POLARIZATION, atol=1e-15 179 | ) 180 | 181 | hwp = rotate_jones_matrix(half_wave_plate(), np.pi / 4) 182 | np.testing.assert_allclose( 183 | hwp @ X_POLARIZATION, 1j * Y_POLARIZATION, atol=1e-15 184 | ) 185 | np.testing.assert_allclose( 186 | hwp @ Y_POLARIZATION, 1j * X_POLARIZATION, atol=1e-15 187 | ) 188 | -------------------------------------------------------------------------------- /atomic_physics/utils.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import scipy.constants as consts 3 | import scipy.optimize as opt 4 | 5 | from atomic_physics.core import Atom, AtomFactory, Level, RFDrive, Transition 6 | from atomic_physics.polarization import ( 7 | cartesian_to_spherical, 8 | dM_for_transition, 9 | ) 10 | 11 | _uB = consts.physical_constants["Bohr magneton"][0] 12 | _uN = consts.physical_constants["nuclear magneton"][0] 13 | 14 | 15 | def df_dB( 16 | atom_factory: AtomFactory, 17 | magnetic_field: float, 18 | states: tuple[int, int], 19 | eps: float = 1e-6, 20 | ) -> float: 21 | r"""Returns the field-sensitivity (:math:`\frac{\mathrm{d}f}{\mathrm{d}B}`) 22 | of a transition between two states in the same level at a given magnetic field. 23 | 24 | :param atom_factory: factory class for the atom of interest. 25 | :param magnetic_field: the magnetic to calculate the field sensitivity at. 26 | :param states: tuple of indices involved in this transition. 27 | :param eps: field difference (T) to use when calculating derivatives numerically. 28 | :return: the transition's field sensitivity (rad/s/T). 29 | """ 30 | f_p = atom_factory( 31 | magnetic_field=magnetic_field + eps 32 | ).get_transition_frequency_for_states(states) 33 | f_m = atom_factory( 34 | magnetic_field=magnetic_field - eps 35 | ).get_transition_frequency_for_states(states) 36 | return (f_p - f_m) / (2 * eps) 37 | 38 | 39 | def d2f_dB2( 40 | atom_factory: AtomFactory, 41 | magnetic_field: float, 42 | states: tuple[int, int], 43 | eps: float = 1e-6, 44 | ) -> float: 45 | r"""Returns the second-order field-sensitivity (:math:`\frac{\mathrm{d}^2f}{\mathrm{d}B^2}`) 46 | of a transition between two states in the same level at a given magnetic field. 47 | 48 | :param atom_factory: factory class for the atom of interest. 49 | :param magnetic_field: the magnetic to calculate the field sensitivity at. 50 | :param states: tuple of indices involved in this transition. 51 | :param eps: field difference (T) to use when calculating derivatives numerically. 52 | :return: the transition's field sensitivity (rad/s/T^2). 53 | """ 54 | df_p = df_dB(atom_factory, magnetic_field + eps, states, eps) 55 | df_m = df_dB(atom_factory, magnetic_field - eps, states, eps) 56 | return (df_p - df_m) / (2 * eps) 57 | 58 | 59 | def field_insensitive_point( 60 | atom_factory: AtomFactory, 61 | level_0: Level, 62 | F_0: float, 63 | M_F_0: float, 64 | level_1: Level, 65 | F_1: float, 66 | M_F_1: float, 67 | magnetic_field_guess: float = 1e-4, 68 | eps: float = 1e-4, 69 | ) -> float | None: 70 | """Returns the magnetic field at which the frequency of a transition 71 | between two states in the same level becomes first-order field independent. 72 | 73 | Since the energy ordering of states can change with magnetic field, we label states 74 | by ``F`` and ``M_F`` instead of using state indices. 75 | 76 | :param atom_factory: factory class for the atom of interest. 77 | :param level_0: level the first state involved in the transition lies in. 78 | :param F_0: value of ``F`` for the first state involved in the transition. 79 | :param M_F_0: value of ``M_F`` for the first state involved in the transition. 80 | :param level_1: level the second state involved in the transition lies in. 81 | :param F_1: value of ``F`` for the second state involved in the transition. 82 | :param M_F_1: value of ``M_F`` for the second state involved in the transition. 83 | :param magnetic_field_guess: Initial guess for the magnetic field insensitive point 84 | (T). This is used both as a seed for the root finding algorithm and as a scale 85 | factor to help numerical accuracy. 86 | :param eps: step size as a fraction of ``magnetic_field_guess`` to use when 87 | calculating numerical derivatives. 88 | :return: the field-independent point (T) or ``None`` if none found. 89 | """ 90 | 91 | def opt_fun(x): 92 | magnetic_field = max(x, 10 * eps) * magnetic_field_guess 93 | atom = atom_factory(magnetic_field=magnetic_field) 94 | states = ( 95 | atom.get_state_for_F(level=level_0, F=F_0, M_F=M_F_0), 96 | atom.get_state_for_F(level=level_1, F=F_1, M_F=M_F_1), 97 | ) 98 | 99 | return df_dB( 100 | atom_factory, 101 | magnetic_field, 102 | states, 103 | eps=eps * magnetic_field_guess, 104 | ) 105 | 106 | res = opt.root( 107 | opt_fun, 108 | x0=1, 109 | options={"xtol": 1e-4, "eps": eps}, 110 | ) 111 | 112 | return res.x[0] * magnetic_field_guess if res.success else None 113 | 114 | 115 | def ac_zeeman_shift_for_state(atom: Atom, state: int, drive: RFDrive) -> float: 116 | r"""Returns the AC Zeeman shift on a given state resulting from an applied RF field. 117 | 118 | The calculated shift includes the counter-rotating term (Bloch-Siegert shift) and 119 | is calculated by summing 120 | :math:`\frac{1}{2} \Omega^2 \left(\omega_0 / (w_0^2 - w_{\mathrm{rf}}^2)\right)` 121 | over all magnetic dipole transitions which couple to the state. Where 122 | :math:`\Omega` is the Rabi frequency for the transition and :math:`w_0` is the 123 | transition frequency. 124 | 125 | Example: 126 | 127 | .. testcode:: 128 | 129 | import numpy as np 130 | 131 | from atomic_physics.core import RFDrive 132 | from atomic_physics.ions.ca40 import Ca40 133 | from atomic_physics.polarization import SIGMA_PLUS_POLARIZATION 134 | from atomic_physics.utils import ac_zeeman_shift_for_state 135 | 136 | ion = Ca40(magnetic_field=10e-4) 137 | w_transition = ion.get_transition_frequency_for_states( 138 | states=( 139 | ion.get_states_for_M(level=Ca40.ground_level, M=+1 / 2), 140 | ion.get_states_for_M(level=Ca40.ground_level, M=-1 / 2), 141 | ) 142 | ) 143 | w_rf = w_transition + 1e6 * 2 * np.pi # RF is 1 MHz blue of the transition 144 | 145 | ac_zeeman_shift_for_state( 146 | atom=ion, 147 | state=ion.get_states_for_M(level=Ca40.ground_level, M=+1 / 2), 148 | drive=RFDrive(frequency=w_rf, amplitude=1e-6, polarization=SIGMA_PLUS_POLARIZATION), 149 | ) 150 | 151 | :param atom: the atom. 152 | :param state: index of the state to calculate the shift for. 153 | :param drive: the applied RF drive. 154 | :return: the AC Zeeman shift (rad/s). 155 | """ 156 | level = atom.get_level_for_state(state) 157 | magnetic_dipoles = atom.get_magnetic_dipoles() 158 | Rnm = magnetic_dipoles / consts.hbar # Rnm := (-1)**(q+1) 159 | 160 | # Omega = B_{-q} * Rnm 161 | amplitude_for_pol = drive.amplitude * np.abs( 162 | cartesian_to_spherical(drive.polarization)[::-1] 163 | ) 164 | 165 | acz = np.zeros(3) 166 | for delta_M in [-1, 0, +1]: 167 | spectators: list[int] = [ 168 | spectator 169 | for spectator in atom.get_states_for_M( 170 | level=level, M=atom.M[state] + delta_M 171 | ) 172 | if spectator != state 173 | ] 174 | for spectator in spectators: 175 | polarization = dM_for_transition(atom=atom, states=(state, spectator)) 176 | w_transition = np.abs( 177 | atom.get_transition_frequency_for_states((state, spectator)) 178 | ) 179 | pol_ind = polarization + 1 180 | Omega = amplitude_for_pol[pol_ind] * Rnm[state, spectator] 181 | 182 | # A positive AC Zeeman shift means that the upper (higher energy) state 183 | # increases in energy, while the lower state decreases in energy 184 | sign = ( 185 | +1 186 | if atom.state_energies[state] > atom.state_energies[spectator] 187 | else -1 188 | ) 189 | 190 | acz[pol_ind] += sign * ( 191 | 0.5 * Omega**2 * (w_transition / (w_transition**2 - drive.frequency**2)) 192 | ) 193 | 194 | return sum(acz) 195 | 196 | 197 | def ac_zeeman_shift_for_transition( 198 | atom: Atom, states: tuple[int, int], drive: RFDrive 199 | ) -> float: 200 | """Returns the AC Zeeman shift on a transition resulting from an applied RF field. 201 | 202 | See :func:`ac_zeeman_shift_for_state` for details. 203 | 204 | :param atom: the atom. 205 | :param states: tuple containing indices of the two states involved in the transition. 206 | :param drive: the applied RF drive. 207 | :return: the AC Zeeman shift (rad/s). 208 | """ 209 | if len(states) != 2: 210 | raise ValueError(f"Expected 2 state indices, got {len(states)}.") 211 | 212 | upper = min(states) 213 | lower = max(states) 214 | 215 | return ac_zeeman_shift_for_state( 216 | atom=atom, state=upper, drive=drive 217 | ) - ac_zeeman_shift_for_state(atom=atom, state=lower, drive=drive) 218 | 219 | 220 | def rayleigh_range(transition: Transition, waist_radius: float) -> float: 221 | """Returns the Rayleigh range for a given beam radius. 222 | 223 | :param transition: the transition the laser drives. 224 | :param waist_radius: Gaussian beam waist (:math:`1/e^2` intensity radius in m). 225 | :return: the Rayleigh range (m). 226 | """ 227 | wavelength = consts.c / (transition.frequency / (2 * np.pi)) 228 | return np.pi * waist_radius**2 / wavelength 229 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /docs/definitions_and_conventions.rst: -------------------------------------------------------------------------------- 1 | .. _definitions: 2 | 3 | Definitions and Conventions 4 | ########################### 5 | 6 | Variable names 7 | ============== 8 | 9 | We aim to have descriptive names, following PEP8, except for standard quantum numbers, 10 | for which we allow single-letter variable names: 11 | 12 | * ``n``: principal quantum number 13 | * ``L``: electron orbital angular momentum 14 | * ``S``: electron spin 15 | * ``J``: total electronic angular momentum 16 | * ``I``: nuclear angular momentum 17 | * ``F``: total (electron + nucleus) angular momentum 18 | * ``M``: magnetic quantum number. This is a good quantum number at all magnetic 19 | fields. In the low-field limit ``M = M_F`` while in the high-field limit ``M = M_I + M_J`` 20 | * ``M_J``: electronic magnetic quantum number 21 | * ``M_I``: nuclear magnetic quantum number 22 | 23 | Units 24 | ===== 25 | 26 | We use SI units throughout this package: 27 | 28 | * Frequencies are in radians per second (``rad/s``) 29 | * Magnetic fields are in Tesla (``T``) 30 | 31 | We define *Rabi frequency*, :math:`\Omega`, such that the pi-time is given by 32 | :math:`t_\pi = \pi / \Omega`. 33 | 34 | We define *saturation intensity* so that, for a resonantly-driven cycling 35 | transition, one saturation intensity gives equal stimulated and spontaneous emission rates. 36 | 37 | Levels and States 38 | ================= 39 | 40 | ``atomic_physics`` is designed to work at any magnetic field which is small compared with 41 | the spin-orbit coupling. In this case we can use LS coupling and label levels by the 42 | quantum numbers ``n``, ``L``, ``S``, ``J`` and ``I``. These are assumed to be 43 | "good" quantum numbers for all fields supported by the atomic physics package. 44 | 45 | ``M`` is a good quantum number at all fields however there is generally more than one 46 | state with a given combination of ``(n, L, S, J, I, M)``. 47 | 48 | When the interaction with the external magnetic field is weak compared to the hyperfine 49 | interaction ``F`` is a good quantum number. When the magnetic field is strong (``M_I``, 50 | ``M_J``) are good quantum numbers. 51 | 52 | In the intermediate field regime none of ``F``, ``M_I`` or ``M_J`` are good quantum 53 | numbers. Despite this, there is a general rule that when the energies of states are 54 | plotted as a function of magnetic field (a Breit-Rabi diagram) the trajectories for 55 | states within the same level with the same value of ``M`` never cross. As a result, 56 | despite not being good quantum numbers, either ``F`` or (``M_I``, ``M_J``) can be 57 | used at arbitrary field to uniquely identify states, with the interpretation "this is 58 | the value of ``F`` (``M_J`` / ``M_J``) which this state would have if we were to 59 | adiabatically ramp the magnetic field to zero (infinity)". Note, however, that these 60 | values do *not* generally correspond to the expectation value of the relevant operators; 61 | they are not good quantum numbers. 62 | 63 | Since we do not make any assumptions about the size of the field compared with the 64 | hyperfine interaction we do not use either (``F``, ``M_F``) or (``M_I``, ``M_J``) 65 | to uniquely identify states by. Instead, we uniquely identify states by an index into a 66 | list of states ordered by *decreasing* energy (with index ``0`` being the atom's 67 | highest-energy state). This convention (as opposed to having index ``0`` be the ground 68 | state) ensures that the Pauli operators have their conventional meaning and signs - for 69 | example with :math:`\sigma_+` being the raising operator and state energies 70 | represented by :math:`H = \frac{1}{2}\omega\sigma_z`. 71 | 72 | :class:`atomic_physics.core.Atom` provides a number of helper functions to convert both 73 | (``F``, ``M_F``) and (``M_I``, ``M_J``) into state indices. 74 | 75 | The Hamiltonian 76 | --------------- 77 | 78 | To calculate the energies of each state we diagonalise the Hamiltonian: 79 | 80 | .. math:: 81 | 82 | H_0 = -\boldsymbol{\mu}\cdot\mathbf{B} 83 | +A \mathbf{I}\cdot\mathbf{J} 84 | +B \left(3\left(\mathbf{I}\cdot\mathbf{J}\right)^2 + \frac{3}{2}\mathbf{I}\cdot{\mathbf{J}} - IJ(I+1)(J+1)\right) 85 | / \left(2IJ(2I - 1)(2J - 1)\right) 86 | 87 | 88 | where: 89 | 90 | * :math:`\boldsymbol{\mu} = - g_J\mu_B\mathbf{J} + g_I\mu_N\mathbf{I}` is the angular 91 | momentum operator. 92 | * :math:`A` and :math:`B` are the are the hyperfine structure constants for the magnetic 93 | dipole and electric quadrupole interactions respectively. 94 | 95 | High-field basis 96 | ---------------- 97 | 98 | Internally, ``atomic_physics`` often works in the high-field (``M_I``, ``M_J``) basis 99 | where the nuclear and electronic spins are decoupled, which makes calculations easier. 100 | Note that these states are not generally energy eignestates. 101 | 102 | We order the states in this basis by increasing ``M_J`` and ``M_I`` (the first state 103 | corresponds to both ``M_J`` and ``M_I`` being 0) *not* by energy ordering. 104 | We order our dimensions as :math:`J \otimes I`. The values of ``M_I`` and ``M_J`` for 105 | each state in this basis are available through :class:`atomic_physics.core.Atom`\'s 106 | `high_field_M_I` and `high_field_M_J` attributes. 107 | 108 | Polarizations 109 | ============= 110 | 111 | We represent polarizations using Jones vectors: 3-element vectors, giving the complex 112 | amplitudes of the relevant field (electric / magnetic, depending on the transition) in 113 | Cartesian coordinates. The field 114 | :math:`\mathbf{A}(t)` is represented by the Jones vector :math:`\mathbf{A}` where 115 | :math:`\mathbf{A}(t) = \Re\left[e^{-iwt} \mathbf{A}\right]`. 116 | 117 | 118 | Axes 119 | ---- 120 | 121 | The quantisation field is assumed to lie along ``z``. We make no assumptions about the 122 | direction of propagation of the fields. 123 | 124 | Jones Matrices 125 | -------------- 126 | 127 | To help manipulating polarization vectors we provide helper functions, which produce 128 | Jones matrices for a range of polarization transformations. The Jones matrices 129 | are complex-valued 3x3 arrays, which act on the Jones vectors through matrix 130 | multiplication (e.g. using numpy's ``@`` operator). Composite transformations can be 131 | formed by chaining Jones matrices through matrix multiplication (right hand side is 132 | applied first). 133 | 134 | The Spherical Basis 135 | ------------------- 136 | 137 | Internally ``atomic_physics`` often works with polarizations in the spherical basis. 138 | This is a convenient choice because angular momentum operators have simple 139 | representations. Helper functions are provided to convert between the Cartesian and 140 | spherical basis. 141 | 142 | The basis vectors are: 143 | 144 | .. math:: 145 | 146 | \hat{\mathbf{e}}_{-1} &= +\frac{1}{\sqrt{2}}\left(\hat{\mathbf{x}} - i\hat{\mathbf{y}}\right)\\ 147 | \hat{\mathbf{e}}_{+1} &= -\frac{1}{\sqrt{2}}\left(\hat{\mathbf{x}} + i\hat{\mathbf{y}}\right)\\ 148 | \hat{\mathbf{e}_0} &= \hat{\mathbf{z}} 149 | 150 | These vectors satisfy orthonormality relations: 151 | 152 | .. math:: 153 | 154 | \hat{\mathbf{e}}_{\pm 1} \cdot \hat{\mathbf{e}}_{\pm 1} &= 0 \\ 155 | \hat{\mathbf{e}}_{\pm 1} \cdot \hat{\mathbf{e}}_{\mp 1} &= -1 \\ 156 | \hat{\mathbf{e}}_{0} \cdot \hat{\mathbf{e}}_{q} &= \delta_{q, 0} 157 | 158 | 159 | In the spherical basis, we use the representation: 160 | 161 | .. math:: 162 | 163 | \mathbf{A} &= \sum_q \left(-1\right)^q A_q \hat{\mathbf{e}}_{-q} \\ 164 | &= \sum_q A_q \hat{\mathbf{e}}_q*\\ 165 | &= -A_{-1} \hat{\mathbf{e}}_{+1} + A_0 \hat{\mathbf{e}}_{0} - A_{+1} \hat{\mathbf{e}}_{-1} 166 | 167 | and represent the vector :math:`\mathbf{A}` by the array 168 | ``np.array((A_{-1}, A_0, A_{+1}))``. 169 | 170 | The dot product of two vectors in the spherical basis is given by: 171 | 172 | .. math:: 173 | 174 | \mathbf{A}\cdot\mathbf{B} = \sum_q \left(-1\right)^q A_q B_{-q} 175 | 176 | Matrix Elements 177 | =============== 178 | 179 | We will encounter Hamiltonians of the form: 180 | 181 | .. math:: 182 | 183 | H = \mathbf{A}(t) \cdot \mathbf{D} 184 | 185 | Where :math:`\mathbf{A}(t)` is the (time-dependent) electric or magnetic field vector 186 | and :math:`\mathbf{D}` is some vector-valued operator. For example, the :ref:`mpole` 187 | Hamiltonian is given by :math:`H = - \boldsymbol{\mu} \cdot \mathbf{B}`. 188 | 189 | We write the part of the Hamiltonian describing the interaction between two states 190 | :math:`\left|u\right>` and :math:`\left|l\right>`, where :math:`\left|u\right>` is the 191 | state with greater energy (the "upper" state) and :math:`\left|l\right>` is the state 192 | with lower energy, as: 193 | 194 | .. math:: 195 | 196 | H^{ul} = \left \sigma_+ + 197 | \left \sigma_- 198 | +\frac{1}{2}\left(\left - \left\right) \sigma_z + 199 | \frac{1}{2}\left(\left + \left\right) \mathbb{1} 200 | 201 | where: 202 | 203 | * all operators act on the (u, l) subspace only. 204 | * :math:`\sigma_\pm = \frac{1}{2}\left(\sigma_x \pm i \sigma_{y}\right)` 205 | * for simplicity, we have neglected the additional :math:`\sigma_z` terms arising from 206 | interactions involving other states. 207 | 208 | For now, we will neglect the :math:`\sigma_z` and :math:`\mathbb{1}` terms. These lead to effective 209 | frequency modulation of the drive field and will be treated in the next section. 210 | In this approximation, the Hamiltonian reduces to: 211 | 212 | .. math:: 213 | 214 | H^{ul} = \left \sigma_+ + \left \sigma_- 215 | 216 | We express the field in terms of its Jones vector, :math:`\mathbf{A}`: 217 | 218 | .. math:: 219 | 220 | \mathbf{A}(t) &= \Re\left[{\mathbf{A} e^{-i\omega t}}\right]\\ 221 | &= \frac{1}{2}\left(\mathbf{A} e^{-i \omega t} + \mathbf{A}^* e^{+i \omega t}\right) 222 | 223 | Thus: 224 | 225 | .. math:: 226 | 227 | H^{ul} = \frac{1}{2}\left( 228 | \left e^{-i \omega t} + 229 | \left e^{+i \omega t} 230 | \right) \sigma_+ + 231 | \frac{1}{2}\left( 232 | \left e^{-i \omega t} + 233 | \left e^{+i \omega t} 234 | \right) \sigma_- 235 | 236 | Moving into the interaction picture with respect to the atomic Hamiltonian 237 | :math:`H_0 = \frac{1}{2}\omega_0 \sigma_z` this Hamiltonian becomes 238 | 239 | .. math:: 240 | 241 | H^{ul} &\rightarrow U^\dagger H^{ul} U\\ 242 | &= e^{\frac{1}{2}i\omega_0 t \sigma_z} H^{ul} e^{-\frac{1}{2}i\omega_0 t \sigma_z} 243 | 244 | where: 245 | 246 | .. math:: 247 | 248 | U &:= e^{-i H_0 t}\\ 249 | &= e^{-\frac{1}{2}i\omega_0 t \sigma_z} 250 | 251 | from the identity: 252 | 253 | .. math:: 254 | 255 | e^{ia\left(\hat{\mathbf{n}}\cdot\boldsymbol{\sigma}\right)} = \mathbb{1}\cos{a} + i{\mathbf{n}}\cdot\boldsymbol{\sigma}\sin{a} 256 | 257 | it follows that: 258 | 259 | .. math:: 260 | 261 | U &= \mathbb{1}\cos{\left(-\frac{1}{2}i\omega_0 t\right)} 262 | + i\sin{\left(-\frac{1}{2}i\omega_0 t\right)}\sigma_z\\ 263 | &= 264 | \left(\begin{matrix} 265 | e^{-\frac{1}{2}i\omega_0 t} & 0\\ 266 | 0 & e^{+\frac{1}{2}i\omega_0 t} 267 | \end{matrix}\right) 268 | \\ 269 | 270 | Multiplying through, we find that: 271 | 272 | .. math:: 273 | 274 | U^\dagger \sigma_\pm U = e^{\pm i\omega_0 t} \sigma_\pm 275 | 276 | Substituting into our Hamiltonian gives: 277 | 278 | .. math:: 279 | H^{ul} &= \frac{1}{2} \left( 280 | \left e^{-i \omega t} + 281 | \left e^{+i \omega t} 282 | \right) e^{i \omega_0 t}\sigma_+\\ 283 | & 284 | + \frac{1}{2}\left( 285 | \left e^{-i \omega t} + 286 | \left e^{+i \omega t} 287 | \right) e^{-i \omega_0 t}\sigma_- 288 | \\ 289 | &= \frac{1}{2}\left e^{i (\omega_0 - \omega) t} \sigma_+ \\ 290 | & + \frac{1}{2}\left e^{i (\omega_0 + \omega) t} \sigma_+ \\ 291 | & + \frac{1}{2} \left e^{-i (\omega + \omega_0) t} \sigma_- \\ 292 | & + \frac{1}{2}\left e^{-i (\omega_0 - \omega) t} \sigma_- \\ 293 | &= \frac{1}{2}\left e^{-i \delta t} \sigma_+ \\ 294 | & + \frac{1}{2}\left e^{i(2\omega_0 + \delta) \delta t} \sigma_+ \\ 295 | & + \frac{1}{2} \left e^{-i(2\omega_0 + delta) t} \sigma_- \\ 296 | & + \frac{1}{2}\left e^{+i \delta t} \sigma_- \\ 297 | 298 | where we have defined the detuning :math:`\omega = \omega_0 + \delta`. 299 | 300 | Making a rotating wave approximation, dropping the counter-rotating terms, results 301 | in the standard Rabi flopping Hamiltonian: 302 | 303 | .. math:: 304 | 305 | H^{ul} &= 306 | \frac{1}{2}\left e^{-i \delta t} \sigma_+ + 307 | & + \frac{1}{2}\left e^{+i \delta t} \sigma_-\\ 308 | &= \frac{1}{2}\Omega e^{-i \delta t} \sigma_+ + \mathrm{hc} 309 | 310 | where ":math:`\mathrm{hc}`" denotes the hermitian conjugate and the Rabi frequency is 311 | given by: 312 | 313 | .. math:: 314 | 315 | \Omega = \left 316 | 317 | .. _fm_mod: 318 | 319 | Frequency Modulation by the Drive Field 320 | --------------------------------------- 321 | 322 | We now come back to the :math:`\sigma_z` terms we neglected in the previous section (the 323 | :math:`\mathbb{1}` terms turn into effective :math:`\sigma_z` terms between different 324 | paris of states) we have: 325 | 326 | .. math:: 327 | 328 | H^z &= \frac{1}{2}\left(\left - \left\right) \sigma_z \\ 329 | 330 | This Hamiltonian is unchanged by moving into the interaction picture with respect to 331 | :math:`{H_0}`. Expanding the form of the Hamiltonian we have 332 | 333 | .. math:: 334 | 335 | H^z &= \frac{1}{2}\left(\left - \left\right) \sigma_z\\ 336 | &= \frac{1}{4}\left(\mathbf{A} e^{-i \omega t} + \mathbf{A}^*e^{+i \omega t}\right) 337 | \cdot\left(\left - \left \right) \sigma_z\\ 338 | 339 | When we move the remainder of :math:`H^{ul}` into the interaction picture with respect to 340 | this Hamiltonian we end up with time dependencies like 341 | :math:`e^{i\left(\delta + \alpha\cos\omega\right)t}`, which are equivalent to frequency modulation 342 | of our RF drive at the RF drive frequency. 343 | 344 | *We will generally ignores this effect, assuming that the modulation depth 345 | is sufficiently small for these terms to be negligible*. However, if the modulation depth 346 | becomes non-negligible these terms will affect the dynamics and must be factored in. 347 | 348 | .. _mpole: 349 | 350 | Magnetic Dipole Interaction 351 | =========================== 352 | 353 | The magnetic dipole Hamiltonian is: 354 | 355 | .. math:: 356 | 357 | H = - \boldsymbol{\mu} \cdot \mathbf{B} 358 | 359 | We wish to find the Rabi frequency: 360 | 361 | .. math:: 362 | 363 | \Omega &= -\left \\ 364 | &= \sum_q \left(-1\right)^{q+1} B_{-q} \left 365 | 366 | The angular momentum operator is given by: 367 | 368 | .. math:: 369 | 370 | \boldsymbol{\mu} = \mu_N g_I \mathbf{I} - \mu_B g_J \mathbf{J} 371 | 372 | We will work in the high-field (:math:`\left|I, J, M_I, M_J\right>`) basis where the 373 | nuclear and electron angular momenta are decoupled. This allows us to consider the two 374 | angular momenta separately. 375 | 376 | To evaluate the matrix element, we need to know the elements of the angular momentum 377 | operator in the spherical basis. These are related to the "ladder operators", 378 | :math:`J_\pm`, by :math:`J_{\pm 1} = \mp \frac{1}{\sqrt{2}}J_\pm` and similarly for :math:`I`. 379 | 380 | We thus have: 381 | 382 | .. math:: 383 | 384 | J_{\pm 1} \left|J, M_J\right> &= \mp \hbar \frac{1}{\sqrt{2}} \sqrt{(J \mp M_J ) (J \pm M_J + 1)}\left|J, M_J\pm1\right>\\ 385 | J_0 \left|J, M_J\right> &= \hbar M_J\left|J, M_J\right> 386 | 387 | It follows that: 388 | 389 | .. math:: 390 | 391 | \left \propto \delta\left(n, m + q\right) 392 | 393 | so: 394 | 395 | .. math:: 396 | 397 | \Omega &= \sum_q (-1)^{q+1} B_{-q} \left \delta\left(M_u, M_l + q\right)\\ 398 | &= R_{ul} B_{-q} 399 | 400 | where: :math:`R_{ul} := (-1)^{q+1}\left` and :math:`q = M_u - M_l`. 401 | We will refer to :math:`R_{ul}` as the "magnetic dipole matrix element". 402 | 403 | Selection Rules 404 | --------------- 405 | 406 | From the above, it follows that: 407 | 408 | * The field :math:`\mathbf{B} = -B_{-1} \hat{\mathbf{e}}_{+1}` drives only :math:`\sigma_+` transitions, for which :math:`M_u - M_l = +1`. 409 | * The field :math:`\mathbf{B} = -B_{+1} \hat{\mathbf{e}}_{-1}` drives only :math:`\sigma_-` transitions, for which :math:`M_u - M_l = -1`. 410 | * The field :math:`\mathbf{B} = B_{0} \hat{\mathbf{e}}_{0}` drives only :math:`\pi` transitions, for which :math:`M_u = M_l`. 411 | 412 | .. _rates: 413 | 414 | Rate Equations 415 | ============== 416 | 417 | Rate equations describe the evolution of state populations resulting from the interaction 418 | between an atom and a set of laser beams, neglecting the impact of coherent interactions 419 | between different transitions. 420 | 421 | We describe the atom's state at time :math:`t` by the population vector 422 | :math:`\mathbf{v}(t)`, which gives the probabilities of the atom being in each state 423 | at time :math:`t` (as usual, :math:`\mathbf{v}(t)_{-1}` is the ground-state probability 424 | and :math:`\mathbf{v}(t)_0` is the probability for the highest-energy state). 425 | 426 | The *transitions matrix*, :math:`T`, describes the evolution of the state population 427 | vector over time: 428 | 429 | .. math:: 430 | 431 | \frac{\mathrm{d}\mathbf{v}}{\mathrm{d}t} = T \mathbf{v}(t) 432 | 433 | Note that :math:`T_{i, j}\mathbf{v}_j` gives the rate of population transfer from state 434 | :math:`j` to state :math:`i`. 435 | 436 | Assuming the laser properties (detuning, intensity, polarization) do not change with 437 | time, this differential equation can be solved to get: 438 | 439 | .. math:: 440 | 441 | \mathbf{v}(t) = e^{T t} \mathbf{v}(t=0) 442 | 443 | The transition matrix is formed from two components: the *stimulated emissions* matrix, 444 | which describes the interaction between the atom and the laser fields; and, 445 | the *spontaneous emissions* matrix, which describes the atom's behaviour in the absence 446 | of any applied lasers. 447 | 448 | Note that, since :math:`T` is a matrix, it should be exponentiated using ``numpy``'s 449 | ``expm`` function. 450 | 451 | Steady State 452 | ------------ 453 | 454 | For cases where all states which are reachable by the atom can decay to the ground state 455 | (i.e. there are no "dark states" which the atom can get stuck in), 456 | the steady-state solution (:math:`t \rightarrow \infty`) is given by the solution to 457 | the equation: 458 | 459 | .. math:: 460 | 461 | \frac{\mathrm{d}\mathbf{v}}{\mathrm{d}t} &= 0\\ 462 | T \mathbf{v}\left(t\rightarrow\infty\right) &= 0 463 | 464 | subject to the constraint that :math:`\sum_i \mathbf{v}\left(t\rightarrow\infty\right)_i = 1` 465 | (i.e. we don't want the trivial solution where there is no population in any state!). 466 | 467 | We impose this constraint by converting the above to the equation: 468 | 469 | .. math:: 470 | 471 | T' \mathbf{v}\left(t\rightarrow\infty\right) = \mathbf{a} 472 | 473 | where: 474 | 475 | .. math:: 476 | 477 | T'_{i, j} &= \left\{ \begin{matrix} 478 | T_{ij} & i \neq 0 \\ 479 | 1 & i = 0 480 | \end{matrix}\right. \\ 481 | 482 | \mathbf{a}_i &= \left\{ \begin{matrix} 483 | 0 & i \neq 0 \\ 484 | 1 & i = 0 485 | \end{matrix}\right. \\ 486 | 487 | NB no information is lost by removing the first row of :math:`T` because it is 488 | rank-deficient, with only :math:`N - 1` linearly independent rows for an atom with 489 | :math:`N` states (the transition rate out of any state must be equal to the 490 | sum of the rates of transitions from that state into all other states). -------------------------------------------------------------------------------- /atomic_physics/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import numpy as np 4 | import scipy.constants as consts 5 | 6 | from atomic_physics.core import RFDrive 7 | from atomic_physics.ions.ca40 import Ca40 8 | from atomic_physics.ions.ca43 import Ca43 9 | from atomic_physics.polarization import ( 10 | PI_POLARIZATION, 11 | SIGMA_MINUS_POLARIZATION, 12 | SIGMA_PLUS_POLARIZATION, 13 | ) 14 | from atomic_physics.utils import ( 15 | ac_zeeman_shift_for_state, 16 | ac_zeeman_shift_for_transition, 17 | d2f_dB2, 18 | df_dB, 19 | field_insensitive_point, 20 | rayleigh_range, 21 | ) 22 | 23 | 24 | def ac_zeeman(Omega, w_transition, w_rf): 25 | return 0.5 * (Omega**2) * (w_transition / (w_transition**2 - w_rf**2)) 26 | 27 | 28 | class TestUtils(unittest.TestCase): 29 | """Tests for ``atomic_physics.utils``""" 30 | 31 | def test_rayleigh_range(self): 32 | waist = 33e-6 33 | transition = Ca43.transitions["397"] 34 | wavelength = consts.c / (transition.frequency / (2 * np.pi)) 35 | 36 | np.testing.assert_allclose( 37 | np.pi * waist**2 / wavelength, 38 | rayleigh_range(Ca43.transitions["397"], waist), 39 | ) 40 | 41 | 42 | class TestFieldSensitivity(unittest.TestCase): 43 | """Tests for field sensitivity helpers. 44 | 45 | References: 46 | [1] T Harty DPhil Thesis. 47 | """ 48 | 49 | def test_df_db(self): 50 | """Tests for ``utils.dF_dB``.""" 51 | ion = Ca43(magnetic_field=146.0942e-4) 52 | 53 | # [1] table E.4 54 | values = [ 55 | (-4, -3, 2.519894), 56 | (-3, -3, 2.237093), 57 | (-3, -2, 1.940390), 58 | (-2, -3, 1.939817), 59 | (-2, -2, 1.643113), 60 | (-2, -1, 1.330090), 61 | (-1, -2, 1.329517), 62 | (-1, -1, 1.016493), 63 | (-1, 0, 0.684932), 64 | (0, -1, 0.684359), 65 | (0, 0, 0.352797), 66 | (0, +1, 0.0), 67 | (+1, 0, -0.000573), 68 | (+1, +1, -0.353370), 69 | (+1, +2, -0.730728), 70 | (+2, +1, -0.731301), 71 | (+2, +2, -1.108659), 72 | (+2, +3, -1.514736), 73 | (+3, +2, -1.515309), 74 | (+3, +3, -1.921385), 75 | (+4, +3, -2.362039), 76 | ] 77 | 78 | for M4, M3, df_dB_ref in values: 79 | l_index = ion.get_state_for_F(level=Ca43.ground_level, F=4, M_F=M4) 80 | u_index = ion.get_state_for_F(level=Ca43.ground_level, F=3, M_F=M3) 81 | np.testing.assert_allclose( 82 | df_dB( 83 | atom_factory=Ca43, 84 | states=(l_index, u_index), 85 | magnetic_field=146.0942e-4, 86 | ) 87 | / (2 * np.pi * 1e10), 88 | df_dB_ref, 89 | atol=1e-6, 90 | ) 91 | 92 | def test_d2f_db2(self): 93 | """Tests for ``utils.d2F_dB2``.""" 94 | ion = Ca43(magnetic_field=146.0942e-4) 95 | 96 | np.testing.assert_allclose( 97 | d2f_dB2( 98 | atom_factory=Ca43, 99 | magnetic_field=146.0942e-4, 100 | states=( 101 | ion.get_state_for_F(Ca43.S12, F=4, M_F=0), 102 | ion.get_state_for_F(Ca43.S12, F=3, M_F=+1), 103 | ), 104 | ) 105 | / (2 * np.pi * 1e11), 106 | 2.416, 107 | atol=1e-3, 108 | ) 109 | 110 | def test_field_insensitive_point(self): 111 | """Tests for ``utils.field_insensitive_point``.""" 112 | np.testing.assert_allclose( 113 | field_insensitive_point( 114 | atom_factory=Ca43, 115 | level_0=Ca43.S12, 116 | F_0=4, 117 | M_F_0=0, 118 | level_1=Ca43.S12, 119 | F_1=3, 120 | M_F_1=+1, 121 | magnetic_field_guess=10e-4, 122 | ), 123 | 146.0942e-4, 124 | atol=1e-4, 125 | ) 126 | 127 | 128 | class TestACZeeman(unittest.TestCase): 129 | """Tests for `atomic_physics.utils`. 130 | 131 | References: 132 | [1] Thomas Harty DPhil Thesis (2013). 133 | """ 134 | 135 | def test_acz_ca40(self): 136 | """ 137 | Test the AC Zeeman shift for the ground level qubit in a 40Ca+ ion. 138 | """ 139 | B_rf = 1e-6 # RF field of 1 uT 140 | ion = Ca40.filter_levels(level_filter=(Ca40.ground_level,))( 141 | magnetic_field=10e-4 142 | ) 143 | 144 | w_0 = ion.get_transition_frequency_for_states((0, 1)) 145 | mu = ion.get_magnetic_dipoles()[0, 1] 146 | rabi_freq = mu * B_rf / consts.hbar 147 | detuning = 2 * np.pi * 3e6 148 | w_rf = w_0 + detuning # blue detuning so expect a negative overall shift 149 | 150 | # All pi shifts should be zero since there are no pi spectator transitions. 151 | rf_drive_pi = RFDrive( 152 | frequency=w_rf, amplitude=B_rf, polarization=PI_POLARIZATION 153 | ) 154 | np.testing.assert_allclose( 155 | ( 156 | ac_zeeman_shift_for_state(ion, 0, rf_drive_pi), 157 | ac_zeeman_shift_for_state(ion, 1, rf_drive_pi), 158 | ac_zeeman_shift_for_transition(ion, (0, 1), rf_drive_pi), 159 | ), 160 | 0.0, 161 | ) 162 | 163 | # All sigma minus shifts should be zero since there are no sigma minus 164 | # spectator transitions. 165 | rf_drive_minus = RFDrive( 166 | frequency=w_rf, amplitude=B_rf, polarization=SIGMA_MINUS_POLARIZATION 167 | ) 168 | np.testing.assert_allclose( 169 | ( 170 | ac_zeeman_shift_for_state(ion, 0, rf_drive_minus), 171 | ac_zeeman_shift_for_state(ion, 1, rf_drive_minus), 172 | ac_zeeman_shift_for_transition(ion, (0, 1), rf_drive_minus), 173 | ), 174 | 0.0, 175 | ) 176 | 177 | # Calculate the absolute value of the expected AC Zeeman shift for each state. 178 | expected_shift_abs = np.abs(ac_zeeman(rabi_freq, w_0, w_rf)) 179 | 180 | rf_drive_plus = RFDrive( 181 | frequency=w_rf, amplitude=B_rf, polarization=SIGMA_PLUS_POLARIZATION 182 | ) 183 | acz_sigma_p_0 = ac_zeeman_shift_for_state(ion, 0, rf_drive_plus) 184 | acz_sigma_p_1 = ac_zeeman_shift_for_state(ion, 1, rf_drive_plus) 185 | acz_sigma_p_transition = ac_zeeman_shift_for_transition( 186 | ion, (0, 1), rf_drive_plus 187 | ) 188 | # The higher energy state moves down in energy by the expected shift and 189 | # the lower energy state moves up in energy by the expected shift since 190 | # the RF frequency is higher than the transition frequency. The frequency 191 | # of the transition decreases by double the expected shift. 192 | self.assertAlmostEqual(acz_sigma_p_0, -expected_shift_abs, delta=1e-8) 193 | self.assertAlmostEqual(acz_sigma_p_1, expected_shift_abs, delta=1e-8) 194 | self.assertAlmostEqual( 195 | acz_sigma_p_transition, -2 * expected_shift_abs, delta=1e-8 196 | ) 197 | 198 | def test_acz_ca43(self): 199 | """Compare AC Zeeman shifts in the ground-level of 43Ca+ at 146G to values from 200 | [1] Chapter 6. 201 | """ 202 | B_rf = 1e-6 # RF field of 1 uT 203 | level = Ca43.ground_level 204 | factory = Ca43.filter_levels(level_filter=(level,)) 205 | ion = factory(magnetic_field=146.0942e-4) 206 | 207 | # RF frequency calculated relative to the transition frequency of the 208 | # (4, 0) <-> (3, 1) qubit pair. 209 | w_0 = ion.get_transition_frequency_for_states( 210 | ( 211 | ion.get_state_for_F(level, F=4, M_F=0), 212 | ion.get_state_for_F(level, F=3, M_F=+1), 213 | ), 214 | ) 215 | 216 | detuning = 2 * np.pi * 3e6 217 | 218 | # Qubit pairs in Tables 6.3, 6.4, and 6.5 in [1] and the expected shifts 219 | # in order [pi_p, pi_m, sigma_p, sigma_m], where _p and _m denote positive or 220 | # negative detunings. 221 | refs = [ 222 | ((4, 0), (3, 0), 2 * np.pi * np.array([2.005, 1.786, -15.344, 16.836])), 223 | ((4, 0), (3, 1), 2 * np.pi * np.array([0.126, -0.0926, -16.298, 15.864])), 224 | ((4, 1), (3, 1), 2 * np.pi * np.array([-1.753, -1.971, -16.341, 15.840])), 225 | ] 226 | 227 | rf_drives = ( 228 | RFDrive( 229 | frequency=w_0 + detuning, amplitude=B_rf, polarization=PI_POLARIZATION 230 | ), 231 | RFDrive( 232 | frequency=w_0 - detuning, amplitude=B_rf, polarization=PI_POLARIZATION 233 | ), 234 | RFDrive( 235 | frequency=w_0 + detuning, 236 | amplitude=B_rf, 237 | polarization=SIGMA_MINUS_POLARIZATION + SIGMA_PLUS_POLARIZATION, 238 | ), 239 | RFDrive( 240 | frequency=w_0 - detuning, 241 | amplitude=B_rf, 242 | polarization=SIGMA_MINUS_POLARIZATION + SIGMA_PLUS_POLARIZATION, 243 | ), 244 | ) 245 | 246 | for (_, M4), (_, M3), ref_shifts in refs: 247 | idx0 = ion.get_state_for_F(level=level, F=4, M_F=M4) 248 | idx1 = ion.get_state_for_F(level=level, F=3, M_F=M3) 249 | 250 | shifts = np.array( 251 | [ 252 | ac_zeeman_shift_for_transition(ion, (idx0, idx1), rf_drive) 253 | for rf_drive in rf_drives 254 | ] 255 | ) 256 | np.testing.assert_allclose( 257 | shifts / (2 * np.pi), ref_shifts / (2 * np.pi), atol=1e-3 258 | ) 259 | 260 | def test_acz_ca43_rf(self): 261 | """Compare AC Zeeman shifts in the ground-level of 43Ca+ at 146G to values from 262 | [1] table 5.9. 263 | """ 264 | level = Ca43.ground_level 265 | ion = Ca43.filter_levels(level_filter=(level,))(magnetic_field=146.0942e-4) 266 | idx_qubit_0 = ion.get_state_for_F(level, F=4, M_F=0) 267 | idx_qubit_1 = ion.get_state_for_F(level, F=3, M_F=+1) 268 | 269 | B_rf = 1e-6 270 | w_rf = 2 * np.pi * 38.2e6 271 | 272 | drives = ( 273 | RFDrive(frequency=w_rf, amplitude=B_rf, polarization=PI_POLARIZATION), 274 | RFDrive( 275 | frequency=w_rf, 276 | amplitude=B_rf, 277 | polarization=SIGMA_MINUS_POLARIZATION + SIGMA_PLUS_POLARIZATION, 278 | ), 279 | ) 280 | 281 | shifts = np.array( 282 | [ 283 | ac_zeeman_shift_for_transition(ion, (idx_qubit_0, idx_qubit_1), drive) 284 | for drive in drives 285 | ] 286 | ) 287 | np.testing.assert_allclose( 288 | shifts / (2 * np.pi), 289 | np.array([0.0604, -0.3976]), 290 | atol=1e-4, 291 | ) 292 | 293 | def test_acz_ca43_d(self): 294 | """Test that we calculate the AC Zeeman shifts correct in the D-levels of 295 | 43Ca+. 296 | 297 | We pick a level with J>1/2 and work at intermediate field (lots of state 298 | mixing) to ensure there is lots going on in these calculations to provide 299 | a good stress test of the code. 300 | """ 301 | level = Ca43.D52 302 | ion = Ca43(magnetic_field=10e-4) 303 | 304 | upper = ion.get_state_for_F(level, F=3, M_F=1) # state 65 305 | lower = ion.get_state_for_F(level, F=4, M_F=+1) # state 70 306 | 307 | # calculate the shift due to pi-polarized radiation 308 | w_0 = ion.get_transition_frequency_for_states((lower, upper)) 309 | detuning = 2e6 * 2 * np.pi 310 | w_rf = w_0 + detuning 311 | drive = RFDrive(frequency=w_rf, amplitude=1e-6, polarization=PI_POLARIZATION) 312 | 313 | # shifts on lower-energy state 314 | victim = lower 315 | shift_lower = 0.0 316 | 317 | spectator = ion.get_state_for_F(level, F=1, M_F=1) # state 50 318 | w_transition = ion.get_transition_frequency_for_states((victim, spectator)) 319 | Omega = ion.get_rabi_rf(victim, spectator, 1e-6) 320 | sign = -1 # victim is the lower-energy state in this transition 321 | shift_lower += sign * ac_zeeman(Omega, w_transition, w_rf) 322 | 323 | spectator = ion.get_state_for_F(level, F=2, M_F=1) # state 57 324 | w_transition = ion.get_transition_frequency_for_states((victim, spectator)) 325 | Omega = ion.get_rabi_rf(victim, spectator, 1e-6) 326 | sign = -1 # victim is the lower-energy state in this transition 327 | shift_lower += sign * ac_zeeman(Omega, w_transition, w_rf) 328 | 329 | spectator = ion.get_state_for_F(level, F=3, M_F=1) # state 65 330 | w_transition = ion.get_transition_frequency_for_states((victim, spectator)) 331 | Omega = ion.get_rabi_rf(victim, spectator, 1e-6) 332 | sign = -1 # victim is the lower-energy state in this transition 333 | shift_lower += sign * ac_zeeman(Omega, w_transition, w_rf) 334 | 335 | spectator = ion.get_state_for_F(level, F=5, M_F=1) # state 79 336 | w_transition = ion.get_transition_frequency_for_states((victim, spectator)) 337 | Omega = ion.get_rabi_rf(victim, spectator, 1e-6) 338 | sign = +1 # victim is the higher-energy state in this transition 339 | shift_lower += sign * ac_zeeman(Omega, w_transition, w_rf) 340 | 341 | spectator = ion.get_state_for_F(level, F=6, M_F=1) # state 87 342 | w_transition = ion.get_transition_frequency_for_states((victim, spectator)) 343 | Omega = ion.get_rabi_rf(victim, spectator, 1e-6) 344 | sign = +1 # victim is the higher-energy state in this transition 345 | shift_lower += sign * ac_zeeman(Omega, w_transition, w_rf) 346 | 347 | # shifts on upper state 348 | victim = upper 349 | shift_upper = 0.0 350 | 351 | spectator = ion.get_state_for_F(level, F=1, M_F=1) # state 50 352 | w_transition = ion.get_transition_frequency_for_states((victim, spectator)) 353 | Omega = ion.get_rabi_rf(victim, spectator, 1e-6) 354 | sign = -1 # victim is the lower-energy state in this transition 355 | shift_upper += sign * ac_zeeman(Omega, w_transition, w_rf) 356 | 357 | spectator = ion.get_state_for_F(level, F=2, M_F=1) # state 57 358 | w_transition = ion.get_transition_frequency_for_states((victim, spectator)) 359 | Omega = ion.get_rabi_rf(victim, spectator, 1e-6) 360 | sign = -1 # victim is the lower-energy state in this transition 361 | shift_upper += sign * ac_zeeman(Omega, w_transition, w_rf) 362 | 363 | spectator = ion.get_state_for_F(level, F=4, M_F=1) # state 70 364 | w_transition = ion.get_transition_frequency_for_states((victim, spectator)) 365 | Omega = ion.get_rabi_rf(victim, spectator, 1e-6) 366 | sign = +1 # victim is the higher-energy state in this transition 367 | shift_upper += sign * ac_zeeman(Omega, w_transition, w_rf) 368 | 369 | spectator = ion.get_state_for_F(level, F=5, M_F=1) # state 79 370 | w_transition = ion.get_transition_frequency_for_states((victim, spectator)) 371 | Omega = ion.get_rabi_rf(victim, spectator, 1e-6) 372 | sign = +1 # victim is the higher-energy state in this transition 373 | shift_upper += sign * ac_zeeman(Omega, w_transition, w_rf) 374 | 375 | spectator = ion.get_state_for_F(level, F=6, M_F=1) # state 87 376 | w_transition = ion.get_transition_frequency_for_states((victim, spectator)) 377 | Omega = ion.get_rabi_rf(victim, spectator, 1e-6) 378 | sign = +1 # victim is the higher-energy state in this transition 379 | shift_upper += sign * ac_zeeman(Omega, w_transition, w_rf) 380 | 381 | np.testing.assert_allclose( 382 | shift_upper - shift_lower, 383 | ac_zeeman_shift_for_transition(ion, (lower, upper), drive), 384 | ) 385 | 386 | # calculate the shift on the same transition due to sigma+-polarized radiation 387 | drive = RFDrive( 388 | frequency=w_rf, amplitude=1e-6, polarization=SIGMA_PLUS_POLARIZATION 389 | ) 390 | 391 | # shifts on lower-energy state 392 | victim = lower # F=4, M_F=+1, state 70 393 | shift_lower = 0.0 394 | 395 | spectator = ion.get_state_for_F(level, F=2, M_F=2) # state 51 396 | w_transition = ion.get_transition_frequency_for_states((victim, spectator)) 397 | Omega = ion.get_rabi_rf(victim, spectator, 1e-6) 398 | sign = -1 # victim is the lower-energy state in this transition 399 | shift_lower += sign * ac_zeeman(Omega, w_transition, w_rf) 400 | 401 | spectator = ion.get_state_for_F(level, F=3, M_F=2) # state 58 402 | w_transition = ion.get_transition_frequency_for_states((victim, spectator)) 403 | Omega = ion.get_rabi_rf(victim, spectator, 1e-6) 404 | sign = -1 # victim is the lower-energy state in this transition 405 | shift_lower += sign * ac_zeeman(Omega, w_transition, w_rf) 406 | 407 | spectator = ion.get_state_for_F(level, F=4, M_F=2) # state 67 408 | w_transition = ion.get_transition_frequency_for_states((victim, spectator)) 409 | Omega = ion.get_rabi_rf(victim, spectator, 1e-6) 410 | sign = -1 # victim is the lower-energy state in this transition 411 | shift_lower += sign * ac_zeeman(Omega, w_transition, w_rf) 412 | 413 | spectator = ion.get_state_for_F(level, F=4, M_F=0) # state 71 414 | w_transition = ion.get_transition_frequency_for_states((victim, spectator)) 415 | Omega = ion.get_rabi_rf(victim, spectator, 1e-6) 416 | sign = +1 # victim is the higher-energy state in this transition 417 | shift_lower += sign * ac_zeeman(Omega, w_transition, w_rf) 418 | 419 | spectator = ion.get_state_for_F(level, F=5, M_F=0) # state 81 420 | w_transition = ion.get_transition_frequency_for_states((victim, spectator)) 421 | Omega = ion.get_rabi_rf(victim, spectator, 1e-6) 422 | sign = +1 # victim is the higher-energy state in this transition 423 | shift_lower += sign * ac_zeeman(Omega, w_transition, w_rf) 424 | 425 | spectator = ion.get_state_for_F(level, F=6, M_F=0) # state 89 426 | w_transition = ion.get_transition_frequency_for_states((victim, spectator)) 427 | Omega = ion.get_rabi_rf(victim, spectator, 1e-6) 428 | sign = +1 # victim is the higher-energy state in this transition 429 | shift_lower += sign * ac_zeeman(Omega, w_transition, w_rf) 430 | 431 | # shifts on upper state 432 | victim = upper # F=3, M_F=+1, state 65 433 | shift_upper = 0.0 434 | 435 | spectator = ion.get_state_for_F(level, F=2, M_F=2) # state 51 436 | w_transition = ion.get_transition_frequency_for_states((victim, spectator)) 437 | Omega = ion.get_rabi_rf(victim, spectator, 1e-6) 438 | sign = -1 # victim is the lower-energy state in this transition 439 | shift_upper += sign * ac_zeeman(Omega, w_transition, w_rf) 440 | 441 | spectator = ion.get_state_for_F(level, F=3, M_F=2) # state 58 442 | w_transition = ion.get_transition_frequency_for_states((victim, spectator)) 443 | Omega = ion.get_rabi_rf(victim, spectator, 1e-6) 444 | sign = -1 # victim is the lower-energy state in this transition 445 | shift_upper += sign * ac_zeeman(Omega, w_transition, w_rf) 446 | 447 | spectator = ion.get_state_for_F(level, F=4, M_F=0) # state 71 448 | w_transition = ion.get_transition_frequency_for_states((victim, spectator)) 449 | Omega = ion.get_rabi_rf(victim, spectator, 1e-6) 450 | sign = +1 # victim is the higher-energy state in this transition 451 | shift_upper += sign * ac_zeeman(Omega, w_transition, w_rf) 452 | 453 | spectator = ion.get_state_for_F(level, F=5, M_F=0) # state 81 454 | w_transition = ion.get_transition_frequency_for_states((victim, spectator)) 455 | Omega = ion.get_rabi_rf(victim, spectator, 1e-6) 456 | sign = +1 # victim is the higher-energy state in this transition 457 | shift_upper += sign * ac_zeeman(Omega, w_transition, w_rf) 458 | 459 | spectator = ion.get_state_for_F(level, F=6, M_F=0) # state 89 460 | w_transition = ion.get_transition_frequency_for_states((victim, spectator)) 461 | Omega = ion.get_rabi_rf(victim, spectator, 1e-6) 462 | sign = +1 # victim is the higher-energy state in this transition 463 | shift_upper += sign * ac_zeeman(Omega, w_transition, w_rf) 464 | 465 | np.testing.assert_allclose( 466 | shift_upper - shift_lower, 467 | ac_zeeman_shift_for_transition(ion, (lower, upper), drive), 468 | ) 469 | -------------------------------------------------------------------------------- /atomic_physics/tests/test_atom.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import numpy as np 4 | from scipy import constants as consts 5 | 6 | from atomic_physics.core import AtomFactory, Level, LevelData 7 | from atomic_physics.ions.ba133 import Ba133 8 | from atomic_physics.ions.ba137 import Ba137 9 | from atomic_physics.ions.ca43 import Ca43 10 | from atomic_physics.operators import ( 11 | AngularMomentumLoweringOp, 12 | AngularMomentumProjectionOp, 13 | AngularMomentumRaisingOp, 14 | expectation_value, 15 | ) 16 | 17 | 18 | class TestAtom(unittest.TestCase): 19 | """Tests for :class:`atomic_physics.core.Atom`. 20 | 21 | References: 22 | [1] Thomas Harty DPhil Thesis (2013). 23 | [2] David Szwer DPhil Thesis (2009). 24 | """ 25 | 26 | def test_num_states(self): 27 | """Check that the ``num_states`` is calculated correctly and that all attributes 28 | have the correct shape. 29 | """ 30 | ion = Ca43(magnetic_field=146.0942e-4) 31 | 32 | num_states = 0 33 | for level in ion.level_data.keys(): 34 | num_states += (2 * ion.nuclear_spin + 1) * (2 * level.J + 1) 35 | assert num_states == ion.num_states 36 | 37 | assert ion.num_states == num_states 38 | assert ion.state_energies.shape == (num_states,) 39 | assert ion.state_vectors.shape == (num_states, num_states) 40 | assert ion.high_field_M_I.shape == (num_states,) 41 | assert ion.high_field_M_J.shape == (num_states,) 42 | assert ion.M.shape == (num_states,) 43 | assert ion.F.shape == (num_states,) 44 | assert ion.M_I.shape == (num_states,) 45 | assert ion.M_J.shape == (num_states,) 46 | 47 | def test_state_vectors(self): 48 | """ 49 | Check the calculation for ``state_vectors`` against values for the ground-level 50 | of 43Ca+ at 146G using [1] table E.2. 51 | """ 52 | ion = Ca43(magnetic_field=146.0942e-4) 53 | level_slice = ion.get_slice_for_level(Ca43.ground_level) 54 | 55 | # M, ((M_J, M_I, coeff_M_J, coeff_M_I)) 56 | expansions = ( 57 | (-4, ((-1 / 2, -7 / 2, 1.0, 0.0), (-1 / 2, -7 / 2, 1.0, 0.0))), 58 | (-3, ((-1 / 2, -5 / 2, 0.9483, 0.3175), (+1 / 2, -7 / 2, 0.3175, -0.9483))), 59 | (-2, ((-1 / 2, -3 / 2, 0.8906, 0.4548), (+1 / 2, -5 / 2, 0.4548, -0.8906))), 60 | (-1, ((-1 / 2, -1 / 2, 0.8255, 0.5645), (+1 / 2, -3 / 2, 0.5645, -0.8255))), 61 | (0, ((-1 / 2, +1 / 2, 0.7503, 0.6611), (+1 / 2, -1 / 2, 0.6611, -0.7503))), 62 | (+1, ((-1 / 2, +3 / 2, 0.6610, 0.7504), (+1 / 2, +1 / 2, 0.7504, -0.6610))), 63 | (+2, ((-1 / 2, +5 / 2, 0.5497, 0.8354), (+1 / 2, +3 / 2, 0.8354, -0.5497))), 64 | (+3, ((-1 / 2, +7 / 2, 0.3964, 0.9181), (+1 / 2, +5 / 2, 0.9181, -0.3964))), 65 | (+4, ((+1 / 2, +7 / 2, 1.0, 0.0), (+1 / 2, +7 / 2, 1.0, 0.0))), 66 | ) 67 | 68 | state_vectors = ion.state_vectors[level_slice, :] 69 | basis_M_I = ion.high_field_M_I[level_slice] 70 | basis_M_J = ion.high_field_M_J[level_slice] 71 | 72 | matched = np.full((len(expansions), 2), False, dtype=bool) 73 | for expansion_idx, (M, (alpha_expansion, beta_expansion)) in enumerate( 74 | expansions 75 | ): 76 | indicies = ion.get_states_for_M(level=Ca43.S12, M=M) 77 | 78 | for ind in indicies: 79 | state_vector = state_vectors[:, ind] 80 | 81 | # Each |M> state is a superposition of two high-field states: 82 | # alpha*|M_J=-1/2, M_I=M+1/2> + beta*|M_J=+1/2, M_I=M-1/2> 83 | # NB if |psi> is an eigenstate then so is -|psi> so the overall sign of 84 | # alpha and beta is not uniquely-defined (although the sign of their ratio 85 | # is). [1] adopts a convention where alpha is always positive; we do the 86 | # same here for the sake of comparison. 87 | 88 | state_ind = np.argwhere( 89 | np.logical_and( 90 | basis_M_J == alpha_expansion[0], 91 | basis_M_I == alpha_expansion[1], 92 | ) 93 | ) 94 | alpha = state_vector[state_ind].ravel()[0] 95 | 96 | state_ind = np.argwhere( 97 | np.logical_and( 98 | basis_M_J == beta_expansion[0], 99 | basis_M_I == beta_expansion[1], 100 | ) 101 | ) 102 | beta = state_vector[state_ind].ravel()[0] 103 | 104 | # Apply the sign convention adopted in [1] 105 | if alpha < 0: 106 | alpha = -alpha 107 | beta = -beta 108 | 109 | closest = np.argmin(np.abs(alpha - [alpha_expansion[2:]])) 110 | alpha_ref = alpha_expansion[closest + 2] 111 | beta_ref = beta_expansion[closest + 2] 112 | np.testing.assert_allclose( 113 | (alpha, beta), 114 | (alpha_ref, beta_ref), 115 | atol=1e-4, 116 | ) 117 | matched[expansion_idx, closest] = True 118 | 119 | # Paranoia check: the above test could theoretically pass with us incorrectly 120 | # finding the same expansion coefficients for two states. Check this isn't the 121 | # case 122 | 123 | # There is only 1 way of making the stretched state! 124 | if any(matched[0, :]): 125 | matched[0, :] = True 126 | if any(matched[-1, :]): 127 | matched[-1, :] = True 128 | 129 | assert np.all(matched) 130 | 131 | def test_levels(self): 132 | """Check ``levels`` is formed correctly.""" 133 | ion = Ca43(magnetic_field=1.0) 134 | assert len(set(ion.levels)) == len(ion.levels) 135 | assert set(ion.levels) == set( 136 | [Ca43.S12, Ca43.P12, Ca43.P32, Ca43.D32, Ca43.D52] 137 | ) 138 | assert set(ion.levels) == set(ion.level_data.keys()) 139 | assert set(ion.levels) == set(ion.level_states.keys()) 140 | 141 | for level, data in ion.level_data.items(): 142 | assert data.level == level 143 | 144 | def test_state_energies(self): 145 | """ 146 | Check the calculation for ``state_energies`` against values for the ground-level 147 | transitions within 43Ca+ at 146G using [1] table E.2. 148 | 149 | This also checks the behaviour of ``get_transition_for_levels``. 150 | """ 151 | ion = Ca43(magnetic_field=146.0942e-4) 152 | 153 | # check that the mean energy of every level is zero (state energies are defined 154 | # relative to the level's centre of gravity) 155 | for level in ion.levels: 156 | level_slice = ion.get_slice_for_level(level) 157 | level_energies = ion.state_energies[level_slice] 158 | np.testing.assert_allclose( 159 | np.sum(level_energies), 0, atol=max(level_energies) * 1e-9 160 | ) 161 | 162 | # M_4, M_3, frequency 163 | ref = ( 164 | (-4, -3, 3.589033180), 165 | (-3, -3, 3.543000725), 166 | (-3, -2, 3.495825733), 167 | (-2, -3, 3.4957420317), 168 | (-2, -2, 3.448567039), 169 | (-2, -1, 3.4000621586), 170 | (-1, -2, 3.399978455), 171 | (-1, -1, 3.351473573), 172 | (-1, 0, 3.301519669), 173 | (0, -1, 3.301435966), 174 | (0, 0, 3.2514820620), 175 | (0, +1, 3.199941077), 176 | (+1, 0, 3.199857374), 177 | (+1, +1, 3.148316389), 178 | (+1, +2, 3.095026842), 179 | (+2, +1, 3.094943139), 180 | (+2, +2, 3.041653592), 181 | (+2, +3, 2.986424605), 182 | (+3, +2, 2.986340903), 183 | (+3, +3, 2.931111915), 184 | (+4, +3, 2.873631427), 185 | ) 186 | for M_4, M_3, ref_frequency in ref: 187 | F_4_ind = ion.get_state_for_F(level=Ca43.ground_level, F=4, M_F=M_4) 188 | F_3_ind = ion.get_state_for_F(level=Ca43.ground_level, F=3, M_F=M_3) 189 | 190 | np.testing.assert_allclose( 191 | (ion.state_energies[F_3_ind] - ion.state_energies[F_4_ind]) 192 | / (2 * np.pi), 193 | ref_frequency * 1e9, 194 | atol=1, 195 | ) 196 | 197 | np.testing.assert_allclose( 198 | ion.get_transition_frequency_for_states(states=(F_4_ind, F_3_ind)) 199 | / (2 * np.pi), 200 | ref_frequency * 1e9, 201 | atol=1, 202 | ) 203 | 204 | def test_magnetic_dipoles(self): 205 | """ 206 | Check the calculation for `state_vectors` for 43Ca+ at 146G against [1] table E.4. 207 | """ 208 | uB = consts.physical_constants["Bohr magneton"][0] 209 | ion = Ca43(magnetic_field=146.0942e-4) 210 | level_slice = ion.get_slice_for_level(Ca43.ground_level) 211 | 212 | magnetic_dipoles = ion.get_magnetic_dipoles() 213 | 214 | values = ( 215 | (-4, -3, 1.342420), 216 | (-3, -3, 0.602802), 217 | (-3, -2, 1.195577), 218 | (-2, -3, 0.204417), 219 | (-2, -2, 0.810866), 220 | (-2, -1, 1.040756), 221 | (-1, -2, 0.363393), 222 | (-1, -1, 0.932839), 223 | (-1, 0, 0.876791), 224 | (0, -1, 0.528271), 225 | (0, 0, 0.993060), 226 | (0, +1, 0.702127), 227 | (+1, 0, 0.702254), 228 | (+1, +1, 0.993034), 229 | (+1, +2, 0.514410), 230 | (+2, +1, 0.887366), 231 | (+2, +2, 0.919344), 232 | (+2, +3, 0.308504), 233 | (+3, +2, 1.085679), 234 | (+3, +3, 0.728641), 235 | (+4, +3, 1.299654), 236 | ) 237 | 238 | for M4, M3, R_ref in values: 239 | u_index = ion.get_state_for_F(Ca43.S12, F=3, M_F=M3) 240 | l_index = ion.get_state_for_F(Ca43.S12, F=4, M_F=M4) 241 | 242 | np.testing.assert_allclose( 243 | np.abs(magnetic_dipoles[u_index, l_index]) / uB, R_ref, atol=1e-6 244 | ) 245 | 246 | # Check all forbidden transitions have 0 matrix element 247 | # Check that Rnm = (-1)^q Rmn 248 | level = Ca43.S12 249 | level_slice = ion.get_slice_for_level(level) 250 | for i_ind in np.arange(ion.num_states)[level_slice]: 251 | for j_ind in np.arange(ion.num_states)[level_slice]: 252 | upper_ind = min(i_ind, j_ind) 253 | lower_ind = max(i_ind, j_ind) 254 | q = int(np.rint(ion.M[upper_ind] - ion.M[lower_ind])) 255 | if np.abs(q) > 1: 256 | np.testing.assert_allclose(magnetic_dipoles[i_ind, j_ind], 0) 257 | np.testing.assert_allclose(magnetic_dipoles[j_ind, i_ind], 0) 258 | 259 | np.testing.assert_allclose( 260 | magnetic_dipoles[upper_ind, lower_ind], 261 | (-1) ** (q) * magnetic_dipoles[lower_ind, upper_ind], 262 | ) 263 | 264 | np.testing.assert_allclose(np.diag(magnetic_dipoles[level_slice]), 0) 265 | 266 | def test_F_positive_ground(self): 267 | """Check we're assigning the right value of F to each state. 268 | 269 | This test looks at the ground level of 137Ba+, which has a positive hyperfine A 270 | coefficient. 271 | """ 272 | level = Ba137.ground_level 273 | factory = Ba137.filter_levels(level_filter=(level,)) 274 | 275 | # Make sure we get the relationships right over a range of fields 276 | for magnetic_field in [1e-4, 100e-4, 1000e-4, 1, 10]: 277 | ion = factory(magnetic_field=magnetic_field) 278 | 279 | # 137Ba+ has I=3/2 so the ground level has F=1 and F=2. A is positive so 280 | # F=2 has higher energy. 281 | 282 | # check that states in F=2 have indices 0-4 283 | # states with higher M_F have greater energy 284 | inds = np.array( 285 | [ion.get_state_for_F(level, F=+2, M_F=M_F) for M_F in range(-2, +3)] 286 | ) 287 | assert all(inds == np.arange(5)[::-1]) 288 | 289 | # check that states in F=1 have indices 5-7 290 | # states with higher M_F have lower energy 291 | inds = np.array( 292 | [ion.get_state_for_F(level, F=+1, M_F=M_F) for M_F in range(-1, +2)] 293 | ) 294 | assert all(inds == np.arange(3) + 5) 295 | 296 | def test_F_negative_ground(self): 297 | """Check we're assigning the right value of F to each state. 298 | 299 | This test looks at the ground level of 133Ba+, which has a negative hyperfine A 300 | coefficient. 301 | """ 302 | level = Ba133.ground_level 303 | factory = Ba133.filter_levels(level_filter=(level,)) 304 | 305 | # Make sure we get the relationships right over a range of fields 306 | for magnetic_field in [1e-4, 100e-4, 1000e-4, 1, 10]: 307 | ion = factory(magnetic_field=magnetic_field) 308 | 309 | # 133Ba+ has I=1/2 so the ground level has F=0 and F=1. A is negative so 310 | # F=1 has higher energy. 311 | 312 | # check that F=0, mF=0 has index 0 313 | self.assertEqual(ion.get_state_for_F(level, F=0, M_F=0), 0) 314 | 315 | # check that states in F=1 have indices 1-3 316 | # states with higher M_F have greater energy 317 | inds = np.array( 318 | [ion.get_state_for_F(level, F=+1, M_F=M_F) for M_F in range(-1, +2)] 319 | ) 320 | assert all(inds == np.arange(3)[::-1] + 1) 321 | 322 | def test_F_D_level(self): 323 | """Check we're assigning the right value of F to each state. 324 | 325 | This test looks at the D level of 137Ba+, which has a positive hyperfine A 326 | coefficient and a negative B coefficient. 327 | """ 328 | level = Ba137.shelf 329 | factory = Ba137.filter_levels(level_filter=(level,)) 330 | 331 | for magnetic_field in [1e-6, 1e-4, 10e-4, 100]: 332 | ion = factory(magnetic_field=1e-9) 333 | 334 | # 137Ba+ has I=3/2 so the ground level F=1, F=2, F=3, F=4. A is negative and 335 | # (just) outweighs B (which is positive) so F=1 has highest energy. 336 | 337 | # check that states in F=1 have indices 0-2 338 | # states with higher M_F have greater energy 339 | inds = np.array( 340 | [ion.get_state_for_F(level, F=+1, M_F=M_F) for M_F in range(-1, +2)] 341 | ) 342 | assert all(inds == np.arange(3)[::-1]) 343 | 344 | # check that states in F=2 have indices 3-7 345 | # states with higher M_F have greater energy 346 | inds = np.array( 347 | [ion.get_state_for_F(level, F=+2, M_F=M_F) for M_F in range(-2, +3)] 348 | ) 349 | assert all(inds == np.arange(5)[::-1] + 3) 350 | 351 | # check that states in F=3 have indices 8-14 352 | # states with higher M_F have greater energy 353 | inds = np.array( 354 | [ion.get_state_for_F(level, F=+3, M_F=M_F) for M_F in range(-3, +4)] 355 | ) 356 | assert all(inds == np.arange(7)[::-1] + 8) 357 | 358 | # check that states in F=4 have indices 15-23 359 | # states with higher M_F have greater energy 360 | inds = np.array( 361 | [ion.get_state_for_F(level, F=+4, M_F=M_F) for M_F in range(-4, +5)] 362 | ) 363 | assert all(inds == np.arange(9)[::-1] + 15) 364 | 365 | def test_F_large_quadrupole(self): 366 | """Check we're assigning the right value of F to each state. 367 | 368 | This test looks at the case where the nuclear quadrupole term dominates the 369 | dipole term. 370 | """ 371 | level = Level(n=1, S=+1 / 2, L=2, J=+5 / 2) 372 | nuclear_spin = +3 / 2 373 | 374 | for Ahfs, Bhfs in ( 375 | (-10e6, +100e6), 376 | (+10e6, -100e6), 377 | (+10e6, 100e6), 378 | (-10e6, -100e6), 379 | ): 380 | level_data = LevelData( 381 | level=level, Ahfs=Ahfs * consts.h, Bhfs=Bhfs * consts.h, g_I=(2 / 3) 382 | ) 383 | factory = AtomFactory( 384 | level_data=(level_data,), transitions={}, nuclear_spin=nuclear_spin 385 | ) 386 | atom = factory(magnetic_field=1e-9) 387 | 388 | # In the low field we should have = F(F+1) 389 | I_dim = int(np.rint(2 * nuclear_spin + 1)) 390 | J_dim = int(np.rint(2 * level.J + 1)) 391 | 392 | J_p = ( 393 | -1 394 | / np.sqrt(2) 395 | * np.kron(AngularMomentumRaisingOp(level.J), np.identity(I_dim)) 396 | ) 397 | J_m = ( 398 | +1 399 | / np.sqrt(2) 400 | * np.kron(AngularMomentumLoweringOp(level.J), np.identity(I_dim)) 401 | ) 402 | J_z = np.kron(AngularMomentumProjectionOp(level.J), np.identity(I_dim)) 403 | 404 | I_p = ( 405 | -1 406 | / np.sqrt(2) 407 | * np.kron( 408 | np.identity(J_dim), AngularMomentumRaisingOp(atom.nuclear_spin) 409 | ) 410 | ) 411 | I_m = ( 412 | +1 413 | / np.sqrt(2) 414 | * np.kron( 415 | np.identity(J_dim), AngularMomentumLoweringOp(atom.nuclear_spin) 416 | ) 417 | ) 418 | I_z = np.kron( 419 | np.identity(J_dim), AngularMomentumProjectionOp(atom.nuclear_spin) 420 | ) 421 | 422 | F_z = I_z + J_z 423 | F_p = I_p + J_p 424 | F_m = I_m + J_m 425 | 426 | F_2_op = F_z @ F_z - (F_p @ F_m + F_m @ F_p) 427 | F_2 = expectation_value(atom.state_vectors, F_2_op) # 428 | F = 0.5 * (np.sqrt(1 + 4 * F_2) - 1) # = f * (f + 1) 429 | 430 | np.testing.assert_allclose(atom.F, F, atol=0.1) 431 | 432 | def test_M_I_M_J(self): 433 | """Check ordering of M_I and M_J by looking at level structure of 137Ba+.""" 434 | ion = Ba137(magnetic_field=10.0) 435 | 436 | # Start by checking states in the ground level against their known ordering 437 | level = Ba137.ground_level 438 | self.assertEqual( 439 | ion.get_state_for_F( 440 | level, 441 | F=2, 442 | M_F=2, 443 | ), 444 | ion.get_state_for_MI_MJ(level, M_I=3 / 2, M_J=1 / 2), 445 | ) 446 | self.assertEqual( 447 | ion.get_state_for_F( 448 | level, 449 | F=2, 450 | M_F=1, 451 | ), 452 | ion.get_state_for_MI_MJ(level, M_I=1 / 2, M_J=1 / 2), 453 | ) 454 | self.assertEqual( 455 | ion.get_state_for_F( 456 | level, 457 | F=2, 458 | M_F=0, 459 | ), 460 | ion.get_state_for_MI_MJ(level, M_I=-1 / 2, M_J=1 / 2), 461 | ) 462 | self.assertEqual( 463 | ion.get_state_for_F( 464 | level, 465 | F=2, 466 | M_F=-1, 467 | ), 468 | ion.get_state_for_MI_MJ(level, M_I=-3 / 2, M_J=1 / 2), 469 | ) 470 | self.assertEqual( 471 | ion.get_state_for_F( 472 | level, 473 | F=2, 474 | M_F=-2, 475 | ), 476 | ion.get_state_for_MI_MJ(level, M_I=-3 / 2, M_J=-1 / 2), 477 | ) 478 | self.assertEqual( 479 | ion.get_state_for_F( 480 | level, 481 | F=1, 482 | M_F=-1, 483 | ), 484 | ion.get_state_for_MI_MJ(level, M_I=-1 / 2, M_J=-1 / 2), 485 | ) 486 | self.assertEqual( 487 | ion.get_state_for_F( 488 | level, 489 | F=1, 490 | M_F=0, 491 | ), 492 | ion.get_state_for_MI_MJ(level, M_I=1 / 2, M_J=-1 / 2), 493 | ) 494 | self.assertEqual( 495 | ion.get_state_for_F( 496 | level, 497 | F=1, 498 | M_F=1, 499 | ), 500 | ion.get_state_for_MI_MJ(level, M_I=3 / 2, M_J=-1 / 2), 501 | ) 502 | 503 | # now check that all values match against the expectation values of the relevant 504 | # operators 505 | for level in ion.level_data.keys(): 506 | level_slice = ion.get_slice_for_level(level) 507 | state_vectors = ion.state_vectors[level_slice, level_slice] 508 | I_dim = int(np.rint(2 * ion.nuclear_spin + 1)) 509 | J_dim = int(np.rint(2 * level.J + 1)) 510 | 511 | J_z = np.kron(AngularMomentumProjectionOp(level.J), np.identity(I_dim)) 512 | I_z = np.kron( 513 | np.identity(J_dim), AngularMomentumProjectionOp(ion.nuclear_spin) 514 | ) 515 | 516 | M_J = expectation_value(state_vectors=state_vectors, operator=J_z) 517 | M_I = expectation_value(state_vectors=state_vectors, operator=I_z) 518 | 519 | np.testing.assert_allclose(M_J, ion.M_J[level_slice], atol=1e-2) 520 | np.testing.assert_allclose(M_I, ion.M_I[level_slice], atol=1e-2) 521 | 522 | def test_get_slice_for_level(self): 523 | """Test for ``get_slice_for_level``.""" 524 | ion = Ca43(1) 525 | 526 | # check that all states are included in at exactly one slice 527 | all_states = np.full(ion.num_states, False, dtype=bool) 528 | for level in ion.level_data.keys(): 529 | level_slice = ion.get_slice_for_level(level) 530 | assert not any(all_states[level_slice]) 531 | all_states[level_slice] = True 532 | assert all(all_states) 533 | 534 | # check that each slice has the correct number of elements 535 | for level in ion.level_data.keys(): 536 | num_states = (2 * ion.nuclear_spin + 1) * (2 * level.J + 1) 537 | level_slice = ion.get_slice_for_level(level) 538 | assert level_slice.stop - level_slice.start == num_states 539 | 540 | def test_get_states_for_level(self): 541 | """Test for ``get_states_for_level``.""" 542 | ion = Ca43(1) 543 | 544 | # check that all states are included in at exactly one slice 545 | all_states = np.full(ion.num_states, False, dtype=bool) 546 | for level in ion.level_data.keys(): 547 | states = ion.get_states_for_level(level) 548 | assert not any(all_states[states]) 549 | all_states[states] = True 550 | assert all(all_states) 551 | 552 | # check that each slice has the correct number of elements 553 | for level in ion.level_data.keys(): 554 | num_states = (2 * ion.nuclear_spin + 1) * (2 * level.J + 1) 555 | states = ion.get_states_for_level(level) 556 | assert len(states) == num_states 557 | assert (states[-1] - states[0]) + 1 == num_states 558 | 559 | def test_get_level_for_state(self): 560 | """Test for ``get_level_for_state``.""" 561 | ion = Ca43(1) 562 | for state in range(ion.num_states): 563 | level = ion.get_level_for_state(state) 564 | level_slice = ion.get_slice_for_level(level) 565 | assert state >= level_slice.start 566 | assert state < level_slice.stop 567 | 568 | def test_get_transition_for_levels(self): 569 | """Test for ``get_transition_for_levels``.""" 570 | ion = Ca43(1) 571 | for transition in ion.transitions.values(): 572 | assert ( 573 | ion.get_transition_for_levels((transition.lower, transition.upper)) 574 | == transition 575 | ) 576 | assert ( 577 | ion.get_transition_for_levels((transition.upper, transition.lower)) 578 | == transition 579 | ) 580 | 581 | def test_get_rabi_rf(self): 582 | """Test for ``get_rabi_rf``.""" 583 | ion = Ca43(magnetic_field=146e-4) 584 | level = Ca43.ground_level 585 | level_slice = ion.get_slice_for_level(level) 586 | mpoles = ion.get_magnetic_dipoles() 587 | for ii in range(level_slice.start, level_slice.stop): 588 | for jj in range(level_slice.start, level_slice.stop): 589 | Omega = ion.get_rabi_rf(lower=jj, upper=ii, amplitude=1) 590 | np.testing.assert_allclose(Omega, mpoles[ii, jj] / consts.hbar) 591 | 592 | def test_get_states_for_M(self): 593 | """Test for ``get_states_for_M``.""" 594 | ion = Ca43(magnetic_field=146e-4) 595 | for level in ion.level_data.keys(): 596 | F_min = np.abs(ion.nuclear_spin - level.J) 597 | F_max = ion.nuclear_spin + level.J 598 | level_F = np.arange(F_min, F_max + 1) 599 | for M in np.arange(-F_max, F_max + 1): 600 | M_states = ion.get_states_for_M(level=level, M=M) 601 | assert len(M_states) == len(set(M_states)) 602 | M_F_states = set( 603 | [ 604 | ion.get_state_for_F(level=level, F=F, M_F=M) 605 | for F in level_F[level_F >= np.abs(M)] 606 | ] 607 | ) 608 | assert set(M_states) == M_F_states 609 | 610 | def test_get_saturation_intensity(self): 611 | """Test for ``get_saturation_intensity``.""" 612 | ion = Ca43(1) 613 | 614 | # saturation intensities for 43Ca+ transitions from [2] 615 | # units are Wm^-2 616 | ref = ( 617 | ("397", 933.82), 618 | ("393", 987.58), 619 | ("866", 89.798), 620 | ("850", 97.954), 621 | ("854", 96.446), 622 | ("729", 9.181e-7), 623 | ) 624 | for transition, I_sat_ref in ref: 625 | I_sat = ion.get_saturation_intensity(transition) 626 | np.testing.assert_allclose(I_sat, I_sat_ref, 1e-2) 627 | 628 | def test_intensity_to_power(self): 629 | """Test for ``intensity_to_power``.""" 630 | ion = Ca43(1) 631 | waist = 10e-6 632 | power = ion.intensity_to_power( 633 | transition="397", 634 | waist_radius=waist, 635 | intensity=1, 636 | ) 637 | # Isat = 933.82 [2] 638 | np.testing.assert_allclose(power, 1 / 2 * np.pi * 933.82 * waist**2, rtol=1e-5) 639 | --------------------------------------------------------------------------------