From a70b578756330368126854a757412662d3bbf911 Mon Sep 17 00:00:00 2001
From: Kevin Kuns <kevin.kuns@ligo.org>
Date: Sun, 27 Mar 2022 15:11:29 -0400
Subject: [PATCH] replace suspect residual gas scattering approximation with
 exact integral

---
 gwinc/noise/residualgas.py                    |  73 ++---
 .../residualgas/AplusSuperGas/__init__.py     |  11 +-
 test/noises/residualgas/test_residualgas.py   | 263 ++++++++++++++++++
 3 files changed, 287 insertions(+), 60 deletions(-)

diff --git a/gwinc/noise/residualgas.py b/gwinc/noise/residualgas.py
index 43881c91..22e1ba42 100644
--- a/gwinc/noise/residualgas.py
+++ b/gwinc/noise/residualgas.py
@@ -62,10 +62,15 @@ def ResidualGasScattering_constructor(species_name):
 
         def calc(self):
             cavity = arm_cavity(self.ifo)
+            Larm_m = self.ifo.Infrastructure.Length
             species = self.ifo.Infrastructure.ResidualGas[species_name]
+            # position along the beamtube starting from vertex corner
+            tube_pos = np.linspace(0, Larm_m, 100)
+            # pressure profile is constant by default
+            pressure_Pa = species.BeamtubePressure * np.ones_like(tube_pos)
             n = residual_gas_scattering_arm(
-                self.freq, self.ifo, cavity, species)
-            dhdl_sqr, sinc_sqr = dhdl(self.freq, self.ifo.Infrastructure.Length)
+                self.freq, self.ifo, cavity, species, pressure_Pa, tube_pos)
+            dhdl_sqr, sinc_sqr = dhdl(self.freq, Larm_m)
             return n * 2 / sinc_sqr
 
     return GasScatteringSpecies
@@ -136,16 +141,16 @@ class ResidualGas(nb.Budget):
     ]
 
 
-def residual_gas_scattering_arm(f, ifo, cavity, species):
-    """Residual gas noise strain spectrum due to scattering from one arm
-
-    Noise caused by the passage of residual gas molecules through the
-    laser beams in one arm cavity due to scattering.
+def residual_gas_scattering_arm(
+        f, ifo, cavity, species, pressure_Pa, tube_pos):
+    """Residual gas scattering from one arm using measured beamtube pressures
 
     :f: frequency array in Hz
     :ifo: gwinc IFO structure
     :cavity: arm cavity structure
     :species: molecular species structure
+    :pressure_Pa: beamtube pressure profile in Pa
+    :tubepos_m: vector of positions where pressure is given in m
 
     :returns: arm strain noise power spectrum at :f:
 
@@ -153,64 +158,22 @@ def residual_gas_scattering_arm(f, ifo, cavity, species):
     E. Zucker, and Stanley E. Whitcomb in their paper Optical
     Pathlength Noise in Sensitive Interferometers Due to Residual Gas.
 
