diff --git a/.gitattributes b/.gitattributes old mode 100644 new mode 100755 index 5ba79e1336fb7442cdb5f44c6acd150621148302..6ace612063f48e24b95b697a461f39bd10a2b911 --- a/.gitattributes +++ b/.gitattributes @@ -2,3 +2,5 @@ tests/keplerian-orbits-1-0-2.h5 filter=lfs diff=lfs merge=lfs -text tests/esa-orbits-1-0-2.h5 filter=lfs diff=lfs merge=lfs -text tests/keplerian-orbits-2-0-dev.h5 filter=lfs diff=lfs merge=lfs -text tests/esa-trailing-orbits-2-0-dev.h5 filter=lfs diff=lfs merge=lfs -text +tests/keplerian-fplan-1-1.h5 filter=lfs diff=lfs merge=lfs -text +tests/esa-trailing-fplan-1-1.h5 filter=lfs diff=lfs merge=lfs -text diff --git a/lisainstrument/instrument.py b/lisainstrument/instrument.py old mode 100644 new mode 100755 index 1062db019c1f58802a8099bd4abb1acd737119f0..aa4ad91cea311f3105e19dd621c19077bbb28778 --- a/lisainstrument/instrument.py +++ b/lisainstrument/instrument.py @@ -74,7 +74,7 @@ class Instrument: # Artifacts glitches=None, # Laser locking and frequency plan - lock='N1-12', offsets_freqs='default', + lock='N1-12', fplan='static', # Laser sources laser_asds=28.2, central_freq=2.816E14, # Laser phase modulation @@ -124,9 +124,8 @@ class Instrument: glitches: path to glitch file, or dictionary of glitch signals per injection point lock: pre-defined laser locking configuration ('N1-12' non-swap N1 with 12 primary laser), or 'six' for 6 lasers locked on cavities, or a dictionary of locking conditions - offsets_freqs: dictionary of laser frequency offsets [Hz], or 'default' - # fplan: path to frequency-plan file, or dictionary of locking beatnote frequencies [Hz], - # or None for a default set of constant locking beatnote frequencies + fplan: path to frequency-plan file, dictionary of locking beatnote frequencies [Hz], + or 'static' for a default set of constant locking beatnote frequencies laser_asds: dictionary of amplitude spectral densities for laser noise [Hz/sqrt(Hz)] central_freq: laser central frequency from which all offsets are computed [Hz] modulation_asds: dictionary of amplitude spectral densities for modulation noise @@ -235,6 +234,7 @@ class Instrument: # Instrument topology self.central_freq = float(central_freq) self.init_lock(lock) + self.init_fplan(fplan) # Laser and modulation noise self.laser_asds = ForEachMOSA(laser_asds) @@ -360,16 +360,6 @@ class Instrument: else: self.mosa_angles = ForEachMOSA(mosa_angles) - # Frequency plan - if offsets_freqs == 'default': - # Default based on default for LISANode - self.offsets_freqs = ForEachMOSA({ - '12': 8.1E6, '23': 9.2E6, '31': 10.3E6, - '13': 1.4E6, '32': -11.6E6, '21': -9.5E6, - }) - else: - self.offsets_freqs = ForEachMOSA(offsets_freqs) - # Orbits, gravitational waves, glitches self.init_orbits(orbits, orbit_dataset) self.init_gws(gws) @@ -470,15 +460,16 @@ class Instrument: """Initialize laser locking configuration.""" if lock == 'six': logger.info("Using pre-defined locking configuration 'six'") - self.lock_config = 'six' + self.lock_config = None # not a standard lock config self.lock = {'12': 'cavity', '23': 'cavity', '31': 'cavity', '13': 'cavity', '32': 'cavity', '21': 'cavity'} elif isinstance(lock, str): logger.info("Using pre-defined locking configuration '%s'", lock) + self.lock_config = lock match = re.match(r'^(N[1-6])-(12|23|31|13|32|21)$', lock) if match: - self.lock_config = (match.group(1), match.group(2)) - lock_12 = self.LOCK_TOPOLOGIES[self.lock_config[0]] # with 12 as primary - cycle = self.INDEX_CYCLES[self.lock_config[1]] # correspondance to lock_12 + topology, primary = match.group(1), match.group(2) + lock_12 = self.LOCK_TOPOLOGIES[topology] # with 12 as primary + cycle = self.INDEX_CYCLES[primary] # correspondance to lock_12 self.lock = {mosa: lock_12[cycle[mosa]] for mosa in self.MOSAS} else: raise ValueError(f"unsupported pre-defined locking configuration '{lock}'") @@ -486,11 +477,68 @@ class Instrument: logger.info("Using explicit locking configuration '%s'", lock) if (set(lock.keys()) != set(self.MOSAS) or set(lock.values()) != set(['cavity', 'distant', 'adjacent'])): - raise ValueError(f"invalid locking dictionary '{lock}'") + raise ValueError(f"invalid locking configuration '{lock}'") + self.lock_config = None self.lock = lock else: raise ValueError(f"invalid locking configuration '{lock}'") + def init_fplan(self, fplan): + """Initialize frequency plan. + + Args: + fplan: `fplan` parameter, c.f. `__init__()` + """ + if fplan == 'static': + logger.info("Using default set of locking beatnote frequencies") + self.fplan_file = None + self.fplan = ForEachMOSA({ + '12': 8E6, '23': 9E6, '31': 10E6, + '13': -8.2E6, '32': -8.5E6, '21': -8.7E6, + }) + elif isinstance(fplan, str): + logger.info("Using frequency-plan file '%s'", fplan) + self.fplan_file = fplan + # Without a standard lock config, there is no dataset + # in the frequency-plan file and therefore we cannot use it + if self.lock_config is None: + raise ValueError("cannot use frequency-plan for non standard lock configuration") + with File(self.fplan_file, 'r') as fplanf: + version = Version(fplanf.attrs['version']) + logger.debug("Using frequency-plan file version %s", version) + # Warn for frequency-plan file development version + if version.is_devrelease: + logger.warning("You are using an frequency-plan file in a development version") + # Switch between various fplan file standards + if version in SpecifierSet('== 1.1.*', True): + logger.debug("Interpolating locking beatnote frequencies with piecewise linear functions") + times = self.t0 + np.arange(fplanf.attrs['size']) * fplanf.attrs['dt'] + interpolate = lambda x: InterpolatedUnivariateSpline(times, x, k=1, ext='raise')(self.physics_t) + lock_beatnotes = {} + # Go through all MOSAs and pick locking beatnotes + try: + for mosa in self.MOSAS: + if self.lock[mosa] == 'cavity': + # No offset for primary laser + lock_beatnotes[mosa] = 0.0 + elif self.lock[mosa] == 'distant': + lock_beatnotes[mosa] = 1E6 * interpolate(fplanf[self.lock_config][f'isi_{mosa}']) + elif self.lock[mosa] == 'adjacent': + # Fplan files only contain the one (left) RFI beatnote + left_mosa = ForEachSC.left_mosa(ForEachMOSA.sc(mosa)) + sign = +1 if left_mosa == mosa else -1 + lock_beatnotes[mosa] = 1E6 * sign * interpolate(fplanf[self.lock_config][f'rfi_{left_mosa}']) + except ValueError as error: + logger.error("Missing frequency-plan information at \n%s") + raise ValueError("missing frequency-plan information, use longer file or adjust sampling") from error + self.fplan = ForEachMOSA(lock_beatnotes) + else: + raise ValueError(f"unsupported frequency-plan file version '{version}'") + else: + logger.info("Using user-provided locking beatnote frequencies") + self.fplan_file = None + self.fplan = ForEachMOSA(fplan) + def init_orbits(self, orbits, orbit_dataset): """Initialize orbits. @@ -502,9 +550,9 @@ class Instrument: logger.info("Using default set of static proper pseudo-ranges") self.orbit_file = None self.pprs = ForEachMOSA({ - # Default PPRs based on first samples of Keplerian orbits (v1.0) - '12': 8.3324, '23': 8.3028, '31': 8.3324, - '13': 8.3315, '32': 8.3044, '21': 8.3315, + # Default PPRs based on first samples of Keplerian orbits (v2.0.dev) + '12': 8.33242295, '23': 8.30282196, '31': 8.33242298, + '13': 8.33159404, '32': 8.30446786, '21': 8.33159402, }) self.d_pprs = ForEachMOSA(0) self.tps_proper_time_deviations = ForEachSC(0) @@ -783,7 +831,7 @@ class Instrument: logger.info("Simulating local beams") self.simulate_locking() - ## simulate sidebands + ## Simulate sidebands logger.debug("Computing upper sideband offsets for primary local beam") self.local_usb_offsets = self.local_carrier_offsets \ @@ -1418,7 +1466,7 @@ class Instrument: self.laser_noises[mosa] = noises.laser(self.physics_fs, self.physics_size, self.laser_asds[mosa]) logger.debug("Computing carrier offsets for primary local beam %s", mosa) - self.local_carrier_offsets[mosa] = self.offsets_freqs[mosa] + self.local_carrier_offsets[mosa] = 0.0 logger.debug("Computing carrier fluctuations for primary local beam %s", mosa) self.local_carrier_fluctuations[mosa] = \ @@ -1438,14 +1486,14 @@ class Instrument: "locked on adjacent beam %s", mosa, adjacent(mosa)) self.local_carrier_offsets[mosa] = \ self.local_carrier_offsets[adjacent(mosa)] \ - + self.offsets_freqs[mosa] * (1 + self.clock_noise_offsets[sc(mosa)]) + - self.fplan[mosa] * (1 + self.clock_noise_offsets[sc(mosa)]) logger.debug("Computing carrier fluctuations for local beam %s " "locked on adjacent beam %s", mosa, adjacent(mosa)) adjacent_carrier_fluctuations = self.local_carrier_fluctuations[adjacent(mosa)] \ + self.central_freq * self.backlink_noises[mosa] self.local_carrier_fluctuations[mosa] = adjacent_carrier_fluctuations \ - + self.offsets_freqs[mosa] * self.clock_noise_fluctuations[sc(mosa)] \ + - self.fplan[mosa] * self.clock_noise_fluctuations[sc(mosa)] \ + self.central_freq * self.oms_rfi_carrier_noises[mosa] \ + self.tdir_tones[mosa] @@ -1466,7 +1514,7 @@ class Instrument: -self.d_pprs[mosa] * self.central_freq \ + (1 - self.d_pprs[mosa]) * self.interpolate(carrier_offsets, -self.pprs[mosa]) self.local_carrier_offsets[mosa] = distant_carrier_offsets \ - + self.offsets_freqs[mosa] * (1 + self.clock_noise_offsets[sc(mosa)]) + - self.fplan[mosa] * (1 + self.clock_noise_offsets[sc(mosa)]) logger.debug("Computing carrier fluctuations for local beam %s " "locked on distant beam %s", mosa, distant(mosa)) @@ -1479,7 +1527,7 @@ class Instrument: - (self.central_freq + self.local_carrier_offsets[mosa]) * self.gws[mosa] \ - (self.central_freq + self.local_carrier_offsets[mosa]) * self.local_ttls[mosa] / c self.local_carrier_fluctuations[mosa] = distant_carrier_fluctuations \ - + self.offsets_freqs[mosa] * self.clock_noise_fluctuations[sc(mosa)] \ + - self.fplan[mosa] * self.clock_noise_fluctuations[sc(mosa)] \ + self.central_freq * self.oms_isi_carrier_noises[mosa] \ + self.tdir_tones[mosa] diff --git a/tests/esa-trailing-fplan-1-1.h5 b/tests/esa-trailing-fplan-1-1.h5 new file mode 100755 index 0000000000000000000000000000000000000000..1ed838af44e8741c0317b94b103b1f416bf1cbad --- /dev/null +++ b/tests/esa-trailing-fplan-1-1.h5 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:91cdf77a8b830999d7409d35e2cb5d44870eeaba9a86d516b5c6e8f87e75fa7a +size 22160440 diff --git a/tests/keplerian-fplan-1-1.h5 b/tests/keplerian-fplan-1-1.h5 new file mode 100755 index 0000000000000000000000000000000000000000..895e915979e8c6268ac53e9ef129c959ac221210 --- /dev/null +++ b/tests/keplerian-fplan-1-1.h5 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f42b997aef2f2820582aaf35e102c16e59efc0d93f6379fa3355f4821ef810e2 +size 20610648 diff --git a/tests/test_fplan.py b/tests/test_fplan.py new file mode 100755 index 0000000000000000000000000000000000000000..23d18f4cff1e1c94a29eb31c9402c4b5f2d76c58 --- /dev/null +++ b/tests/test_fplan.py @@ -0,0 +1,247 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Test the usage of frequency plans.""" + +import numpy as np +import pytest + +from h5py import File +from scipy.interpolate import interp1d +from lisainstrument import Instrument + + +def _consistent_locking_beatnotes(instru, fplan=None): + """Check that TPS locking beatnotes are consistent with ``fplan``. + + We check that locking beatnotes are equal up to machine precision. + + Args: + instru (:class:`lisainstrument.Instrument`): instrument object + fplan (dict or None): dictionary of locking beatnotes [Hz] or None to read fplan file + + Returns: + (bool) Whether the locking beatnotes are valid. + """ + # Read fplan file + if fplan is None: + with File(instru.fplan_file, 'r') as fplanf: + t = np.arange(fplanf.attrs['size']) * fplanf.attrs['dt'] + if instru.lock_config == 'N1-12': + fplan = { + '13': interp1d(t, -fplanf[instru.lock_config]['rfi_12'][:] * 1E6)(instru.physics_t), + '31': interp1d(t, fplanf[instru.lock_config]['isi_31'][:] * 1E6)(instru.physics_t), + '32': interp1d(t, -fplanf[instru.lock_config]['rfi_31'][:] * 1E6)(instru.physics_t), + '21': interp1d(t, fplanf[instru.lock_config]['isi_21'][:] * 1E6)(instru.physics_t), + '23': interp1d(t, fplanf[instru.lock_config]['rfi_23'][:] * 1E6)(instru.physics_t), + } + elif instru.lock_config == 'N1-21': + fplan = { + '23': interp1d(t, fplanf[instru.lock_config]['rfi_23'][:] * 1E6)(instru.physics_t), + '32': interp1d(t, fplanf[instru.lock_config]['isi_32'][:] * 1E6)(instru.physics_t), + '31': interp1d(t, fplanf[instru.lock_config]['rfi_31'][:] * 1E6)(instru.physics_t), + '12': interp1d(t, fplanf[instru.lock_config]['isi_12'][:] * 1E6)(instru.physics_t), + '13': interp1d(t, -fplanf[instru.lock_config]['rfi_12'][:] * 1E6)(instru.physics_t), + } + elif instru.lock_config == 'N4-12': + fplan = { + '13': interp1d(t, -fplanf[instru.lock_config]['rfi_12'][:] * 1E6)(instru.physics_t), + '31': interp1d(t, fplanf[instru.lock_config]['isi_31'][:] * 1E6)(instru.physics_t), + '32': interp1d(t, -fplanf[instru.lock_config]['rfi_31'][:] * 1E6)(instru.physics_t), + '23': interp1d(t, fplanf[instru.lock_config]['isi_23'][:] * 1E6)(instru.physics_t), + '21': interp1d(t, fplanf[instru.lock_config]['isi_21'][:] * 1E6)(instru.physics_t), + } + # Check locking beatnotes + if instru.lock_config == 'N1-12': + return np.all([ + np.allclose(instru.tps_rfi_carrier_offsets['13'], fplan['13']), + np.allclose(instru.tps_isi_carrier_offsets['31'], fplan['31']), + np.allclose(instru.tps_rfi_carrier_offsets['32'], fplan['32']), + np.allclose(instru.tps_isi_carrier_offsets['21'], fplan['21']), + np.allclose(instru.tps_rfi_carrier_offsets['23'], fplan['23']), + ]) + if instru.lock_config == 'N1-21': + return np.all([ + np.allclose(instru.tps_rfi_carrier_offsets['23'], fplan['23']), + np.allclose(instru.tps_isi_carrier_offsets['32'], fplan['32']), + np.allclose(instru.tps_rfi_carrier_offsets['31'], fplan['31']), + np.allclose(instru.tps_isi_carrier_offsets['12'], fplan['12']), + np.allclose(instru.tps_rfi_carrier_offsets['13'], fplan['13']), + ]) + if instru.lock_config == 'N4-12': + return np.all([ + np.allclose(instru.tps_rfi_carrier_offsets['13'], fplan['13']), + np.allclose(instru.tps_isi_carrier_offsets['31'], fplan['31']), + np.allclose(instru.tps_rfi_carrier_offsets['32'], fplan['32']), + np.allclose(instru.tps_isi_carrier_offsets['23'], fplan['23']), + np.allclose(instru.tps_isi_carrier_offsets['21'], fplan['21']), + ]) + raise ValueError(f"unsupported lock configuration '{instru.lock_config}'") + +def test_static_fplan(): + """Test the default static set of locking beatnotes.""" + + # Check fplan initialization + instru = Instrument(size=100, fplan='static') + static = { + '12': 8E6, '23': 9E6, '31': 10E6, + '13': -8.2E6, '32': -8.5E6, '21': -8.7E6, + } + for mosa in instru.MOSAS: + assert instru.fplan[mosa] == static[mosa] + + # Check locking beatnotes + instru = Instrument(size=100, lock='N1-12', fplan='static') + instru.simulate() + assert _consistent_locking_beatnotes(instru, static) + instru = Instrument(size=100, lock='N1-21', fplan='static') + instru.simulate() + assert _consistent_locking_beatnotes(instru, static) + instru = Instrument(size=100, lock='N4-12', fplan='static') + instru.simulate() + assert _consistent_locking_beatnotes(instru, static) + +def test_static_fplan_valid_with_all_lock_configs(): + """Check that 'static' fplan is valid for all lock configs. + + We check that all beatnotes are between 5 and 25 MHz (and negative) + for all locking configurations, using the default static set of locking + beatnotes. + """ + for primary in Instrument.MOSAS: + for topology in Instrument.LOCK_TOPOLOGIES: + instru = Instrument(size=100, lock=f'{topology}-{primary}', fplan='static') + instru.disable_all_noises() + instru.simulate() + + for mosa in instru.MOSAS: + assert np.all(5E6 < np.abs(instru.isi_carriers[mosa]) < 25E6) + assert np.all(5E6 < np.abs(instru.isi_usbs[mosa]) < 25E6) + assert np.all(5E6 < np.abs(instru.rfi_carriers[mosa]) < 25E6) + assert np.all(5E6 < np.abs(instru.rfi_usbs[mosa]) < 25E6) + +def test_constant_equal_fplan(): + """Test a user-defined constant equal fplan.""" + + # Check fplan initialization + instru = Instrument(size=100, fplan=42.0) + for mosa in instru.MOSAS: + assert instru.fplan[mosa] == 42.0 + +def test_constant_unequal_fplan(): + """Test a user-defined constant unequal fplan.""" + + # Check fplan initialization + fplan = { + '12': 8.1E6, '23': 9.2E6, '31': 10.3E6, + '13': 1.4E6, '32': -11.6E6, '21': -9.5E6, + } + instru = Instrument(size=100, lock='N1-12', fplan=fplan) + for mosa in instru.MOSAS: + assert instru.fplan[mosa] == fplan[mosa] + + # Check locking beatnotes + instru = Instrument(size=100, lock='N1-12', fplan=fplan) + instru.simulate() + assert _consistent_locking_beatnotes(instru, fplan) + instru = Instrument(size=100, lock='N1-21', fplan=fplan) + instru.simulate() + assert _consistent_locking_beatnotes(instru, fplan) + instru = Instrument(size=100, lock='N4-12', fplan=fplan) + instru.simulate() + assert _consistent_locking_beatnotes(instru, fplan) + +def test_varying_equal_fplan(): + """Test a user-defined time-varying equal fplan.""" + + # Check fplan initialization + fplan = np.random.uniform(5E6, 25E6, size=400) + instru = Instrument(size=100, fplan=fplan) + for mosa in instru.MOSAS: + assert np.all(instru.fplan[mosa] == fplan) + + # Check locking beatnotes + fplan = {mosa: fplan for mosa in Instrument.MOSAS} + instru = Instrument(size=100, lock='N1-12', fplan=fplan) + instru.simulate() + assert _consistent_locking_beatnotes(instru, fplan) + instru = Instrument(size=100, lock='N1-21', fplan=fplan) + instru.simulate() + assert _consistent_locking_beatnotes(instru, fplan) + instru = Instrument(size=100, lock='N4-12', fplan=fplan) + instru.simulate() + assert _consistent_locking_beatnotes(instru, fplan) + +def test_varying_unequal_fplan(): + """Test a user-defined time-varying unequal fplan.""" + + # Check fplan initialization + fplan = { + mosa: np.random.uniform(5E6, 25E6, size=400) + for mosa in Instrument.MOSAS + } + instru = Instrument(size=100, fplan=fplan) + for mosa in instru.MOSAS: + assert np.all(instru.fplan[mosa] == fplan[mosa]) + + # Check locking beatnotes + instru = Instrument(size=100, lock='N1-12', fplan=fplan) + instru.simulate() + assert _consistent_locking_beatnotes(instru, fplan) + instru = Instrument(size=100, lock='N1-21', fplan=fplan) + instru.simulate() + assert _consistent_locking_beatnotes(instru, fplan) + instru = Instrument(size=100, lock='N4-12', fplan=fplan) + instru.simulate() + assert _consistent_locking_beatnotes(instru, fplan) + +def test_keplerian_fplan_1_1(): + """Test standard Keplerian fplan file v1.1.""" + + # Check fplan file with standard lock configs + for primary in Instrument.MOSAS: + for topology in Instrument.LOCK_TOPOLOGIES: + instru = Instrument(size=100, lock=f'{topology}-{primary}', fplan='tests/keplerian-fplan-1-1.h5') + + # Should raise an error for non-standard lock config + with pytest.raises(ValueError): + Instrument(size=100, lock='six', fplan='tests/keplerian-fplan-1-1.h5') + with pytest.raises(ValueError): + lock = {'12': 'cavity', '13': 'cavity', '21': 'distant', '31': 'distant', '23': 'adjacent', '32': 'adjacent'} + Instrument(size=100, lock=lock, fplan='tests/keplerian-fplan-1-1.h5') + + # Check locking beatnotes + instru = Instrument(size=100, lock='N1-12', fplan='tests/keplerian-fplan-1-1.h5') + instru.simulate() + assert _consistent_locking_beatnotes(instru) + instru = Instrument(size=100, lock='N1-21', fplan='tests/keplerian-fplan-1-1.h5') + instru.simulate() + assert _consistent_locking_beatnotes(instru) + instru = Instrument(size=100, lock='N4-12', fplan='tests/keplerian-fplan-1-1.h5') + instru.simulate() + assert _consistent_locking_beatnotes(instru) + +def test_esa_trailing_fplan_1_1(): + """Test standard ESA trailing fplan file v1.1.""" + + # Check fplan file with standard lock configs + for primary in Instrument.MOSAS: + for topology in Instrument.LOCK_TOPOLOGIES: + instru = Instrument(size=100, lock=f'{topology}-{primary}', fplan='tests/esa-trailing-fplan-1-1.h5') + + # Should raise an error for non-standard lock config + with pytest.raises(ValueError): + Instrument(size=100, lock='six', fplan='tests/esa-trailing-fplan-1-1.h5') + with pytest.raises(ValueError): + lock = {'12': 'cavity', '13': 'cavity', '21': 'distant', '31': 'distant', '23': 'adjacent', '32': 'adjacent'} + Instrument(size=100, lock=lock, fplan='tests/esa-trailing-fplan-1-1.h5') + + # Check locking beatnotes + instru = Instrument(size=100, lock='N1-12', fplan='tests/esa-trailing-fplan-1-1.h5') + instru.simulate() + assert _consistent_locking_beatnotes(instru) + instru = Instrument(size=100, lock='N1-21', fplan='tests/esa-trailing-fplan-1-1.h5') + instru.simulate() + assert _consistent_locking_beatnotes(instru) + instru = Instrument(size=100, lock='N4-12', fplan='tests/esa-trailing-fplan-1-1.h5') + instru.simulate() + assert _consistent_locking_beatnotes(instru) diff --git a/tests/test_instrument.py b/tests/test_instrument.py old mode 100644 new mode 100755