diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c33a8df2d35546de3f49d7c01bc6a7fe549a70a4..13e0e1a81c18f75aaf3592d1764ce81d28ac18f3 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -15,10 +15,10 @@ test: - export PYTHONPATH=inspiral_range - export MPLBACKEND=agg - python3 -m gwinc aLIGO -s aLIGO.png - - python3 -m gwinc A+ -s A+.png + - python3 -m gwinc Aplus -s Aplus.png - python3 -m gwinc Voyager -s Voyager.png - python3 -m gwinc.test aLIGO -t 10e-6 -k Seismic -k "Substrate Thermo-Elastic" -p -s aLIGO_test.png - - python3 -m gwinc.test -t 100e-6 -k Seismic -k "Substrate Thermo-Elastic" A+ -p -s A+_test.png + - python3 -m gwinc.test -t 100e-6 -k Seismic -k "Substrate Thermo-Elastic" Aplus -p -s Aplus_test.png after_script: - rm gitID.txt cache: @@ -29,10 +29,10 @@ test: expire_in: 4w paths: - aLIGO.png - - A+.png + - Aplus.png - Voyager.png - aLIGO_test.png - - A+_test.png + - Aplus_test.png pages: stage: deploy @@ -41,10 +41,10 @@ pages: script: - mkdir public - mv aLIGO.png public/ - - mv A+.png public/ + - mv Aplus.png public/ - mv Voyager.png public/ - mv aLIGO_test.png public/ || true - - mv A+_test.png public/ || true + - mv Aplus_test.png public/ || true - apt-get install -y -qq python3-pip python3-dev make - pip3 install sphinx sphinx-rtd-theme - cd docs diff --git a/HDF5_SCHEMATA b/HDF5_SCHEMATA new file mode 100644 index 0000000000000000000000000000000000000000..b2ef34183ef687f19dc6bcb6c7f535c18cf3f531 --- /dev/null +++ b/HDF5_SCHEMATA @@ -0,0 +1,104 @@ +# HDF5 Schema for GWINC noise trace storage + + +This file describes a schemata for HDF5 storage of noise trace data +and plot styling GWINC noise budgets. + +HDF5 is a heirarchical, structured data storage format [0]. Content +is organized into a heirarchical folder-like structure, with two +types of named objects: + + * groups: holder of other objects (like folders) + * datasets: holder of data arrays (like files) + +Objects can also have attributes as (almost) arbitrary key:value +pairs. + +Bindings are available for most platforms including Python [1] and +Matlab. + +[0] https://en.wikipedia.org/wiki/Hierarchical_Data_Format +[1] http://www.h5py.org/ + + +## version history + +v1 +- first versioned schema release + + +## schema + +The following describes the noise budget schema. Specific strings are +enclosed in single quotes (''), and variables are described in +brackets (<>). Group objects are indicated by a closing '/' +separator, data set are indicated by a closing ':' followed by a +specification of their length and type (e.g. "(N),float"), and +attributes are specified in the .attrs[] dictionary format. Optional +elements are enclosed in parentheses. + +A single trace is a length N array (with optional plot style specified +in attributes: +``` +/<trace>: (N),float +(/<trace>.attrs['label'] = <label>) +(/<trace>.attrs['color] = <color>) +... +``` + +A budget item, i.e. a collection of noises is structured as follows: +``` +/<budget>/ + /'Total': (N),float + /<trace_0>: (N),float + (/<trace_1>: (N),float) +``` +<!-- ``` --> +<!-- /'noises'/ --> +<!-- /*<noise_0>: (N),float --> +<!-- ... --> +<!-- /\*'references'/ --> +<!-- /*<ref_0>: (N),float --> +<!-- ... --> +<!-- ``` --> + + +## Top-level Budget + +The following two root attributes expected: a string describing the schema, +and an int schema version number: +``` +/.attrs['SCHEMA'] = 'GWINC Noise Budget' +/.attrs['SCHEMA_VERSION'] = 1 +``` + +Top-level attributes are generally used for specifying overall plot styling, but the +following root attributes are typically defined: +``` +/.attrs['title'] = <experiment description string (e.g. 'H1 Strain Budget')> +/.attrs['date'] = <ISO-formatted string (e.g. '2015-10-24T20:30:00.000000Z')> +``` + +The budget frequency array is defined in a 'Freq' dataset: +``` +/'Freq': (N), float +``` + +The budget traces are defined a traces group. The overall structure +looks something like this: +``` +/.attrs['SCHEMA'] = 'GWINC Noise Budget' +/.attrs['SCHEMA_VERSION'] = 1 +/.attrs['title'] = <experiment description string (e.g. 'H1 Strain Budget')> +/.attrs['date'] = <ISO-formatted string (e.g. '2015-10-24T20:30:00.000000Z')> +/'Freq': (N), float +/traces/ + /'Total': (N),float + /<noise_0>: (N),float + /<noise_1>: (N),float + /<noise_2>/ + /'Total': (N),float + /<noise_3>: (N),float + /<noise_4>: (N),float + ... +``` diff --git a/README.md b/README.md index f983a18fec3cbf381b10b54e98e220a158d53b87..adf5a057689297196c1a71085532c6aa65ee69bf 100644 --- a/README.md +++ b/README.md @@ -16,14 +16,11 @@ description is loaded, the noise budget can be calculated and plotted: >>> import gwinc >>> import numpy as np >>> freq = np.logspace(1, 3, 1000) ->>> ifo = gwinc.load_ifo('aLIGO') +>>> Budget, ifo, freq_, plot_style = gwinc.load_ifo('aLIGO') >>> ifo = gwinc.precompIFO(freq, ifo) ->>> noises = gwinc.noise_calc(freq, ifo) ->>> gwinc.plot_noise(noises) -``` -Or the `gwinc` convenience function can be used to handle it all: -``` ->>> score, data, ifo = gwinc.gwinc(freq, ifo, plot=True) +>>> traces = Budget(freq, ifo=ifo).calc_trace() +>>> fig = gwinc.plot_noise(freq, traces, **plot_style) +>>> fig.show() ``` @@ -32,39 +29,7 @@ Or the `gwinc` convenience function can be used to handle it all: You can make gwinc plots directly from the command line by executing the package directly: ```shell -$ python3 -m gwinc -h -usage: gwinc [-h] [--flo FLO] [--fhi FHI] [--npoints NPOINTS] [--title TITLE] - [--matlab] [--fom FOM] [--dump | --save SAVE | --interactive] - [IFO] - -Plot GWINC noise budget for specified IFO. - -If the inspiral_range package is installed, various figures of merit -can be calculated for the resultant spectrum with the --fom argument, -e.g.: - - gwinc --fom horizon ... - gwinc --fom range:m1=20,m2=20 ... - -See documentation for inspiral_range package for details. - -positional arguments: - IFO IFO name or description file path (.yaml or .mat) - -optional arguments: - -h, --help show this help message and exit - --flo FLO, -fl FLO lower frequency bound in Hz [5] - --fhi FHI, --fh FHI upper frequency bound in Hz [6000] - --npoints NPOINTS, -n NPOINTS - number of frequency points [3000] - --title TITLE, -t TITLE - plot title - --matlab, -m use MATLAB gwinc engine to calculate noises - --fom FOM calculate inspiral range for resultant spectrum - ('func[:param=val,param=val]') - --dump, -d print IFO parameters to stdout and exit - --save SAVE, -s SAVE save figure to file - --interactive, -i open interactive shell when plotting +$ python3 -m gwinc aLIGO ``` @@ -80,6 +45,51 @@ various detectors: * [Voyager.yaml](https://git.ligo.org/gwinc/pygwinc/blob/master/gwinc/ifo/Voyager.yaml) + +## noise budgets + + +GWINC provides an `nb` package for defining arbitrary noise budgets: + +```python +import numpy as np +from gwinc import nb +from gwinc import noise + +class ExcessGas(nb.Noise): + """Excess gas""" + style = dict( + label='Excess Gas', + color='#ad900d', + linestyle='--', + ) + + def calc(self): + return noise.residualgas.gas(self.freq, self.ifo) + +class MeasuredNoise(nb.Noise): + """My measured noise""" + style = dict( + label='Measured Noise, + color='#838209, + linestyle='-', + ) + + def load(self): + psd, freq = np.loadtxt('/path/to/measured/psd.txt') + self.data = self.interpolate(f, psd) + + def calc(self): + return self.data + +class MyBudget(nb.Budget): + noises = [ + ExcessGas, + MeasuredNoise, + ] +``` + + ## comparison with MATLAB gwinc `pygwinc` includes the ability use MATLAB gwinc directly via the diff --git a/gwinc/__init__.py b/gwinc/__init__.py index abf5763fd5323f41731a5125b9b33246d2ee1b4e..640ed7365f593773a5f24691b2b2f909c42c943c 100644 --- a/gwinc/__init__.py +++ b/gwinc/__init__.py @@ -1,6 +1,5 @@ -from .struct import load_struct from .ifo import available_ifos, load_ifo +from .struct import load_struct from .precomp import precompIFO -from .gwinc import noise_calc -from .gwinc import gwinc from .plot import plot_noise +from .io import load_hdf5, save_hdf5 diff --git a/gwinc/__main__.py b/gwinc/__main__.py index 0005c5a72f9d4390fec4af9071408284b33ba24e..b20919da799a681221cac83e851e2ac67b4aedc0 100644 --- a/gwinc/__main__.py +++ b/gwinc/__main__.py @@ -11,9 +11,8 @@ logging.basicConfig(format='%(message)s', from .ifo import available_ifos, load_ifo from .precomp import precompIFO -from . import gwinc from . import plot_noise -from . import util +from . import io ################################################## @@ -30,10 +29,11 @@ By default a GWINC noise budget of the specified IFO will calculated, and plotted with an interactive plotter. If the --save option is specified the plot will be saved directly to a file (without display) (various formats are supported, indicated by file extension). If the -requested extension is "hdf5" then the noise traces and IFO parameters -will be saved to an HDF5 file (without plotting). The input file -(IFO) can be an HDF5 file saved from a previous call, in which all -noise traces and IFO parameters will be loaded from that file. +requested extension is 'hdf5' or 'h5' then the noise traces and IFO +parameters will be saved to an HDF5 file (without plotting). The +input file (IFO) can be an HDF5 file saved from a previous call, in +which case all noise traces and IFO parameters will be loaded from +that file. If the inspiral_range package is installed, various figures of merit can be calculated for the resultant spectrum with the --fom argument, @@ -51,9 +51,10 @@ FLO = 5 FHI = 6000 NPOINTS = 3000 -parser = argparse.ArgumentParser(prog='gwinc', - description=description, - formatter_class=argparse.RawDescriptionHelpFormatter) +parser = argparse.ArgumentParser( + prog='gwinc', + description=description, + formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument('--flo', '-fl', default=FLO, type=float, help="lower frequency bound in Hz [{}]".format(FLO)) parser.add_argument('--fhi', '--fh', default=FHI, type=float, @@ -68,7 +69,7 @@ group = parser.add_mutually_exclusive_group() group.add_argument('--interactive', '-i', action='store_true', help="interactive plot with interactive shell") group.add_argument('--save', '-s', - help="save noise (hdf5) or figure (pdf/png/svg) to file") + help="save budget traces (.hdf5/.h5) or plot (.pdf/.png/.svg) to file") group.add_argument('--yaml', '-y', action='store_true', help="print IFO as yaml to stdout and exit") group.add_argument('--text', '-x', action='store_true', @@ -77,8 +78,8 @@ group.add_argument('--diff', '-d', metavar='IFO', help="show differences table between another IFO description") group.add_argument('--no-plot', '-np', action='store_false', dest='plot', help="supress plotting") -parser.add_argument('IFO', default=IFO, - help="IFO name or description file path (.yaml, .mat, .m), or full HDF5 data file") +parser.add_argument('IFO', + help="IFO name, description file path (.yaml, .mat, .m), budget module (.py), or HDF5 data file (.hdf5, .h5)") def main(): @@ -89,26 +90,34 @@ def main(): ########## # initial arg processing - if os.path.splitext(args.IFO)[1] in ['.hdf5', '.h5']: - title, ifo, noises = util.load_hdf5(args.IFO) + if os.path.splitext(os.path.basename(args.IFO))[1] in ['.hdf5', '.h5']: + Budget = None + freq, traces, attrs = io.load_hdf5(args.IFO) + ifo = getattr(attrs, 'IFO', None) + plot_style = attrs else: - ifo = load_ifo(args.IFO) - noises = None - if args.title: - title = args.title - else: - title = '{} GWINC Noise Budget'.format(args.IFO) + Budget, ifo, freq, plot_style = load_ifo(args.IFO) + # FIXME: this should be done only if specified, to allow for + # using any FREQ specified in budget module by default + freq = np.logspace(np.log10(args.flo), np.log10(args.fhi), args.npoints) + traces = None if args.yaml: + if not ifo: + parser.exit(2, "no IFO structure available.") print(ifo.to_yaml(), end='') return if args.text: + if not ifo: + parser.exit(2, "no IFO structure available.") print(ifo.to_txt(), end='') return if args.diff: + if not ifo: + parser.exit(2, "no IFO structure available.") fmt = '{:30} {:>20} {:>20}' - ifoo = load_ifo(args.diff) + Budget, ifoo, freq, plot_style = load_ifo(args.diff) diffs = ifo.diff(ifoo) if diffs: print(fmt.format('', args.IFO, args.diff)) @@ -118,6 +127,14 @@ def main(): ov = repr(p[2]) print(fmt.format(k, v, ov)) return + + if args.title: + plot_style['title'] = args.title + elif Budget: + plot_style['title'] = "GWINC Noise Budget: {}".format(Budget.name) + else: + plot_style['title'] = "GWINC Noise Budget: {}".format(args.IFO) + if args.plot: if args.save: # FIXME: this silliness seems to be the only way to have @@ -156,18 +173,20 @@ def main(): ########## # main calculations - if noises: - freq = noises['Freq'] - - else: - logging.info("calculating noises...") - freq = np.logspace(np.log10(args.flo), np.log10(args.fhi), args.npoints) - score, noises, ifo = gwinc(freq, ifo) - - logging.info('recycling factor: {: >0.3f}'.format(ifo.gwinc.prfactor)) - logging.info('BS power: {: >0.3f} W'.format(ifo.gwinc.pbs)) - logging.info('arm finesse: {: >0.3f}'.format(ifo.gwinc.finesse)) - logging.info('arm power: {: >0.3f} kW'.format(ifo.gwinc.parm/1000)) + if not traces: + if ifo: + logging.info("precomputing ifo...") + ifo = precompIFO(freq, ifo) + logging.info("calculating budget...") + budget = Budget(freq=freq, ifo=ifo) + budget.load() + budget.update() + traces = budget.calc_trace() + + # logging.info('recycling factor: {: >0.3f}'.format(ifo.gwinc.prfactor)) + # logging.info('BS power: {: >0.3f} W'.format(ifo.gwinc.pbs)) + # logging.info('arm finesse: {: >0.3f}'.format(ifo.gwinc.finesse)) + # logging.info('arm power: {: >0.3f} kW'.format(ifo.gwinc.parm/1000)) if args.fom: logging.info("calculating inspiral {}...".format(range_func)) @@ -181,30 +200,35 @@ def main(): m2=H.params['m2'], fom=fom, ) - title += '\n{}'.format(fom_title) + plot_style['title'] += '\n{}'.format(fom_title) ########## # output # save noise traces to HDF5 file if args.save and os.path.splitext(args.save)[1] in ['.hdf5', '.h5']: - util.save_hdf5( - title, - ifo, - noises, - args.save, + logging.info("saving budget traces {}...".format(args.save)) + if ifo: + plot_style['IFO'] = ifo.to_yaml() + io.save_hdf5( + path=args.save, + freq=freq, + traces=traces, + **plot_style ) # interactive shell plotting elif args.interactive: ipshell = InteractiveShellEmbed( - user_ns={'freq': freq, - 'noises': noises, - 'ifo': ifo, - 'plot_noise': plot_noise, + user_ns={ + 'freq': freq, + 'traces': traces, + 'ifo': ifo, + 'plot_style': plot_style, + 'plot_noise': plot_noise, }, banner1=''' -PYGWINC interactive plotter +GWINC interactive plotter You may interact with plot using "plt." methods, e.g.: @@ -212,20 +236,21 @@ You may interact with plot using "plt." methods, e.g.: >>> plt.savefig("foo.pdf") ''') ipshell.enable_pylab() - ipshell.run_code("plot_noise(noises)") - ipshell.run_code("plt.title('{}')".format(title)) + ipshell.run_code("plot_noise(freq, traces, **plot_style)") + ipshell.run_code("plt.title('{}')".format(plot_style['title'])) ipshell() # standard plotting elif args.plot: - logging.info("plotting...") + logging.info("plotting noises...") fig = plt.figure() ax = fig.add_subplot(1, 1, 1) plot_noise( - noises, + freq, + traces, ax=ax, + **plot_style ) - ax.set_title(title) fig.tight_layout() if args.save: fig.savefig( diff --git a/gwinc/ifo/Aplus/__init__.py b/gwinc/ifo/Aplus/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..81bb8b17cf0bbb4717ce34505d13ec5b248bc638 --- /dev/null +++ b/gwinc/ifo/Aplus/__init__.py @@ -0,0 +1,18 @@ +from gwinc.ifo.noises import * + + +class Aplus(nb.Budget): + + name = 'A+' + + noises = [ + QuantumVacuum, + Seismic, + Newtonian, + SuspensionThermal, + CoatingBrownian, + CoatingThermoOptic, + SubstrateBrownian, + SubstrateThermoElastic, + ExcessGas, + ] diff --git a/gwinc/ifo/A+.yaml b/gwinc/ifo/Aplus/ifo.yaml similarity index 100% rename from gwinc/ifo/A+.yaml rename to gwinc/ifo/Aplus/ifo.yaml diff --git a/gwinc/ifo/CE/__init__.py b/gwinc/ifo/CE/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..ca22f9e70a78ccac2cbafbe4edb3903fb0ba3487 --- /dev/null +++ b/gwinc/ifo/CE/__init__.py @@ -0,0 +1,20 @@ +from gwinc.ifo.noises import * + + +class CE(nb.Budget): + + name = 'Cosmic Explorer' + + noises = [ + QuantumVacuum, + Seismic, + Newtonian, + SuspensionThermal, + CoatingBrownian, + CoatingThermoOptic, + ITMThermoRefractive, + ITMCarrierDensity, + SubstrateBrownian, + SubstrateThermoElastic, + ExcessGas, + ] diff --git a/gwinc/ifo/CE2.yaml b/gwinc/ifo/CE/ifo.yaml similarity index 100% rename from gwinc/ifo/CE2.yaml rename to gwinc/ifo/CE/ifo.yaml diff --git a/gwinc/ifo/Voyager/__init__.py b/gwinc/ifo/Voyager/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..0839e3c2113765e734f5d1620dacf81832701d80 --- /dev/null +++ b/gwinc/ifo/Voyager/__init__.py @@ -0,0 +1,20 @@ +from gwinc.ifo.noises import * + + +class Voyager(nb.Budget): + + name = 'Voyager' + + noises = [ + QuantumVacuum, + Seismic, + Newtonian, + SuspensionThermal, + CoatingBrownian, + CoatingThermoOptic, + ITMThermoRefractive, + ITMCarrierDensity, + SubstrateBrownian, + SubstrateThermoElastic, + ExcessGas, + ] diff --git a/gwinc/ifo/Voyager.yaml b/gwinc/ifo/Voyager/ifo.yaml similarity index 100% rename from gwinc/ifo/Voyager.yaml rename to gwinc/ifo/Voyager/ifo.yaml diff --git a/gwinc/ifo/__init__.py b/gwinc/ifo/__init__.py index 7ea1c794bf7ae0d296e7ab38edc89d930fe6b6b0..a51ff4a2c365f6edc51021dfd932534666c1081a 100644 --- a/gwinc/ifo/__init__.py +++ b/gwinc/ifo/__init__.py @@ -1,48 +1,76 @@ import os -import fnmatch +import logging -from ..struct import Struct -from ..gwinc_matlab import Matlab +from ..struct import load_struct, STRUCT_EXT +from ..util import load_module + + +PLOT_STYLE = dict( + ylabel=u"Strain [1/\u221AHz]", +) def available_ifos(): - """List available included IFO files""" + """List available included pre-defined IFOs + + """ ifos = [] - for f in os.listdir(os.path.dirname(__file__)): - if fnmatch.fnmatch(f, '*.yaml'): - ifos.append(f.split('.')[0]) - return ifos + root = os.path.dirname(__file__) + for f in os.listdir(root): + if os.path.isdir(os.path.join(root, f)) and f[0] != '_': + ifos.append(f) + return sorted(ifos) def load_ifo(name_or_path): - """Load IFO by name or from file. + """Load GWINC IFO Budget by name or from file. - Named IFOs should correspond to the basename of .yaml IFO - definition files included with pygwinc (see available_ifos() - above). + Named IFOs should correspond to one of the IFOs available in the + gwinc package (see gwinc.available_ifos()). If a path is provided + it should either be a budget package (directory) or module (ending + in .py), or an IFO struct (see gwinc.load_struct()). In the + latter case the base aLIGO budget definition will be used. - When specifying by path files may be either .yaml, .mat or .m. - For .m files, the file is expected to include either an object or - function that corresponds to the basename of the file. The MATLAB - engine will be invoked to execute the .m code and extract the - resultant IFO data. + Returns primary Budget class, ifo structure, frequency array, and + plot style dictionary, with the last three being None if they are + not defined in the budget. """ + ifo = None + if os.path.exists(name_or_path): - path = name_or_path - else: - path = os.path.join(os.path.dirname(__file__), - name_or_path+'.yaml') + path = name_or_path.rstrip('/') + bname, ext = os.path.splitext(os.path.basename(path)) - (root, ext) = os.path.splitext(path) + if ext in STRUCT_EXT: + logging.info("loading struct {}...".format(path)) + ifo = load_struct(path) + bname = 'aLIGO' + modname = 'gwinc.ifo.aLIGO' + logging.info("loading budget {}...".format(modname)) - if ext == '.m': - matlab = Matlab() - matlab.addpath(os.path.dirname(path)) - func_name = os.path.basename(root) - matlab.eval("ifo = {};".format(func_name), nargout=0) - ifo = matlab.extract('ifo') - return Struct.from_matstruct(ifo) + else: + modname = path + logging.info("loading module path {}...".format(modname)) else: - return Struct.from_file(path) + if name_or_path not in available_ifos(): + raise RuntimeError("Unknonw IFO '{}' (available IFOs: {}).".format( + name_or_path, + available_ifos(), + )) + bname = name_or_path + modname = 'gwinc.ifo.'+name_or_path + logging.info("loading module {}...".format(modname)) + + mod, modpath = load_module(modname) + + Budget = getattr(mod, bname) + ifo = getattr(mod, 'IFO', ifo) + ifopath = os.path.join(modpath, 'ifo.yaml') + if not ifo and ifopath: + ifo = load_struct(ifopath) + freq = getattr(mod, 'FREQ', None) + plot_style = getattr(mod, 'PLOT_STYLE', PLOT_STYLE) + + return Budget, ifo, freq, plot_style diff --git a/gwinc/ifo/aLIGO/__init__.py b/gwinc/ifo/aLIGO/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..1f1bf4efffb7c6c0c64b3b1ad14c86b5135733a5 --- /dev/null +++ b/gwinc/ifo/aLIGO/__init__.py @@ -0,0 +1,18 @@ +from gwinc.ifo.noises import * + + +class aLIGO(nb.Budget): + + name = 'Advanced LIGO' + + noises = [ + QuantumVacuum, + Seismic, + Newtonian, + SuspensionThermal, + CoatingBrownian, + CoatingThermoOptic, + SubstrateBrownian, + SubstrateThermoElastic, + ExcessGas, + ] diff --git a/gwinc/ifo/aLIGO.yaml b/gwinc/ifo/aLIGO/ifo.yaml similarity index 100% rename from gwinc/ifo/aLIGO.yaml rename to gwinc/ifo/aLIGO/ifo.yaml diff --git a/gwinc/ifo/noises.py b/gwinc/ifo/noises.py new file mode 100644 index 0000000000000000000000000000000000000000..2cea3c4c019af97523c36980a5e45e9a15301cbe --- /dev/null +++ b/gwinc/ifo/noises.py @@ -0,0 +1,151 @@ +from .. import nb +from .. import noise + + +class QuantumVacuum(nb.Noise): + """Quantum Vacuum + + """ + style = dict( + label='Quantum', + color='#ad03de', + ) + + def calc(self): + return noise.quantum.shotrad(self.freq, self.ifo) + + +class Seismic(nb.Noise): + """Seismic + + """ + style = dict( + label='Seismic', + color='#653700', + ) + + def calc(self): + return noise.seismic.seismic(self.freq, self.ifo) + + +class Newtonian(nb.Noise): + """Newtonian Gravity + + """ + style = dict( + label='Newtonian Gravity', + color='#15b01a', + ) + + def calc(self): + return noise.newtonian.gravg(self.freq, self.ifo) + + +class SuspensionThermal(nb.Noise): + """Suspension Thermal + + """ + style = dict( + label='Suspension Thermal', + color='#0d75f8', + ) + + def calc(self): + return noise.suspensionthermal.susptherm(self.freq, self.ifo) + + +class CoatingBrownian(nb.Noise): + """Coating Brownian + + """ + style = dict( + label='Coating Brownian', + color='#fe0002', + ) + + def calc(self): + return noise.coatingthermal.coatbrownian(self.freq, self.ifo) + + +class CoatingThermoOptic(nb.Noise): + """Coating Thermo-Optic + + """ + style = dict( + label='Coating Thermo-Optic', + color='#02ccfe', + linestyle='--', + ) + + def calc(self): + return noise.coatingthermal.thermooptic(self.freq, self.ifo) + + +class ITMThermoRefractive(nb.Noise): + """ITM Thermo-Refractive + + """ + style = dict( + label='ITM Thermo-Refractive', + color='#448ee4', + linestyle='--', + ) + + def calc(self): + return noise.substratethermal.thermorefractiveITM(self.freq, self.ifo) + + +class ITMCarrierDensity(nb.Noise): + """ITM Carrier Density + + """ + style = dict( + label='ITM Carrier Density', + color='#929591', + linestyle='--', + ) + + def calc(self): + return noise.substratethermal.carrierdensity(self.freq, self.ifo) + + +class SubstrateBrownian(nb.Noise): + """Substrate Brownian + + """ + style = dict( + label='Substrate Brownian', + color='#fb7d07', + linestyle='--', + ) + + def calc(self): + return noise.substratethermal.subbrownian(self.freq, self.ifo) + + +class SubstrateThermoElastic(nb.Noise): + """Substrate Thermo-Elastic + + """ + style = dict( + label='Substrate Thermo-Elastic', + color='#f5bf03', + linestyle='--', + ) + + def calc(self): + return noise.substratethermal.subtherm(self.freq, self.ifo) + + +class ExcessGas(nb.Noise): + """Excess Gas + + """ + style = dict( + label='Excess Gas', + color='#ad900d', + linestyle='--', + ) + + def calc(self): + return noise.residualgas.gas(self.freq, self.ifo) diff --git a/gwinc/io.py b/gwinc/io.py new file mode 100644 index 0000000000000000000000000000000000000000..5184082732a6263d559e0483bf6056b6ea5a5bad --- /dev/null +++ b/gwinc/io.py @@ -0,0 +1,60 @@ +import datetime +import h5py + + +SCHEMA = 'GWINC noise budget' +SCHEMA_VERSION = 1 + + +def _write_trace_recursive(grp, traces): + for name, trace in traces.items(): + if isinstance(trace, dict): + tgrp = grp.create_group(name) + _write_trace_recursive(tgrp, trace) + else: + data, style = trace + dset = grp.create_dataset(name, data=data) + for key, val in style.items(): + dset.attrs[key] = val + + +def save_hdf5(path, freq, traces, **kwargs): + """Save GWINC traces dict to HDF5 file. + + See HDF5_SCHEMA. + + """ + with h5py.File(path, 'w') as f: + f.attrs['SCHEMA'] = SCHEMA + f.attrs['SCHEMA_VERSION'] = SCHEMA_VERSION + # FIXME: add budget code hash or something + f.attrs['date'] = datetime.datetime.now().isoformat() + for key, val in kwargs.items(): + f.attrs[key] = val + f.create_dataset('Freq', data=freq) + tgrp = f.create_group('traces') + _write_trace_recursive(tgrp, traces) + + +def _read_trace_recursive(element): + trace = {} + for name, item in element.items(): + if isinstance(item, h5py.Group): + trace[name] = _read_trace_recursive(item) + else: + trace[name] = item.value, dict(item.attrs.items()) + return trace + + +def load_hdf5(path): + """Load traces from HDF5 file. + + Returns a recursive traces dictionary. See HDF5_SCHEMA. + + """ + with h5py.File(path, 'r') as f: + # FIXME: check SCHEMA name/version + freq = f['Freq'].value + traces = _read_trace_recursive(f['/traces']) + attrs = dict(f.attrs.items()) + return freq, traces, attrs diff --git a/gwinc/nb.py b/gwinc/nb.py new file mode 100644 index 0000000000000000000000000000000000000000..9434ef13388a5e49a6543c002cb1d7d46120ede4 --- /dev/null +++ b/gwinc/nb.py @@ -0,0 +1,420 @@ +import os +import logging +import itertools +import importlib +import importlib.util +import collections +import numpy as np +import scipy.interpolate + + +def quadsum(data): + """Calculate quadrature sum of list of data arrays. + + Provided data are assumed to be power-referred, so this is a + simple point-by-point sum. + + NaNs in sum elements do not contribute to sum. + + """ + return np.nansum(data, 0) + + + +class BudgetItem: + """GWINC BudgetItem class + + """ + def load(self): + """Overload method for initial loading of static data. + + """ + return None + + def update(self, **kwargs): + """Overload method for updating data needed to calculate final PSD. + + By default any keyword arguments provided are written directly + as attribute variables (as with __init__). + + """ + for key, val in kwargs.items(): + setattr(self, key, val) + + def calc(self): + """Overload method for calculation of final PSD. + + Should return an array of power-referenced values evaluated at + all evaluation frequencies (self.freq). + + """ + return None + + ########## + + def __init__(self, freq, **kwargs): + """Initialize budget item. + + Primary argument is the evaluation frequency array. Any + keyword arguments provided are simple written as attribute + variables in the initialized object. + + """ + self.__freq = freq + for key, val in kwargs.items(): + setattr(self, key, val) + + @property + def name(self): + """"Name of this BudgetItem class.""" + return self.__class__.__name__ + + def __str__(self): + # FIXME: provide info on internal state (load/update/calc/etc.) + return '<{} {}>'.format( + ' '.join([c.__name__ for c in self.__class__.__bases__]), + self.name, + ) + + @property + def freq(self): + """Evaluation frequency array supplied at initialization.""" + return self.__freq + + def interpolate(self, freq, data): + """Interpolate data to the evaluation frequencies. + + """ + func = scipy.interpolate.interp1d( + freq, data, + kind='nearest', + copy=False, + assume_sorted=True, + bounds_error=False, + fill_value=np.nan, + ) + return func(self.freq) + + +class Calibration(BudgetItem): + """GWINC Calibration class + + BudgetItem that represents a calibration transfer function for a + Noise. The calc() method should return a transfer function + amplitude array evaluated at the evaluation frequencies supplied + at initialization and available in the `freq` array attribute + (self.freq). + + """ + def __call__(self, data): + """Calibrate input data. + + Returns calibrated version of input data array, + e.g. point-by-point product of data and calibration arrays. + + """ + cal = self.calc() + assert data.shape == cal.shape, \ + "data shape does not match calibration ({} != {})".format(data.shape, cal.shape) + return data * cal + + +class Noise(BudgetItem): + """GWINC Noise class + + BudgetItem that represents a PSD noise calculation. The calc() + method should return the noise PSD spectrum array evaluated at the + evaluation frequencies supplied at initialization and available in + the `freq` array attribute (self.freq). + + """ + + style = {} + """Trace plot style dictionary""" + + def calc_trace(self, calibration=None, calc=True): + """Returns noise (PSD, style) tuple. + + If `calibration` is not None it is assumed to be a + len(self.freq) array that will be multiplied to the output + PSD. + + If calc=False, the noise will not be calculated and the PSD + will be None. This is useful for just getting style the + style. + + """ + if calc: + data = self.calc() + if calibration is not None: + data *= calibration + else: + data = None + return data, self.style + + +class Budget(Noise): + """GWINC Budget class + + This is a Noise that represents the budget of multiple sub noises. + + The `noises` attribute of this class should be list constituent + Noise classes. Each element can be either a single Noise class, + or a tuple of (Noise, Calibration) classes, e.g.: + + noises = [ + Thermal, + (Shot, Sensing), + ] + + When this object is initialized, all sub noises and calibrations + are initialized. Pre-defined load() and update() methods call the + load() and update() methods of all sub noises and calibrations. + When calc() is called, the PSD is calculated for all sub noises, + the relevant calibration is evaluated and applied, and the + quadrature sum of all calibrated consituent noises is returned. + + Additionally a `references` attribute may be definied, similar to + the `noises` attribute described above except that the specified + noises do not contribute to the overall budget total. + + NOTE: an `ifo` attribute is always passed as an initialization + argument to sub noises. + + """ + + noises = [] + """List of constituent noise classes""" + + references = [] + """List of reference nosie classes""" + + def __init__(self, *args, noises=None, **kwargs): + """Initialize Budget object. + + See BudgetItem for base initialization arguments. + + If a `noises` keyword argument is provided it should be an + iterable of noise names (constituent or reference) which will + be used to filter the noises initialized in this budget. + + """ + super().__init__(*args, **kwargs) + # store args and kwargs for later use + self.args = args + self.kwargs = kwargs + # FIXME: special casing the IFO here, in case it's defined as + # a class attribute rather than passed at initialization. we + # do this because we're not defining a standard way to extract + # IFO variables that get passed around in a reasonable way. + # how can we clarify this? + if 'ifo' not in kwargs and getattr(self, 'ifo', None): + self.kwargs['ifo'] = getattr(self, 'ifo', None) + # all noise objects keyed by name + self._noise_objs = collections.OrderedDict() + # all cal objects keyed by name + self._cal_objs = {} + # noise to calibration mapping + self._noise_cal = {} + # set of all constituent budget noise names + self._budget_noises = set() + # initialize all noise objects + for nc in self.noises: + name, noise_obj, cal = self.__init_noise(nc) + if noises and name not in noises: + continue + self.__add_noise(noise_obj, cal) + self._budget_noises.add(name) + for nc in self.references: + name, noise_obj, cal = self.__init_noise(nc) + if noises and name not in noises: + continue + self.__add_noise(noise_obj, cal) + # error if requested noise is not present + if noises: + sset = set(noises) + nset = set([name for name in self._noise_objs.keys()]) + if not sset <= nset: + raise AttributeError("unknown noise terms: {}".format(' '.join(sset-nset))) + + def __init_noise(self, nc): + cal = None + if isinstance(nc, (list, tuple)): + noise, cal = nc[:2] + else: + noise = nc + noise_obj = noise( + *self.args, + **self.kwargs + ) + return noise_obj.name, noise_obj, cal + + def __add_noise(self, noise_obj, cal): + logging.debug("init {}".format(noise_obj)) + # instantiate the noise object + name = noise_obj.name + self._noise_objs[name] = noise_obj + if cal is not None: + # if a cal object is specified, instantiate and store + cal_obj = cal(*self.args, **self.kwargs) + if cal_obj.name not in self._cal_objs: + self._cal_objs[cal_obj.name] = cal_obj + self._noise_cal[name] = cal_obj.name + + def __getitem__(self, name): + try: + return self._noise_objs[name] + except KeyError: + try: + return self._cal_objs[name] + except KeyError: + raise KeyError("unknown noise or cal name '{}".format(name)) + + def keys(self): + """Iterate over budget noise names.""" + return iter(self._noise_objs.keys()) + + def values(self): + """Iterate over budget noise objects.""" + return iter(self._noise_objs.values()) + + def items(self): + """Iterate over budget noise (name, object) tuples.""" + return iter(self._noise_objs.items()) + + def __iter__(self): + return iter(self.keys()) + + def walk(self): + """Walk recursively through every BudgetItem in the budget. + + This includes Noise, Calibration and Budget objects, as well + as any decendents of Budget objects. + + For each leaf item yields a tuple of all ancestor objects, + e.g.: + + (self) + (self, BudgetItem) + (self, ChildBudget1) + (self, ChildBudget1, BudgetItem) + ... + + """ + yield (self,) + for item in itertools.chain( + self._cal_objs.values(), + self._noise_objs.values()): + if isinstance(item, Budget): + for i in item.walk(): + yield (self,) + i + else: + yield (self, item) + + def load(self): + """Load all noise and cal objects.""" + for name, item in itertools.chain( + self._cal_objs.items(), + self._noise_objs.items()): + logging.debug("load {}".format(item)) + item.load() + + def update(self, **kwargs): + """Update all noise and cal objects with supplied kwargs.""" + for name, item in itertools.chain( + self._cal_objs.items(), + self._noise_objs.items()): + logging.debug("update {}".format(item)) + item.update(**kwargs) + + def cal_for_noise(self, name): + """Return the calibration object for named noise.""" + try: + return self._cal_objs[self._noise_cal[name]] + except KeyError: + return None + + def calc_noise(self, name): + """Return calibrated individual noise term. + + The noise PSD and calibration transfer functions are + calculated, and the calibrated noise array is returned. + + """ + noise = self._noise_objs[name] + nd = noise.calc() + cal = self.cal_for_noise(name) + if cal: + cd = cal.calc() + return nd * cd + else: + return nd + + def calc(self): + """Calculate sum of all noises. + + """ + data = [self.calc_noise(name) for name in self._noise_objs.keys() if name in self._budget_noises] + return quadsum(data) + + def calc_trace(self, calibration=None, calc=True): + """Returns a dictionary of noises traces, keyed by noise names. + + Values are (data, style) trace tuples (see Noise.calc_trace). + The key of the budget sum total is 'Total'. The values of sub + budgets are themselves dictionaries returned from + calc_trace() of the sub budget. + + If `calibration` is not None it is assumed to be a + len(self.freq) array that will be multiplied to the output + PSD of the budget and all sub noises. + + If calc=False, the noise will not be calculated and the PSD + will be None. This is useful for just getting style the + style. + + """ + # start by creating an empty OrderedDict used for outputing trace data + # or style info with the following order: + # references + # total + # constituents + d = collections.OrderedDict() + # allocate references + for name, noise in self._noise_objs.items(): + if name in self._budget_noises: + continue + d[name] = noise.calc_trace(calc=False) + # allocate total + if self._budget_noises: + d['Total'] = None, self.style + # allocate constituent + for name, noise in self._noise_objs.items(): + if name not in self._budget_noises: + continue + d[name] = noise.calc_trace(calc=False) + # if we're not calc'ing, just return the dict now + if not calc: + return d + # calc all noises + for name, noise in self._noise_objs.items(): + # extract/calc the budget-level calibration for this noise + cal_obj = self.cal_for_noise(name) + if cal_obj: + cal = cal_obj.calc() + else: + cal = np.ones_like(self.freq) + # then multiply by the supplied calibration + if calibration is not None: + cal *= calibration + d[name] = noise.calc_trace(calibration=cal, calc=True) + # calc budget total + constituent_data = [] + for name in self._budget_noises: + if isinstance(d[name], dict): + data = d[name]['Total'][0] + else: + data = d[name][0] + constituent_data.append(data) + d['Total'] = quadsum(constituent_data), self.style + return d diff --git a/gwinc/plot.py b/gwinc/plot.py index 16f6da5ef67300e5685ce7aa02cb098bb5a59a17..4aec5cfa8b8dce10e60aff1c5c665ee7286488bc 100644 --- a/gwinc/plot.py +++ b/gwinc/plot.py @@ -1,91 +1,19 @@ from numpy import sqrt -PRIORITY_MAP = { - 'Quantum Vacuum': 0, - 'Seismic': 1, - 'Newtonian Gravity': 2, - 'Suspension Thermal': 3, - 'Coating Brownian': 4, - 'Coating Thermo-Optic': 5, - 'ITM Thermo-Refractive': 6, - 'ITM Carrier Density': 7, - 'Substrate Brownian': 8, - 'Substrate Thermo-Elastic': 9, - 'Excess Gas': 10, -} - -STYLE_MAP = { - 'Quantum Vacuum': dict( - # color = 'xkcd:vibrant purple', - color = '#ad03de', - ), - 'Seismic': dict( - # color = 'xkcd:brown', - color = '#653700', - ), - 'Newtonian Gravity': dict( - # color = 'xkcd:green', - color = '#15b01a', - ), - 'Atmospheric Infrasound': dict( - # color = 'xkcd:neon green', - color = '#0cff0c', - ls = '--', - ), - 'Suspension Thermal': dict( - # color = 'xkcd:deep sky blue', - color = '#0d75f8', - ), - 'Coating Brownian': dict( - # color = 'xkcd:fire engine red', - color = '#fe0002', - ), - 'Coating Thermo-Optic': dict( - # color = 'xkcd:bright sky blue', - color = '#02ccfe', - ls = '--', - ), - 'ITM Thermo-Refractive': dict( - # color = 'xkcd:dark sky blue', - color = '#448ee4', - ls = '--', - ), - 'ITM Carrier Density': dict( - # color = 'xkcd:grey', - color = '#929591', - ls = '--', - ), - 'Substrate Brownian': dict( - # color = 'xkcd:pumpkin orange', - color = '#fb7d07', - ls = '--', - ), - 'Substrate Thermo-Elastic': dict( - # color = 'xkcd:golden', - color = '#f5bf03', - ls = '--', - ), - 'Excess Gas': dict( - # color = 'xkcd:baby shit brown', - color = '#ad900d', - ls = '--', - ), -} - def plot_noise( - noises, + freq, + traces, ax=None, + **kwargs ): """Plot a GWINC noise budget from calculated noises If an axis handle is provided it will be used for the plot. - Returns the figure handle used. + Returns the figure handle. """ - f = noises['Freq'] - if ax is None: import matplotlib.pyplot as plt fig = plt.figure() @@ -93,31 +21,27 @@ def plot_noise( else: fig = ax.figure - def plot_dict(noises): - #use sorted to force a consistent ordering - #The key lambda in sorted gets the (name, noise) pair and so nn[0] returns the name - #the return tuple causes sorting by priority, with a fallback to lexical sort on the name - for name, noise in sorted(noises.items(), key = lambda nn : (PRIORITY_MAP.get(nn[0], 100), nn[0])): - if name in ['Freq', 'Total']: - continue - if isinstance(noise, dict): - plot_dict(noise) - else: - noise = noises[name] - stylekw = dict( - color = (0, 0, 0), - label = name, - lw = 3, - ) - try: - style = STYLE_MAP[name] - stylekw.update(style) - except KeyError: - pass - ax.loglog(f, sqrt(noise), **stylekw) - plot_dict(noises) - - ax.loglog(f, sqrt(noises['Total']), color='#000000', alpha=0.6, label='Total', lw=4) + for name, trace in traces.items(): + if isinstance(trace, dict): + data, style = trace['Total'] + else: + data, style = trace + # assuming all data is PSD + data = sqrt(data) + if name == 'Total': + style = dict( + color='#000000', + alpha=0.6, + lw=4, + ) + ylim = [min(data)/10, max(data)] + if 'label' not in style: + style['label'] = name + if 'linewidth' in style: + style['lw'] = style['linewidth'] + elif 'lw' not in style: + style['lw'] = 3 + ax.loglog(freq, data, **style) ax.grid( True, @@ -132,9 +56,17 @@ def plot_noise( fontsize='small', ) + ax.autoscale(enable=True, axis='y', tight=True) + if 'ylim' in kwargs: + ax.set_ylim(kwargs['ylim']) + else: + ax.set_ylim(ylim) + ax.set_xlim(freq[0], freq[-1]) + ax.set_xlabel('Frequency [Hz]') - ax.set_ylabel(u"Strain [1/\u221AHz]") - ax.set_xlim([min(f), max(f)]) - ax.set_ylim([3e-25, 1e-21]) + if 'ylabel' in kwargs: + ax.set_ylabel(kwargs['ylabel']) + if 'title' in kwargs: + ax.set_title(kwargs['title']) return fig diff --git a/gwinc/test/A+.pkl b/gwinc/test/A+.pkl deleted file mode 100644 index 53308c4037b0eabca523951b605c60cac89049d4..0000000000000000000000000000000000000000 --- a/gwinc/test/A+.pkl +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:226246bdc5600e865629bfc56bae363a3a6db04d1acfdc665b8aa95edc7e3d38 -size 897625 diff --git a/gwinc/test/Aplus.pkl b/gwinc/test/Aplus.pkl new file mode 100644 index 0000000000000000000000000000000000000000..1edf74d3b2833413ec2013fd1ea6a10dbe94b2a2 Binary files /dev/null and b/gwinc/test/Aplus.pkl differ diff --git a/gwinc/test/__main__.py b/gwinc/test/__main__.py index f648311bc3e817c511289e1a7debc966072cc014..dd46d6532d857b6dc916789389dd807cc99e8b5a 100644 --- a/gwinc/test/__main__.py +++ b/gwinc/test/__main__.py @@ -12,7 +12,8 @@ import logging logging.basicConfig(format='%(message)s', level=os.getenv('LOG_LEVEL', logging.INFO)) -from .. import load_ifo, gwinc +from .. import load_ifo +from ..gwinc import gwinc from ..gwinc_matlab import gwinc_matlab try: import inspiral_range @@ -57,7 +58,7 @@ def main(): args = parser.parse_args() logging.info("loading IFO '{}'...".format(args.IFO)) - ifo = load_ifo(args.IFO) + Budget, ifo, freq, plot_style = load_ifo(args.IFO) freq = np.logspace(np.log10(FLO), np.log10(FHI), NPOINTS) diff --git a/gwinc/util.py b/gwinc/util.py index b0a7618261a2dfddee7d1bf977f77db7ef4c7ac7..3f07dffef74ec53ad4a9ae86c2111dac0a4ac3a2 100644 --- a/gwinc/util.py +++ b/gwinc/util.py @@ -1,34 +1,39 @@ -import datetime -import h5py +import os +import importlib -from . import struct +def lpath(file0, file1): + """Return path of file1 when expressed relative to file0. -def load_hdf5(path): - """Load IFO and noises from HDF5 file. + For instance, if file0 is "/path/to/file0" and file1 is + "../for/file1" then what is returned is "/path/for/file1". - Returns (name, ifo, noises) tuple load from file. + This is useful for resolving paths within packages with e.g.: + + rpath = lpath(__file__, '../resource.yaml') """ - with h5py.File(path, 'r') as f: - name = f.attrs['name'] - ifo = struct.Struct.from_yaml(f.attrs['IFO']) - noises = {} - for name, data in f['/traces'].items(): - noises[name] = data.value - return name, ifo, noises + return os.path.abspath(os.path.join(os.path.dirname(file0), file1)) + +def load_module(name_or_path): + """Load module from name or path. -def save_hdf5(name, ifo, noises, path): - """Save IFO and noises to HDF5 file. + Return loaded module and module path. """ - with h5py.File(path, 'w') as f: - f.attrs['schema'] = 'GWINC noise budget' - # FIXME: add GWINC version - f.attrs['date'] = datetime.datetime.now().isoformat() - f.attrs['name'] = name - f.attrs['IFO'] = ifo.to_yaml() - tgrp = f.create_group('traces') - for noise, data in noises.items(): - tgrp.create_dataset(noise, data=data) + if os.path.exists(name_or_path): + path = name_or_path.rstrip('/') + modname = os.path.splitext(os.path.basename(path))[0] + if os.path.isdir(path): + path = os.path.join(path, '__init__.py') + spec = importlib.util.spec_from_file_location(modname, path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + else: + mod = importlib.import_module(name_or_path) + try: + path = mod.__path__[0] + except AttributeError: + path = mod.__file__ + return mod, path