-    Added to Bench by Zhigang Pan, Summer 2006
-    Cleaned up by PF, Apr 07
-    Eliminated numerical integration and substituted first order
-    expansion of exp, to speed it up.
-
-    """
-    L = ifo.Infrastructure.Length
-    kT = ifo.Infrastructure.Temp * const.kB
-    P = species.BeamtubePressure
-    M = species.mass
-    alpha = species.polarizability
-
-    rho = P / (kT)                   # number density of Gas
-    v0 = sqrt(2*kT / M)              # mean speed of Gas
-
-    waist = cavity.w0                # Gaussian beam waist size
-    zr = cavity.zr                   # Rayleigh range
-    z1 = -cavity.zBeam_ITM           # location of ITM relative to the waist
-    z2 = cavity.zBeam_ETM            # location of ETM relative to the waist
-
-    # The exponential of Eq. 1 of P940008 is expanded to first order; this
-    # can be integrated analytically
-    zint = log(z2 + sqrt(z2**2 + zr**2)) - log(z1 + sqrt(z1**2 + zr**2))
-    zint = zint * zr/waist
-    zint = zint - 2*pi*L*f/v0
-    # optical path length for one arm
-    zint = zint*((4*rho*(2*pi*alpha)**2)/v0)
-    # eliminate any negative values due to first order approx.
-    zint[zint < 0] = 0
-
-    return zint
-
-
-def residual_gas_scattering_beamtube_pressure(
-        f, ifo, cavity, species, pressure_Pa, position_m):
-    """Residual gas scattering from one arm using measured beamtube pressures
-
-    :f: frequency array in Hz
-    :ifo: gwinc IFO structure
-    :cavity: arm cavity structure
-    :species: molecular species structure
-    :pressure_Pa: beamtube pressure profile in Pa
-    :position_m: vector of positions where pressure is given in m
-
-    :returns: arm strain noise power spectrum at :f:
     """
-    ww_m = cavity.w0 * np.sqrt(1 + ((position_m - cavity.zBeam_ITM)/cavity.zr)**2)
+    # beam profile
+    ww_m = cavity.w0 * np.sqrt(1 + ((tube_pos - cavity.zBeam_ITM)/cavity.zr)**2)
     kT = ifo.Infrastructure.Temp * const.kB
     M = species.mass
     alpha = species.polarizability
 
-    v0 = np.sqrt(2*kT / M)
+    v0 = np.sqrt(2*kT / M)  # most probable speed of Gas
 
     alpha = species.polarizability
 
+    # put the integrand into a (numfreq, numpressure) array for faster
+    # integration with trapz
     integrand = np.exp(np.einsum('i,j->ij', -2*np.pi*f, ww_m/v0))
     integrand *= np.einsum('i,j->ij', np.ones_like(f), pressure_Pa / ww_m)
-    zint = trapz(integrand, position_m, axis=1)
+    zint = trapz(integrand, tube_pos, axis=1)
 
     noise = 4 * (2*np.pi*alpha)**2 / (v0 * kT) * zint
 
diff --git a/test/noises/residualgas/AplusSuperGas/__init__.py b/test/noises/residualgas/AplusSuperGas/__init__.py
index a047885e..1fef5588 100644
--- a/test/noises/residualgas/AplusSuperGas/__init__.py
+++ b/test/noises/residualgas/AplusSuperGas/__init__.py
@@ -27,7 +27,7 @@ class Quantum(nb.Budget):
 
 
 def ResidualGasScattering_constructor(species_name, tube):
-    """Residual gas scattering for a single species
+    """Residual gas scattering for a single species and a single arm
 
     """
 
@@ -59,17 +59,18 @@ def ResidualGasScattering_constructor(species_name, tube):
             fname = path.join(path.split(bpath)[0], 'beamtube_pressure.txt')
             df = pd.read_csv(fname, sep='\t')
             pressure_torr = df[species_name + '_' + tube]
-            self.position_m = df['position_m']
+            self.tubepos_m = df['position_m']
             self.pressure_Pa = pressure_torr * 133.3
 
         def calc(self):
             cavity = noise.residualgas.arm_cavity(self.ifo)
             species = self.ifo.Infrastructure.ResidualGas[species_name]
-            n = noise.residualgas.residual_gas_scattering_beamtube_pressure(
+            n = noise.residualgas.residual_gas_scattering_arm(
                 self.freq, self.ifo, cavity, species, self.pressure_Pa,
-                self.position_m)
+                self.tubepos_m)
             dhdl_sqr, sinc_sqr = dhdl(self.freq, self.ifo.Infrastructure.Length)
-            return n * 2 / sinc_sqr
+            # note that this is for a single arm, so no factor of 2
+            return n / sinc_sqr
 
     return GasScatteringSpeciesTube
 
