chemistrykit.spectro#
chemistrykit.spectro: spectroscopy.
The Beer-Lambert absorbance law and one well-characterized instrumental
deviation from its linearity (stray light); rigid-rotor rotational
spectra with \(\Delta J=\pm1\) selection rules and isotope shifts,
built on chemistrykit.quantum.systems.rigid_rotor; harmonic vs.
Morse (anharmonic) vibrational band positions, built on
chemistrykit.quantum.systems.harmonic_oscillator, plus a genuine
triatomic normal-mode calculation via the Wilson GF-matrix method;
Franck-Condon vibronic progressions for electronic (UV-Vis) spectra; a
first-order NMR multiplet simulator (chemical shifts and J-coupling
splitting patterns), Larmor frequencies, the Karplus relation, and
Fourier-transform NMR (free-induction decay to spectrum); the
Balmer-Rydberg formula for hydrogen-like atomic lines; and Lorentzian/Gaussian/Voigt lineshape utilities
shared across all of the above.
- class chemistrykit.spectro.NormalModeResult(wavenumbers, internal_coordinate_vectors, is_linear)[source]#
Bases:
objectThe output of
TriatomicNormalModes.solve().- internal_coordinate_vectors: ndarray#
Column k gives mode k’s displacement pattern in the (r1, r2, bend) internal-coordinate basis (see
TriatomicNormalModes), useful for classifying a mode as (anti)symmetric stretch or bend by inspection of the relative signs and magnitudes of its r1/r2 components.- Type:
ndarray, shape (3, 3)
- is_linear: bool#
Whether the equilibrium geometry is linear (in which case the computed bend mode is one member of a doubly degenerate pair – see
TriatomicNormalModes’s docstring).- Type:
- class chemistrykit.spectro.Spectrum(positions, intensities, labels=<factory>, extra=<factory>)[source]#
Bases:
objectA discrete “stick” spectrum: peak positions and relative intensities.
- Parameters:
positions (
ndarray) – Peak positions (e.g. wavenumber in cm^-1, or chemical shift in ppm – the unit is model-specific and documented by whichever function constructs the Spectrum).intensities (
ndarray) – Relative intensity (peak area) of each position, same order as positions.labels (
list) – Human-readable label for each peak (e.g."J=0->1").extra (
dict) – Free-form slot for additional diagnostics a model chooses to attach.
Examples
>>> import numpy as np >>> spectrum = Spectrum(positions=np.array([100.0, 200.0]), intensities=np.array([1.0, 0.5])) >>> x = np.linspace(0, 300, 3001) >>> broadened = spectrum.broaden(x, shape="gaussian", fwhm=5.0) >>> round(float(np.trapezoid(broadened, x)), 2) 1.5
- broaden(x, shape='gaussian', fwhm=1.0, fwhm_lorentzian=None)[source]#
Broaden every stick into a continuous spectrum via
chemistrykit.spectro.utils.lineshapes.broaden_stick_spectrum().
- class chemistrykit.spectro.TriatomicNormalModes(masses, equilibrium_coordinates, k_r1, k_r2, k_theta)[source]#
Bases:
objectNormal-mode vibrational frequencies of an A-B-A triatomic, via the Wilson GF-matrix method.
A genuine (not tabulated) normal-mode calculation: an internal- coordinate force field is combined with the molecule’s actual equilibrium geometry and atomic masses through Wilson’s G (kinetic energy) and F (force constant) matrices, and the resulting generalized eigenvalue problem is solved for the true normal-mode frequencies (Wilson, Decius & Cross, Molecular Vibrations, 1955, Ch. 4). Three internal coordinates are used: the two bond stretches \(r_1\), \(r_2\), and the bond angle (or, for a linear equilibrium geometry, a linearized bend coordinate – see below).
The `G` matrix (mass-weighted, purely geometric) is built here numerically: the Wilson B-matrix, \(B_{ki}=\partial S_k/\partial x_i\) (internal coordinate k with respect to Cartesian displacement i), is obtained by central finite differences of the internal-coordinate functions, then \(G=BM^{-1}B^T\) with M the diagonal atomic-mass matrix – an exact numerical evaluation of the standard analytic B-matrix formulas, not an independent approximation.
The `F` matrix is a simple (diagonal) valence force field: \(F=\mathrm{diag}(k_{r_1},k_{r_2},k_\theta)\), with no stretch-stretch or stretch-bend interaction constants. Flagged as an approximation: a full experimental force field for a real molecule (e.g. CO2, H2O) generally does include small off-diagonal interaction terms, fit to reproduce observed frequencies to high precision; the diagonal approximation used here still gives the correct qualitative mode pattern (symmetric stretch, antisymmetric stretch, bend) and the right order of magnitude for reasonable literature-typical k_r/k_theta, but not spectroscopic-accuracy absolute wavenumbers.
Linear equilibrium geometries (is_linear=True) need a bend coordinate that stays well-defined exactly at 180 degrees, where the ordinary \(\theta=\arccos(\dots)\) bond-angle formula’s derivative diverges. The standard fix (Wilson, Decius & Cross, Ch. 4) is used: the bend coordinate is instead the linearized transverse (in-plane) displacement combination \(S_{\text{bend}}=(x_0-2x_1+x_2)/r_{eq}\), well-defined at exactly linear. A linear triatomic’s bend is genuinely doubly degenerate (in-plane and out-of-plane bending are equivalent by the molecule’s cylindrical symmetry); this module solves only the in-plane 3-internal-coordinate problem, so the reported bend wavenumber should be understood as representing both degenerate components (Herzberg, Molecular Spectra and Molecular Structure II, 1945, Ch. I.3).
The resulting symmetric generalized eigenvalue problem, GFL=L\Lambda, is solved here as the equivalent symmetric form \(Fc=\lambda G^{-1}c\) via
chemistrykit.quantum.utils.secular_equation.solve_secular_equation()(with F playing the role of the Hamiltonian and \(G^{-1}\) the role of the overlap matrix) – reusing the same shared-eigenvalue- solve machinerychemistrykit.quantum’s variational models use, since both are literally the same kind of generalized symmetric eigenproblem, \(\lambda=\omega^2\) in SI units, converted to a wavenumber via \(\tilde\nu=\omega/(2\pi c)\).- Parameters:
masses (array-like of float, shape (3,)) – Atomic masses (atom0, central atom1, atom2), in kg.
equilibrium_coordinates (array-like of float, shape (3, 3)) – Cartesian equilibrium coordinates, in meters.
k_r1 (
float) – Stretch force constants for bonds (atom0-atom1) and (atom2-atom1), in N/m.k_r2 (
float) – Stretch force constants for bonds (atom0-atom1) and (atom2-atom1), in N/m.k_theta (
float) – Bend force constant, in J/rad^2 (equivalently N m/rad^2).
Examples
See
linear()andbent()for the usual way to construct one;solve()returns three positive wavenumbers for a stable equilibrium geometry.- classmethod bent(mass_terminal, mass_central, bond_length, bond_angle_degrees, k_r, k_theta)[source]#
Build a symmetric bent A-B-A triatomic (e.g. H2O-like) in the xz plane.
- Parameters:
mass_terminal (
float) – Mass of each terminal atom (A), in kg.mass_central (
float) – Mass of the central atom (B), in kg.bond_length (
float) – Equilibrium A-B bond length, in meters.bond_angle_degrees (
float) – Equilibrium A-B-A bond angle, in degrees (< 180).k_r (
float) – Shared stretch force constant for both bonds, in N/m.k_theta (
float) – Bend force constant, in J/rad^2.
- Return type:
- Returns:
TriatomicNormalModes
- classmethod linear(mass_terminal, mass_central, bond_length, k_r, k_theta)[source]#
Build a symmetric linear A-B-A triatomic (e.g. CO2-like) along the z axis.
- Parameters:
mass_terminal (
float) – Mass of each terminal atom (A), in kg.mass_central (
float) – Mass of the central atom (B), in kg.bond_length (
float) – Equilibrium A-B bond length, in meters.k_r (
float) – Shared stretch force constant for both bonds, in N/m.k_theta (
float) – Bend force constant, in J/rad^2.
- Return type:
- Returns:
TriatomicNormalModes
- solve()[source]#
Diagonalize the GF-matrix eigenproblem and return the normal-mode wavenumbers.
- Return type:
- Returns:
NormalModeResult
Examples
A bent triatomic (H2O-like) has 3 real, positive vibrational wavenumbers, with the bend lower in energy than either stretch – the correct qualitative ordering for a real bent AX2 molecule:
>>> import scipy.constants as sc >>> water = TriatomicNormalModes.bent( ... mass_terminal=1.008 * sc.atomic_mass, ... mass_central=15.999 * sc.atomic_mass, ... bond_length=95.8e-12, ... bond_angle_degrees=104.5, ... k_r=770.0, ... k_theta=0.7e-18, ... ) >>> result = water.solve() >>> bool(np.all(result.wavenumbers > 0)) True >>> bool(result.wavenumbers[0] < result.wavenumbers[1] < result.wavenumbers[2]) True
- chemistrykit.spectro.absorbance(molar_absorptivity, concentration, path_length)[source]#
The Beer-Lambert law: \(A=\varepsilon c l\).
Linear in concentration by construction – the exact relationship assumed to hold for a dilute, non-interacting, monochromatic-light measurement (Skoog, Holler & Crouch, Principles of Instrumental Analysis, 7th ed., Ch. 13.2). See
apparent_absorbance_with_stray_light()for one well-known real instrumental effect that breaks this linearity at high absorbance.- Parameters:
- Returns:
float or ndarray – Absorbance (dimensionless).
Examples
>>> round(absorbance(molar_absorptivity=5000.0, concentration=2.0e-5, path_length=1.0), 4) 0.1 >>> absorbance(5000.0, 4.0e-5, 1.0) == 2.0 * absorbance(5000.0, 2.0e-5, 1.0) True
- chemistrykit.spectro.anharmonicity_from_overtones(wavenumber_01, wavenumber_02)[source]#
Recover the Morse parameters \(\omega_e\), \(\omega_ex_e\) from two measured band positions.
The Morse-oscillator term-value formula gives the fundamental and first-overtone wavenumbers as
\[\tilde\nu_{0\to1} = \omega_e - 2\omega_ex_e, \qquad \tilde\nu_{0\to2} = 2\omega_e - 6\omega_ex_e\](Atkins & de Paula, Physical Chemistry, 11th ed., Ch. 12.4; Herzberg, Molecular Spectra and Molecular Structure I, 2nd ed., Ch. III), a linear system solved here exactly (no fitting) for the two spectroscopic constants \(\omega_e\) (the harmonic wavenumber) and \(\omega_ex_e\) (the anharmonicity constant) from two observed transitions – the standard way real vibrational anharmonicity constants are extracted from an observed overtone spectrum.
- Parameters:
- Return type:
- Returns:
omega_e (float) – Harmonic wavenumber, in cm^-1.
omega_e_xe (float) – Anharmonicity constant, in cm^-1.
Examples
Round-trip against a
MorseOscillatorbuilt from known omega_e/omega_e_xe-equivalent parameters recovers them from its own predicted band positions:>>> morse = MorseOscillator(mass=1.6e-27, force_constant=500.0, dissociation_energy=7.0e-19) >>> nu01, nu02 = morse_transition_wavenumbers(morse, v_max=2) >>> omega_e, omega_e_xe = anharmonicity_from_overtones(nu01, nu02) >>> bool(omega_e > nu01 > 0) # the harmonic wavenumber exceeds the (red-shifted) observed fundamental True >>> bool(omega_e_xe > 0) True
- chemistrykit.spectro.apparent_absorbance_with_stray_light(molar_absorptivity, concentration, path_length, stray_light_fraction)[source]#
The apparent absorbance measured in the presence of instrumental stray light.
Approximation flagged explicitly: real Beer-Lambert-law deviations at high concentration have several distinct physical origins (a concentration-dependent chemical equilibrium shifting the true absorbing species’ concentration, use of polychromatic rather than strictly monochromatic radiation, and instrumental stray light); this function models only the last of those, one specific, well-characterized, purely instrumental effect (Skoog, Holler & Crouch, Principles of Instrumental Analysis, 7th ed., Ch. 13.4).
A small fraction \(p_s\) of the light reaching the detector is “stray” radiation that never actually passed through the fully absorbing sample path (e.g. light scattered around the sample cell), so it is not attenuated by the sample the way the main beam is. The detector reads the combined transmitted-plus-stray intensity, so the apparent absorbance is
\[A_{\text{apparent}} = -\log_{10}\!\left(10^{-A_{\text{true}}} + p_s\right)\](normalized so that at stray_light_fraction=0, or in the dilute limit \(A_{\text{true}}\to0\), this reduces exactly to the true Beer-Lambert absorbance). Because the stray-light term does not shrink as the sample absorbs more strongly, it dominates the detector reading at high true absorbance, causing the classic negative (sublinear, “rolling over”) deviation from Beer-Lambert linearity seen in real instruments at high concentration.
- Parameters:
- Return type:
- Returns:
float – Apparent absorbance, always \(\le A_{\text{true}}\).
- Raises:
ValueError – If stray_light_fraction is not in \([0, 1)\).
Examples
With no stray light, the apparent absorbance is exactly the true one:
>>> round(apparent_absorbance_with_stray_light(5000.0, 2.0e-5, 1.0, stray_light_fraction=0.0), 6) 0.1
At low concentration (low true absorbance) the deviation is negligible, but it grows sharply at high concentration – Beer- Lambert linearity breaks down exactly where the textbook warns it does:
>>> low = apparent_absorbance_with_stray_light(5000.0, 1.0e-6, 1.0, stray_light_fraction=0.001) >>> low_true = absorbance(5000.0, 1.0e-6, 1.0) >>> high = apparent_absorbance_with_stray_light(5000.0, 1.0e-3, 1.0, stray_light_fraction=0.001) >>> high_true = absorbance(5000.0, 1.0e-3, 1.0) >>> bool(abs(low - low_true) < abs(high - high_true)) True
- chemistrykit.spectro.broaden_stick_spectrum(centers, intensities, x, shape='gaussian', fwhm=1.0, fwhm_lorentzian=None)[source]#
Sum a lineshape over every (center, intensity) stick to build a continuous spectrum.
- Parameters:
centers (array-like of float) – Peak positions.
intensities (array-like of float) – Relative intensity (area) of each peak, same length as centers.
x (array-like of float) – Grid to evaluate the broadened spectrum on.
shape (
str) – Lineshape to use for every peak.fwhm (
float) – FWHM (or the Gaussian-component FWHM, for shape=”voigt”).fwhm_lorentzian (float, optional) – Lorentzian-component FWHM, required (and only used) for shape=”voigt”.
- Returns:
ndarray, shape matching x
- Raises:
ValueError – For an unrecognized shape, or a missing fwhm_lorentzian when shape=”voigt”.
Examples
A single stick recovers the plain lineshape function, scaled by its intensity:
>>> import numpy as np >>> x = np.linspace(-10, 10, 501) >>> spectrum = broaden_stick_spectrum([0.0], [2.0], x, shape="gaussian", fwhm=1.0) >>> bool(np.allclose(spectrum, 2.0 * gaussian(x, 0.0, 1.0))) True
- chemistrykit.spectro.chemical_shift_ppm(frequency_hz, reference_frequency_hz)[source]#
Chemical shift \(\delta=10^6(\nu-\nu_\text{ref})/\nu_\text{ref}\), in ppm.
Dividing by the reference frequency removes the (field-proportional) Larmor frequency, so \(\delta\) is the same on every spectrometer – the reason NMR spectra are reported in ppm.
- Parameters:
- Returns:
float or ndarray
Examples
A proton resonating 2800 Hz above TMS on a 400 MHz instrument sits at 7.0 ppm:
>>> round(chemical_shift_ppm(400.0e6 + 2800.0, 400.0e6), 6) 7.0
- chemistrykit.spectro.concentration_from_absorbance(absorbance_value, molar_absorptivity, path_length)[source]#
Invert the Beer-Lambert law for concentration: \(c=A/(\varepsilon l)\).
- Parameters:
- Returns:
float or ndarray – Concentration, in mol/L.
Examples
>>> c = concentration_from_absorbance(absorbance_value=0.1, molar_absorptivity=5000.0, path_length=1.0) >>> round(c, 8) 2e-05
- chemistrykit.spectro.energy_to_wavenumber(energy_j)[source]#
Convert an energy (in J) to a spectroscopic wavenumber \(\tilde\nu=E/(hc)\), in cm^-1.
Examples
>>> round(float(energy_to_wavenumber(H * C * 100.0)), 6) 1.0
- chemistrykit.spectro.fid_to_spectrum(fid, dwell_time)[source]#
Fourier-transform an FID into a frequency-domain (absorption-mode) spectrum.
Uses the discrete Fourier transform with the first point halved (the standard correction that removes a constant baseline offset) and returns the real (absorption) part, frequency axis centered on zero. A line of transverse relaxation time \(T_2\) becomes a Lorentzian of FWHM \(1/(\pi T_2)\) Hz.
- Parameters:
- Return type:
- Returns:
frequencies_hz (ndarray) – Ascending frequency axis, in Hz.
spectrum (ndarray) – Real (absorption-mode) spectrum, in units of FID amplitude times seconds.
Examples
One resonance at +50 Hz Fourier-transforms into a single peak at +50 Hz:
>>> t = np.arange(4096) * 1e-3 >>> freqs, spec = fid_to_spectrum(free_induction_decay(t, [50.0], [1.0], t2=0.2), 1e-3) >>> round(float(freqs[np.argmax(spec)]), 1) 50.0
- chemistrykit.spectro.first_order_multiplet(chemical_shift_ppm, j_coupling_hz, n_neighbors, spectrometer_frequency_mhz)[source]#
Simulate a simple multiplet from coupling to one set of n equivalent neighbors.
Line positions are evenly spaced by \(J/\nu_0\) in ppm (J in Hz converted to ppm by dividing by the spectrometer frequency in MHz, since 1 ppm on the chemical-shift scale corresponds to \(\nu_0\,[\text{Hz}]/10^6\) at operating frequency \(\nu_0\)), symmetric about chemical_shift_ppm, with the Pascal’s-triangle relative intensities from
pascals_triangle_intensities().- Parameters:
chemical_shift_ppm (
float) – Unperturbed chemical shift, in ppm.j_coupling_hz (
float) – Coupling constant J, in Hz.n_neighbors (
int) – Number of equivalent coupled neighbors.spectrometer_frequency_mhz (
float) – Spectrometer (proton, or whichever nucleus) operating frequency, in MHz – needed to convert J from Hz to the ppm scale.
- Return type:
- Returns:
Spectrum – positions in ppm, intensities the relative (unnormalized) binomial line intensities.
Examples
A triplet, symmetric about the chemical shift, spaced by J:
>>> spectrum = first_order_multiplet(chemical_shift_ppm=1.2, j_coupling_hz=7.0, n_neighbors=2, spectrometer_frequency_mhz=400.0) >>> len(spectrum.positions) 3 >>> round(float(np.mean(spectrum.positions)), 6) == round(1.2, 6) True >>> spectrum.intensities.tolist() [1.0, 2.0, 1.0]
- chemistrykit.spectro.franck_condon_factor(v, S)[source]#
The \(0\to v\) Franck-Condon factor for two displaced, equal-frequency harmonic oscillators.
\[\text{FC}(0\to v) = \frac{e^{-S}S^v}{v!}\](a Poisson distribution in v with mean S; see the module docstring for the underlying approximation).
- Parameters:
- Return type:
- Returns:
float – In \([0, 1]\).
Examples
Zero displacement (S=0) puts all intensity in the 0->0 transition (no vibronic structure at all):
>>> round(franck_condon_factor(0, S=0.0), 6) 1.0 >>> round(franck_condon_factor(1, S=0.0), 6) 0.0
- chemistrykit.spectro.franck_condon_progression(v_max, S)[source]#
Tabulate the Franck-Condon factors for \(v=0,1,\dots,v_{max}\).
- Parameters:
- Return type:
- Returns:
ndarray, shape (v_max + 1,)
Examples
The Franck-Condon factors are (approximately) a complete probability distribution over the final vibrational level, so they sum to (approximately) 1 once v_max comfortably exceeds the Poisson distribution’s mean S – the required normalization check for any genuine Franck-Condon calculation:
>>> round(float(np.sum(franck_condon_progression(v_max=40, S=3.0))), 9) 1.0
The most probable (highest-intensity) vibronic transition lands near \(v'\approx S\) – the qualitative rule of thumb for reading off a vibronic progression’s displacement from its band shape:
>>> progression = franck_condon_progression(v_max=20, S=5.0) >>> bool(abs(int(np.argmax(progression)) - 5) <= 1) True
- chemistrykit.spectro.franck_condon_spectrum(origin_wavenumber, vibrational_wavenumber, S, v_max)[source]#
Build a vibronic-progression
Spectrum.Stick positions are the electronic origin (the \(0\to0\) transition) plus v’ quanta of the excited-state vibrational wavenumber; stick intensities are the corresponding Franck-Condon factors (Atkins & de Paula, Physical Chemistry, 11th ed., Ch. 12.7(b)).
- Parameters:
- Return type:
- Returns:
Spectrum – positions in cm^-1, intensities the Franck-Condon factors.
Examples
>>> spectrum = franck_condon_spectrum(origin_wavenumber=25000.0, vibrational_wavenumber=1500.0, S=1.5, v_max=10) >>> round(float(spectrum.positions[0]), 1) 25000.0 >>> round(float(spectrum.positions[2] - spectrum.positions[1]), 1) 1500.0
- chemistrykit.spectro.free_induction_decay(t, offsets_hz, amplitudes, t2)[source]#
Complex free-induction decay (FID) after a single 90-degree pulse.
Each resonance, at offset \(\Delta\nu_k\) from the rotating-frame reference, contributes a decaying complex exponential,
\[s(t)=\sum_k a_k\,e^{2\pi i\Delta\nu_k t}\,e^{-t/T_2},\]the time-domain signal whose Fourier transform is the spectrum (R. R. Ernst & W. A. Anderson, Rev. Sci. Instrum. 37, 93 (1966)).
- Parameters:
- Return type:
- Returns:
ndarray of complex, shape matching t
Examples
At \(t=0\) every component is in phase, so the signal is the sum of the amplitudes:
>>> fid = free_induction_decay([0.0, 0.1], offsets_hz=[10.0, -25.0], amplitudes=[1.0, 2.0], t2=0.5) >>> complex(fid[0]) (3+0j)
- chemistrykit.spectro.gaussian(x, x0, fwhm)[source]#
A normalized Gaussian lineshape.
\[g(x) = \frac{2}{\text{FWHM}}\sqrt{\frac{\ln 2}{\pi}} \exp\!\left[-4\ln 2\left(\frac{x-x_0}{\text{FWHM}}\right)^2\right]\]Normalized so \(\int_{-\infty}^{\infty}g(x)\,dx=1\) (Hollas, Modern Spectroscopy, 4th ed., Ch. 2.3).
- Parameters:
- Returns:
float or ndarray
Examples
The peak height at x0 and the normalization both follow the closed-form prefactor:
>>> import numpy as np >>> expected_peak = 2.0 / 1.0 * np.sqrt(np.log(2) / np.pi) >>> round(float(gaussian(0.0, 0.0, fwhm=1.0)), 6) == round(float(expected_peak), 6) True >>> x = np.linspace(-20, 20, 200001) >>> round(float(np.trapezoid(gaussian(x, 0.0, fwhm=2.0), x)), 3) 1.0
- chemistrykit.spectro.harmonic_fundamental_wavenumber(force_constant, reduced_mass)[source]#
The harmonic-oscillator fundamental (\(v=0\to1\)) IR band position.
\[\tilde\nu = \frac{1}{2\pi c}\sqrt{\frac{k}{\mu}}\]Every harmonic-oscillator transition sits at exactly this same wavenumber (evenly spaced levels), unlike the Morse oscillator’s overtones (Atkins & de Paula, Physical Chemistry, 11th ed., Ch. 12.4).
- Parameters:
- Return type:
- Returns:
float – Wavenumber, in cm^-1.
Examples
>>> import scipy.constants as sc >>> mu = (1.008 * 34.97) / (1.008 + 34.97) * sc.atomic_mass # HCl-like reduced mass >>> nu = harmonic_fundamental_wavenumber(force_constant=480.0, reduced_mass=mu) >>> bool(2500.0 < nu < 3200.0) # HCl's real fundamental is 2886 cm^-1 True
- chemistrykit.spectro.huang_rhys_factor(displacement, mass, angular_frequency)[source]#
The dimensionless Huang-Rhys displacement parameter \(S=\frac12\frac{m\omega}{\hbar}\Delta Q^2\).
- Parameters:
displacement (
float) – Excited-minus-ground-state equilibrium displacement along the normal coordinate, \(\Delta Q\), in m (for a mass-weighted coordinate) or the natural length unit of the coordinate used.mass (
float) – Oscillator (reduced) mass, in kg.angular_frequency (
float) – Shared vibrational angular frequency \(\omega\), in rad/s.
- Return type:
- Returns:
float
Examples
>>> round(huang_rhys_factor(displacement=0.0, mass=1.6e-27, angular_frequency=5.0e13), 6) 0.0
- chemistrykit.spectro.isotope_shift_ratio(mass1a, mass2, mass1b)[source]#
Ratio of rotational constants (and transition wavenumbers) between two isotopologues.
Substituting atom 1 (e.g. H) for a heavier isotope (e.g. D) changes only the reduced mass \(\mu=m_1m_2/(m_1+m_2)\), and the rotational constant \(B\propto1/I=1/(\mu r^2)\) scales inversely with it (bond length r is, to a very good approximation, unchanged by isotopic substitution, since it depends on the electronic structure, not the nuclear mass – the Born-Oppenheimer approximation). So
\[\frac{B(\text{isotopologue }b)}{B(\text{isotopologue }a)} = \frac{\mu_a}{\mu_b}\](Atkins & de Paula, Physical Chemistry, 11th ed., Ch. 12.3). Every rotational transition wavenumber scales by this same ratio, since they are all proportional to B.
- Parameters:
- Return type:
- Returns:
float – \(B_b/B_a\) (and \(\tilde\nu_b/\tilde\nu_a\)), < 1 since substituting a heavier isotope increases the reduced mass and moment of inertia, lowering B.
Examples
Substituting D for H in HCl roughly halves the rotational constant (deuterium is about twice as heavy as hydrogen, and Cl is much heavier than either, so the reduced mass roughly doubles):
>>> import scipy.constants as sc >>> ratio = isotope_shift_ratio(mass1a=1.008 * sc.atomic_mass, mass2=34.97 * sc.atomic_mass, mass1b=2.014 * sc.atomic_mass) >>> bool(0.45 < ratio < 0.55) True
Directly matches a from-scratch
RigidRotorcomparison:>>> r = 127.5e-12 >>> rotor_hcl = RigidRotor.from_diatomic(1.008 * sc.atomic_mass, 34.97 * sc.atomic_mass, r) >>> rotor_dcl = RigidRotor.from_diatomic(2.014 * sc.atomic_mass, 34.97 * sc.atomic_mass, r) >>> ratio_direct = rotor_dcl.rotational_constant / rotor_hcl.rotational_constant >>> bool(abs(ratio_direct - ratio) < 1e-9) True
- chemistrykit.spectro.karplus_coupling(dihedral_degrees, coefficients=None)[source]#
Vicinal H-C-C-H coupling constant \(^3J\) from the dihedral angle: the Karplus relation.
With
coefficients=Nonethis is Karplus’s original 1959 valence-bond result (M. Karplus, J. Chem. Phys. 30, 11 (1959)),\[\begin{split}^3J(\phi) = \begin{cases} 8.5\cos^2\phi - 0.28, & 0^\circ\le\phi\le90^\circ \\ 9.5\cos^2\phi - 0.28, & 90^\circ<\phi\le180^\circ \end{cases}\ \text{Hz};\end{split}\]otherwise the general three-term form \(^3J=A\cos^2\phi+B\cos\phi+C\) (M. Karplus, J. Am. Chem. Soc. 85, 2870 (1963)) with
coefficients=(A, B, C)in Hz.- Parameters:
- Returns:
float or ndarray – \(^3J\), in Hz.
Examples
Anti (180 degrees) protons couple strongly, gauche (60 degrees) weakly, and perpendicular (90 degrees) protons hardly at all:
>>> round(karplus_coupling(180.0), 2), round(karplus_coupling(60.0), 3), round(karplus_coupling(90.0), 2) (9.22, 1.845, -0.28)
- chemistrykit.spectro.larmor_frequency(gyromagnetic_ratio, field_tesla)[source]#
Nuclear Larmor (resonance) frequency \(\nu_0=\gamma B_0/(2\pi)\), in Hz.
The frequency at which Bloch’s and Purcell’s 1946 experiments found a sharp resonant response from nuclear spins in a static field \(B_0\) (F. Bloch, Phys. Rev. 70, 460 (1946); Atkins & de Paula, Physical Chemistry, 11th ed., Ch. 12A).
- Parameters:
- Returns:
float or ndarray – Resonance frequency, in Hz.
Examples
A “400 MHz” spectrometer’s magnet is about 9.4 T:
>>> import scipy.constants as sc >>> gamma_h = sc.physical_constants["proton gyromag. ratio"][0] >>> round(larmor_frequency(gamma_h, 9.3947) / 1e6, 1) 400.0
- chemistrykit.spectro.lorentzian(x, x0, fwhm)[source]#
A normalized Lorentzian lineshape.
\[L(x) = \frac{1}{\pi}\frac{\text{FWHM}/2}{(x-x_0)^2+(\text{FWHM}/2)^2}\]Normalized so \(\int_{-\infty}^{\infty}L(x)\,dx=1\) (Hollas, Modern Spectroscopy, 4th ed., Ch. 2.3).
- Parameters:
- Returns:
float or ndarray
Examples
>>> import numpy as np >>> x = np.linspace(-2000, 2000, 4_000_001) >>> round(float(np.trapezoid(lorentzian(x, 0.0, fwhm=2.0), x)), 3) 1.0
By definition, the value has dropped to half its peak at \(x_0\pm\text{FWHM}/2\):
>>> peak = lorentzian(0.0, 0.0, fwhm=2.0) >>> half_max_point = lorentzian(1.0, 0.0, fwhm=2.0) >>> round(float(half_max_point), 9) == round(float(peak) / 2.0, 9) True
- chemistrykit.spectro.morse_transition_wavenumbers(morse, v_max)[source]#
Wavenumbers of the \(0\to v\) Morse-oscillator “overtone” transitions, \(v=1,\dots,v_{max}\).
Unlike the harmonic oscillator, successive Morse transitions (fundamental \(0\to1\), first overtone \(0\to2\), …) are not simple multiples of each other – the level spacing shrinks with v (Atkins & de Paula, Physical Chemistry, 11th ed., Ch. 12.4).
- Parameters:
morse (
MorseOscillator)v_max (
int) – Highest final vibrational level to include.
- Return type:
- Returns:
ndarray, shape (v_max,) – Wavenumbers of the \(0\to1,0\to2,\dots,0\to v_{max}\) transitions, in cm^-1.
Examples
The first overtone (\(0\to2\)) falls short of exactly twice the fundamental (\(0\to1\)) – the textbook anharmonicity signature:
>>> morse = MorseOscillator(mass=1.6e-27, force_constant=500.0, dissociation_energy=7.0e-19) >>> wavenumbers = morse_transition_wavenumbers(morse, v_max=2) >>> bool(wavenumbers[1] < 2.0 * wavenumbers[0]) True
- chemistrykit.spectro.multi_coupling_multiplet(chemical_shift_ppm, couplings, spectrometer_frequency_mhz)[source]#
Simulate a multiplet from independent first-order coupling to several inequivalent neighbor sets.
Each coupling constant splits every line already present (the couplings act independently and multiplicatively), giving \(\prod_k(n_k+1)\) lines in the general case (fewer if some coincide) – e.g. a doublet of triplets (dt) from one neighbor with \(J_1\) and two equivalent neighbors with \(J_2\ne J_1\).
- Parameters:
- Return type:
- Returns:
Spectrum – positions in ppm (sorted ascending), intensities the combined relative intensities (coincident positions within numerical tolerance are merged, summing their intensities).
Examples
A doublet of doublets (dd) with equal J’s degenerates into a triplet with 1:2:1 intensities (two different coupling partners with the same J are indistinguishable from one set of two equivalent partners):
>>> spectrum = multi_coupling_multiplet(chemical_shift_ppm=5.0, couplings=[(7.0, 1), (7.0, 1)], spectrometer_frequency_mhz=400.0) >>> len(spectrum.positions) 3 >>> spectrum.intensities.tolist() [1.0, 2.0, 1.0]
A genuine doublet of triplets (\(J_1=12\) Hz to one neighbor, \(J_2=5\) Hz to two equivalent neighbors) has 6 distinct lines:
>>> dt = multi_coupling_multiplet(chemical_shift_ppm=3.0, couplings=[(12.0, 1), (5.0, 2)], spectrometer_frequency_mhz=400.0) >>> len(dt.positions) 6 >>> round(float(np.sum(dt.intensities)), 6) 8.0
- chemistrykit.spectro.multiplicity(n_equivalent_neighbors)[source]#
The n+1 rule: n equivalent spin-1/2 neighbors split a resonance into n+1 lines.
- Parameters:
n_equivalent_neighbors (
int) – Number of magnetically equivalent, spin-1/2, first-order-coupled neighboring nuclei.- Return type:
- Returns:
int
Examples
Ethanol’s -CH2- protons, coupled to the 3 equivalent -CH3- protons, form a quartet:
>>> multiplicity(3) 4
- chemistrykit.spectro.pascals_triangle_intensities(n_equivalent_neighbors)[source]#
Relative line intensities for n equivalent spin-1/2 neighbors: the binomial coefficients \(\binom{n}{k}\).
- Parameters:
n_equivalent_neighbors (
int)- Return type:
- Returns:
ndarray, shape (n_equivalent_neighbors + 1,)
Examples
A quartet’s classic 1:3:3:1 intensity pattern (e.g. ethanol’s CH2, coupled to 3 equivalent CH3 protons):
>>> pascals_triangle_intensities(3) array([1., 3., 3., 1.])
- chemistrykit.spectro.rotational_line_wavenumbers(rotor, J_max)[source]#
Wavenumbers of the \(J\to J+1\) rotational absorption lines, \(J=0,\dots,J_{max}\).
Each line sits at \(\tilde\nu_J=2B(J+1)\) (in wavenumber units), evenly spaced by \(2B\) – the defining signature of a rigid- rotor rotational spectrum (Atkins & de Paula, Physical Chemistry, 11th ed., Ch. 12.2).
- Parameters:
rotor (
RigidRotor)J_max (
int) – Highest initial J to include.
- Return type:
- Returns:
ndarray, shape (J_max + 1,) – Wavenumbers, in cm^-1.
Examples
>>> import scipy.constants as sc >>> rotor = RigidRotor.from_diatomic(mass1=1.008 * sc.atomic_mass, mass2=34.97 * sc.atomic_mass, bond_length=127.5e-12) >>> lines = rotational_line_wavenumbers(rotor, J_max=4) >>> spacings = np.diff(lines) >>> bool(np.allclose(spacings, spacings[0])) True
- chemistrykit.spectro.rotational_spectrum(rotor, J_max, temperature)[source]#
Build a rotational-spectrum
Spectrum, with relative intensities.Approximation flagged explicitly: the relative intensity of the \(J\to J+1\) line is modeled here as proportional to the thermal (Boltzmann) population of the initial level J, weighted by its \((2J+1)\)-fold degeneracy, times a \((J+1)\) line-strength factor coming from the \(\Delta J=\pm1\) transition-dipole matrix element (Atkins & de Paula, Physical Chemistry, 11th ed., Ch. 12.2(b)):
\[I_J \propto (J+1)(2J+1)\exp\!\left[-\frac{E_J}{k_BT}\right]\]This reproduces the qualitative textbook feature of a rotational spectrum – intensity rising from J=0, peaking at some intermediate J, then falling off – but is not a first-principles transition- dipole calculation for any particular real molecule.
- Parameters:
rotor (
RigidRotor)J_max (
int) – Highest initial J to include.temperature (
float) – Temperature, in K, for the Boltzmann population weighting.
- Return type:
- Returns:
Spectrum – positions in cm^-1; intensities relative (not normalized to any absolute scale).
Examples
At a low enough temperature, only the lowest few J are significantly populated, so the peak intensity line has a small J:
>>> import scipy.constants as sc >>> rotor = RigidRotor.from_diatomic(mass1=1.008 * sc.atomic_mass, mass2=34.97 * sc.atomic_mass, bond_length=127.5e-12) >>> spectrum = rotational_spectrum(rotor, J_max=15, temperature=300.0) >>> peak_J = int(np.argmax(spectrum.intensities)) >>> bool(0 < peak_J < 15) True
- chemistrykit.spectro.rydberg_wavenumber(n_lower, n_upper, nuclear_charge=1, nuclear_mass=None)[source]#
Wavenumber of a hydrogen-like atom’s \(n_\text{upper}\to n_\text{lower}\) line, in cm^-1.
- Parameters:
n_lower (int or array-like of int) – Principal quantum number of the lower level (1 = Lyman, 2 = Balmer, 3 = Paschen, …).
n_upper (int or array-like of int) – Principal quantum number of the upper level (must exceed n_lower).
nuclear_charge (
int) – Nuclear charge Z.nuclear_mass (
float|None) – Nuclear mass M, in kg, for the reduced-mass correction \(R_M=R_\infty/(1+m_e/M)\).Noneuses the infinite-nuclear-mass constant \(R_\infty\).
- Returns:
float or ndarray – Vacuum wavenumber, in cm^-1 (positive).
Examples
Balmer’s H-alpha line (\(3\to2\)) of ordinary hydrogen, at a vacuum wavelength of 656.47 nm:
>>> import scipy.constants as sc >>> nu = rydberg_wavenumber(2, 3, nuclear_mass=sc.m_p) >>> round(1e7 / nu, 2) 656.47
The series limit (\(n_\text{upper}\to\infty\)) of the Lyman series is the ionization energy, \(R_\infty\) itself for an infinitely heavy nucleus:
>>> round(rydberg_wavenumber(1, 10**6) / (sc.Rydberg / 100.0), 9) 1.0
- chemistrykit.spectro.transmittance(absorbance_value)[source]#
Transmittance \(T=10^{-A}\), the fraction of incident light transmitted.
- Parameters:
- Returns:
float or ndarray – In \([0, 1]\).
Examples
>>> round(transmittance(0.0), 6) 1.0 >>> round(transmittance(1.0), 6) 0.1
- chemistrykit.spectro.voigt(x, x0, fwhm_gaussian, fwhm_lorentzian)[source]#
A normalized Voigt profile: the convolution of a Gaussian and a Lorentzian.
\[V(x) = \mathrm{Re}\!\left[\frac{w(z)}{\sigma\sqrt{2\pi}}\right], \qquad z=\frac{(x-x_0)+i\gamma}{\sigma\sqrt2}\]where w is the Faddeeva function (
scipy.special.wofz()), \(\sigma=\text{FWHM}_G/(2\sqrt{2\ln2})\) is the Gaussian standard deviation, and \(\gamma=\text{FWHM}_L/2\) is the Lorentzian half-width – the standard numerically stable evaluation of the Voigt profile (Hollas, Modern Spectroscopy, 4th ed., Ch. 2.3; J. Humlicek, J. Quant. Spectrosc. Radiat. Transfer 21, 309 (1979)).- Parameters:
- Returns:
float or ndarray
Examples
Normalized, and reduces to a pure Gaussian/Lorentzian in the appropriate limit:
>>> import numpy as np >>> x = np.linspace(-1000, 1000, 200001) >>> round(float(np.trapezoid(voigt(x, 0.0, fwhm_gaussian=1.0, fwhm_lorentzian=1.0), x)), 2) 1.0 >>> x_narrow = np.linspace(-50, 50, 200001) >>> pure_lorentzian_limit = voigt(x_narrow, 0.0, fwhm_gaussian=1e-8, fwhm_lorentzian=2.0) >>> bool(np.allclose(pure_lorentzian_limit, lorentzian(x_narrow, 0.0, fwhm=2.0), atol=1e-3)) True