diff --git a/test/noises/residualgas/test_residualgas.py b/test/noises/residualgas/test_residualgas.py
index 22fb1686..56fa3605 100644
--- a/test/noises/residualgas/test_residualgas.py
+++ b/test/noises/residualgas/test_residualgas.py
@@ -3,9 +3,37 @@ Unit tests for residual gas noise
 """
 import numpy as np
 import gwinc
+import gwinc.noise.residualgas as resgas
+from gwinc.ifo.noises import dhdl, arm_cavity
+from gwinc import const
+import pytest
+from itertools import product
+import matplotlib.pyplot as plt
+from timeit import timeit
+from importlib import import_module
+from numpy import sqrt, log, pi
+
+
+SPECIES = [
+    'H2',
+    'N2',
+    'H2O',
+    'O2',
+]
+
+
+def timer(func, *args, **kwargs):
+    """
+    Helper function for timing functions with timeit
+    """
+    def timeit_func():
+        return func(*args, **kwargs)
+    return timeit_func
 
 
 def test_custom_beamtube_pressure(fpath_join, tpath_join):
+    """Plot the AplusSuperGas budget specifying a custom pressure profile
+    """
     F_Hz = np.logspace(np.log10(5), 5, 3000)
     budget = gwinc.load_budget(fpath_join('AplusSuperGas'), freq=F_Hz)
     traces = budget.run()
@@ -13,3 +41,238 @@ def test_custom_beamtube_pressure(fpath_join, tpath_join):
     fig_resgas = traces.ResidualGas.plot()
     fig_total.savefig(tpath_join('total.pdf'))
     fig_resgas.savefig(tpath_join('residual_gas.pdf'))
+
+
+@pytest.mark.parametrize('ifo_name', gwinc.IFOS)
+def test_compare_scattering_budgets(ifo_name, tpath_join):
+    """
+    Compare the budgets for the exact and old approximate gas scattering
+    calculations.
+    """
+    # import, initialize, and run the default exact budget
+    budget = gwinc.load_budget(ifo_name)
+    traces = budget.run()
+
+    # import the default exact budget
+    mod = import_module('gwinc.ifo.{:s}'.format(ifo_name))
+    Budget_approx = getattr(mod, ifo_name)
+    # replace the exact calculation with the approximate one
+    Budget_approx.noises.pop(-1)
+    Budget_approx.noises.append(ResidualGasApprox)
+    # initialize and run the approximate budget with the default ifo
+    budget_approx = Budget_approx(ifo=budget.ifo, freq=budget.freq)
+    traces_approx = budget_approx.run()
+
+    fig = traces.plot()
+    fig_rg = traces.ResidualGas.plot()
+    ylims = fig.gca().get_ylim()
+    ylims_rg = fig_rg.gca().get_ylim()
+    fig.gca().set_title('{:s} Exact Calculation'.format(ifo_name))
+    fig_rg.gca().set_title('{:s} Exact Calculation'.format(ifo_name))
+    fig.savefig(tpath_join('total.pdf'))
+    fig_rg.savefig(tpath_join('resgas.pdf'))
+
+    fig_approx = traces_approx.plot()
+    fig_approx_rg = traces_approx.ResidualGasApprox.plot()
+    fig_approx.gca().set_ylim(*ylims)
+    fig_approx_rg.gca().set_ylim(*ylims_rg)
+    fig_approx.gca().set_title('{:s} Approximate Calculation'.format(ifo_name))
+    fig_approx_rg.gca().set_title('{:s} Approximate Calculation'.format(ifo_name))
+    fig_approx.savefig(tpath_join('total_approx.pdf'))
+    fig_approx_rg.savefig(tpath_join('resgas_approx.pdf'))
+
+
+@pytest.mark.parametrize('species_name, ifo', product(SPECIES, gwinc.IFOS))
+def test_scattering_calcs(species_name, ifo, tpath_join):
+    """
+    Compare the exact gas scattering calculations using different size pressure
+    arrays with the old approximate calculation
+    """
+    budget = gwinc.load_budget(ifo)
+    ifo = budget.ifo
+    F_Hz = np.logspace(1, 4, 3000)
+    cavity = resgas.arm_cavity(ifo)
+    Larm_m = ifo.Infrastructure.Length
+    species = ifo.Infrastructure.ResidualGas[species_name]
+    pressure_Pa = species.BeamtubePressure
+    approx = residual_gas_scattering_arm_approx(F_Hz, ifo, cavity, species)
+    approx = np.sqrt(approx)
+
+    def calc_exact(npts):
+        position_m = np.linspace(0, Larm_m, npts)
+        exact = resgas.residual_gas_scattering_arm(
+            F_Hz, ifo, cavity, species, pressure_Pa, position_m)
+        return np.sqrt(exact)
+
+    exact1000 = calc_exact(1000)
+    exact500  = calc_exact(500)
+    exact200  = calc_exact(200)
+    exact100  = calc_exact(100)
+    exact50   = calc_exact(50)
+
+    fig, ax = plt.subplots()
+    ax.loglog(F_Hz, exact1000, label='exact')
+    ax.loglog(F_Hz, approx, label='approximate')
+    ax.set_xlim(F_Hz[0], F_Hz[-1])
+    ax.set_ylim(5e-23, 5e-21)
+    ax.grid(True, which='major', alpha=0.5)
+    ax.grid(True, which='minor', alpha=0.2)
+    ax.set_xlabel('Frequency [Hz]')
+    ax.set_ylabel('Strain noise [1/Hz$^{-1/2}$]')
+    ax.set_title(species_name)
+    ax.legend()
+    fig.savefig(tpath_join('resgas.pdf'))
+
+    fig_rat, ax_rat = plt.subplots()
+    ax_rat.semilogx(F_Hz, np.abs(1 - exact500 / exact1000), label='500')
+    ax_rat.semilogx(F_Hz, np.abs(1 - exact200 / exact1000), label='200')
+    ax_rat.semilogx(F_Hz, np.abs(1 - exact100 / exact1000), label='100')
+    ax_rat.semilogx(F_Hz, np.abs(1 - exact50 / exact1000), label='50')
+    ax_rat.semilogx(F_Hz, np.abs(1 - approx / exact1000), label='approximate')
+    ax_rat.set_yscale('log')
+    ax_rat.set_xlim(F_Hz[0], F_Hz[-1])
+    ax_rat.set_ylim(1e-7, 1e-2)
+    ax_rat.set_xlabel('Frequency [Hz]')
+    ax_rat.set_ylabel('Ratio with 1000 points')
+    ax_rat.set_title(species_name)
+    ax_rat.legend()
+    ax_rat.grid(True, which='major', alpha=0.5)
+    ax_rat.grid(True, which='minor', alpha=0.2)
+    fig_rat.savefig(tpath_join('ratios.pdf'))
+
+
+@pytest.mark.slow
+def test_time(pprint):
+    """
+    Compare the new exact and the old approximate gas scattering calculations
+    """
+    niter = 500
+    budget = gwinc.load_budget('Aplus')
+    ifo = budget.ifo
+    F_Hz = np.logspace(1, 4, 3000)
+    cavity = resgas.arm_cavity(ifo)
+    Larm_m = ifo.Infrastructure.Length
+    species = ifo.Infrastructure.ResidualGas['N2']
+    pressure_Pa = species.BeamtubePressure
+
+    position_m = np.linspace(0, Larm_m, 100)
+    time_exact = timeit(
+        timer(
+            resgas.residual_gas_scattering_arm,
+            F_Hz, ifo, cavity, species, pressure_Pa, position_m
+        ),
+        number=niter
+    ) / niter
+
+    time_approx = timeit(
+        timer(
+            residual_gas_scattering_arm_approx,
+            F_Hz, ifo, cavity, species
+        ),
+        number=niter
+    ) / niter
+
+    pprint('approximate: {:0.3f} ms'.format(1e3 * time_approx))
+    pprint('exact: {:0.3f} ms'.format(1e3 * time_exact))
+    pprint('relative change: {:0.2f}'.format(time_exact / time_approx))
+
+
+############################################################
+# Old approximate gas scattering functions for comparison
+############################################################
+
+
+def ResidualGasScatteringApprox_constructor(species_name):
+    """Residual gas scattering for a single species
+
+    """
+
+    class GasScatteringSpecies(resgas.nb.Noise):
+        name = 'Scattering' + species_name
+        style = dict(
+            label=resgas.RESGAS_STYLES[species_name]['label'] + ' scattering',
+            color=resgas.RESGAS_STYLES[species_name]['color'],
+            linestyle='-',
+        )
+
+        def calc(self):
+            cavity = arm_cavity(self.ifo)
+            species = self.ifo.Infrastructure.ResidualGas[species_name]
+            n = residual_gas_scattering_arm_approx(
+                self.freq, self.ifo, cavity, species)
+            dhdl_sqr, sinc_sqr = dhdl(self.freq, self.ifo.Infrastructure.Length)
+            return n * 2 / sinc_sqr
+
+    return GasScatteringSpecies
+
+
+def residual_gas_scattering_arm_approx(f, ifo, cavity, species):
+    """Residual gas noise strain spectrum due to scattering from one arm
+
+    Noise caused by the passage of residual gas molecules through the
+    laser beams in one arm cavity due to scattering.
+
+    :f: frequency array in Hz
+    :ifo: gwinc IFO structure
+    :cavity: arm cavity structure
+    :species: molecular species structure
+
+    :returns: arm strain noise power spectrum at :f:
+
+    The method used here is presented by Rainer Weiss, Micheal
+    E. Zucker, and Stanley E. Whitcomb in their paper Optical
+    Pathlength Noise in Sensitive Interferometers Due to Residual Gas.
+
+    Added to Bench by Zhigang Pan, Summer 2006
+    Cleaned up by PF, Apr 07
+    Eliminated numerical integration and substituted first order
+    expansion of exp, to speed it up.
+
+    """
+    L = ifo.Infrastructure.Length
+    kT = ifo.Infrastructure.Temp * const.kB
+    P = species.BeamtubePressure
+    M = species.mass
+    alpha = species.polarizability
+
+    rho = P / (kT)                   # number density of Gas
+    v0 = sqrt(2*kT / M)              # mean speed of Gas
+
+    waist = cavity.w0                # Gaussian beam waist size
+    zr = cavity.zr                   # Rayleigh range
+    z1 = -cavity.zBeam_ITM           # location of ITM relative to the waist
+    z2 = cavity.zBeam_ETM            # location of ETM relative to the waist
+
+    # The exponential of Eq. 1 of P940008 is expanded to first order; this
+    # can be integrated analytically
+    zint = log(z2 + sqrt(z2**2 + zr**2)) - log(z1 + sqrt(z1**2 + zr**2))
+    zint = zint * zr/waist
+    zint = zint - 2*pi*L*f/v0
+    # optical path length for one arm
+    zint = zint*((4*rho*(2*pi*alpha)**2)/v0)
+    # eliminate any negative values due to first order approx.
+    zint[zint < 0] = 0
+
+    return zint
+
+
+class ResidualGasApprox(resgas.nb.Budget):
+    """Residual Gas
+
+    """
+    style = dict(
+        label='Residual Gas',
+        color='#add00d',
+        linestyle='-',
+    )
+
+    noises = [
+        ResidualGasScatteringApprox_constructor('H2'),
+        ResidualGasScatteringApprox_constructor('N2'),
+        ResidualGasScatteringApprox_constructor('H2O'),
+        ResidualGasScatteringApprox_constructor('O2'),
+        resgas.ResidualGasDamping_constructor('H2'),
+        resgas.ResidualGasDamping_constructor('N2'),
+        resgas.ResidualGasDamping_constructor('H2O'),
+        resgas.ResidualGasDamping_constructor('O2'),
+    ]
-- 
GitLab