diff --git a/HDF5_SCHEMATA b/HDF5_SCHEMATA
deleted file mode 100644
index f7989feee01cdef64904d7bdae023299de3dad75..0000000000000000000000000000000000000000
--- a/HDF5_SCHEMATA
+++ /dev/null
@@ -1,107 +0,0 @@
-# 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.  Python functions for writing
-budget data to and reading budget data from this format are included
-in the `gwinc.io` module.
-
-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 can be used for storing any relevant metadata,
-such as "ifo" parameters in YAML format or overall plot styling.
-Typically at least the following root attributes are 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/HDF5_SCHEMATA.md b/HDF5_SCHEMATA.md
new file mode 100644
index 0000000000000000000000000000000000000000..107980f3c8ec0aa0c4f96daf047dca2bc732fe95
--- /dev/null
+++ b/HDF5_SCHEMATA.md
@@ -0,0 +1,122 @@
+# 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.  Python functions for writing
+budget data to and reading budget data from this format are included
+in the `gwinc.io` module.
+
+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/
+
+
+## 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 '/', data
+sets 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.
+
+
+## top-level attributes
+
+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
+```
+
+The following root root attributes are defined:
+```
+/.attrs['title'] = <experiment description string (e.g. 'H1 Strain Budget')>
+/.attrs['date'] = <ISO-formatted string (e.g. '2015-10-24T20:30:00.000000Z')>
+/.attrs['ifo'] = <IFO Struct object, YAML serialized>
+```
+
+The remaining root level attributes usually pertain to the plot style.
+
+The budget frequency array is defined in a top level 'Freq' dataset:
+
+```
+/'Freq': (N),float
+```
+
+## version history
+
+### v1
+
+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>/
+/<budget>/Total': (N),float
+/<budget>/<trace_0>: (N),float
+/<budget>/...
+```
+
+
+The budget traces are defined a traces group.  The overall structure
+looks something like this:
+```
+/traces/
+/traces/'Total': (N),float
+/traces/<noise_0>: (N),float
+/traces/<noise_1>: (N),float
+/traces/<noise_2>/
+/traces/<noise_2>/'Total': (N),float
+/traces/<noise_2>/<noise_3>: (N),float
+/traces/<noise_2>/...
+/traces/...
+```
+
+
+### v2
+
+Each trace is given the following structure:
+```
+/<trace>/
+/<trace>.attrs['style'] = <YAML trace style dict>
+/<trace>/'PSD': (N),float
+/<trace>/budget/
+/<trace>/budget/<subtrace_0>/
+/<trace>/budget/<subtrace_1>/
+/<trace>/budget/...
+```
+
+The overall structure is:
+```
+/<budget>/
+/<budget>/.attrs['plot_style'] = <YAML plot style dict>
+/<budget>/.attrs['style'] = <YAML "Total" trace style dict>
+/<budget>/.attrs[*] = <arbitrary data>
+/<budget>/'Freq': (N),float
+/<budget>/'PSD': (N),float
+/<budget>/budget/
+/<budget>/budget/<trace_0>/...
+/<budget>/budget/<trace_1>/...
+/<budget>/budget/...
+```
diff --git a/README.md b/README.md
index c71b445cf1742c145f67d41b63b89ec8c4710220..9873b1c502b3a4ac9076217a4abf8959c12bc402 100644
--- a/README.md
+++ b/README.md
@@ -39,8 +39,8 @@ data.
 
 The [`inspiral_range`](https://git.ligo.org/gwinc/inspiral-range)
 package can be used to calculate various common "inspiral range"
-figures of merit for gravitational wave detector budgets.  See
-[figures of merit](#figures-of-merit) section below.
+figures of merit for gravitational wave detector budgets.  See the
+[inspiral range](#inspiral-range) section below.
 
 
 ## usage
@@ -48,12 +48,12 @@ figures of merit for gravitational wave detector budgets.  See
 ### command line interface
 
 `pygwinc` provides a command line interface that can be used to
-calculate and plot noise budgets for generic noise budgets or the
-various canonical IFOs described above, save/plot hdf5 trace data, and
-dump budget IFO parameters:
+calculate and plot the various canonical IFO noise budgets described
+above, as well as custom noise budgets (see below):
 ```shell
 $ python3 -m gwinc aLIGO
 ```
+Budget plots save/plot hdf5 trace data, and dump budget IFO parameters:
 
 You can play with IFO parameters and see the effects on the budget by
 dumping the pre-defined parameters to a [YAML-formatted parameter
@@ -84,18 +84,19 @@ $ python3 -m gwinc path/to/mybudget
 ```
 
 The command line interface also includes an "interactive" mode which
-provides an [IPython](https://ipython.org/) shell for interacting with a processed budget:
+provides an [IPython](https://ipython.org/) shell for interacting with
+a processed budget:
 ```shell
 $ python3 -m gwinc -i Aplus
 GWINC interactive shell
 
-The 'ifo' Struct and 'traces' data are available for inspection.
+The 'ifo' Struct and 'trace' data are available for inspection.
 Use the 'whos' command to view the workspace.
 
 You may interact with the plot using the 'plt' functions, e.g.:
 
-In [.]: plt.title("foo")
-In [.]: plt.savefig("foo.pdf")
+In [.]: plt.title("My Special Budget")
+In [.]: plt.savefig("mybudget.pdf")
 
 In [1]: 
 ```
@@ -116,30 +117,30 @@ accessed directly through the `gwinc` library interface:
 >>> freq = np.logspace(1, 3, 1000)
 >>> budget = gwinc.load_budget('aLIGO', freq)
 >>> trace = budget.run()
->>> fig = gwinc.plot_noise(freq, trace)
+>>> fig = gwinc.plot_budget(trace)
 >>> fig.show()
 ```
 
 The `load_budget()` function takes most of the same inputs as the
 command line interface (e.g. IFO names, budget module paths, YAML
-parameter files), and returns the `Budget` object defined in the
-specified budget module (see [budget interface](#budget-interface)
-below).  The budget `ifo` `gwinc.Struct` is assigned to the
-`budget.ifo` attribute.
+parameter files), and returns the instantiated `Budget` object defined
+in the specified budget module (see [budget
+interface](#budget-interface) below).  The budget `ifo` `gwinc.Struct`
+is available in the `budget.ifo` attribute.
 
-The budget `run()` method is a convenience method that calculates all
-budget noises and the noise total and returns a (possibly) nested
-dictionary of a noise data, in the form of a `(data, style)` tuple
-where 'data' is the PSD data and 'style' is a plot style dictionary
-for the trace.  The dictionary will be nested if the budget includes
-any sub-budgets.
+The budget `run()` method calculates all budget noises and the noise
+total and returns a `BudgetTrace` object with `freq`, `psd`, and `asd`
+properties.  The budget sub-traces are available through a dictionary
+(`trace['QuantumVacuum']`) interface and via attributes
+(`trace.QuantumVacumm`).
 
 
 ## noise functions
 
-`pygwinc` noise functions are available in the `gwinc.noise` package.
-This package includes multiple sub-modules for the different types of
-noises, e.g. `suspensionthermal`, `coatingthermal`, `quantum`, etc.)
+The `pygwinc` analytical noise functions are available in the
+`gwinc.noise` package.  This package includes multiple sub-modules for
+the different types of noises, e.g. `suspensionthermal`,
+`coatingthermal`, `quantum`, etc.)
 
 The various noise functions need many different parameters to
 calculate their noise outputs.  Many parameters are expected to be in
@@ -162,7 +163,7 @@ def coating_brownian(f, materials, wavelength, wBeam, dOpt):
 ```
 
 
-### `gwinc.Struct` objects
+## `gwinc.Struct` objects
 
 `pygwinc` provides a `Struct` class that can hold parameters in
 attributes and additionally acts like a dictionary, for passing to the
@@ -233,6 +234,11 @@ are:
 * `update(**kwargs)`: update data/attributes
 * `calc()`: return final data array
 
+Generally these methods are not called directly.  Instead, the `Noise`
+and `Budget` classes include a `run` method that smartly executes then
+all in sequence.
+
+
 See the built-in documentation for more info (e.g. `pydoc3
 gwinc.nb.BudgetItem`)
 
@@ -314,10 +320,10 @@ The `style` attributes of the various `Noise` classes define plot
 style for the noise.
 
 This budget can be loaded with the `gwinc.load_budget()` function, and
-processed with the `Budget.run()` method:
+processed as usual with the `Budget.run()` method:
 ```python
 budget = load_budget('/path/to/MyBudget', freq)
-traces = budget.run()
+trace = budget.run()
 ```
 
 Other than the necessary `freq` initialization argument that defines
@@ -339,8 +345,7 @@ Alternate ifos can be specified at run time:
 ```python
 budget = load_budget('/path/to/MyBudget', freq)
 ifo = Struct.from_file('/path/to/MyBudget.ifo')
-traces = budget.run(ifo=ifo)
-...
+trace = budget.run(ifo=ifo)
 ```
 
 The IFOs included in `gwinc.ifo` provide examples of the use of the
@@ -354,10 +359,9 @@ interface.  The most straightforward way is to run the full budget,
 and extract the noise data the output traces dictionary:
 
 ```python
-Budget = load_budget('/path/to/MyBudget')
-budget = Budget(freq)
-traces = budget.calc_traces()
-data, plot_style = traces['QuantumVacuum']
+budget = load_budget('/path/to/MyBudget', freq)
+trace = budget.run()
+quantum_trace = trace['QuantumVacuum']
 ```
 
 You can also calculate the final calibrated output noise for just a
@@ -375,7 +379,7 @@ data = budget['QuantumVacuum'].calc()
 ```
 
 
-# figures of merit
+# inspiral range
 
 The [`inspiral_range`](https://git.ligo.org/gwinc/inspiral-range)
 package can be used to calculate various common "inspiral range"
@@ -388,18 +392,15 @@ import inspiral_range
 import numpy as np
 
 freq = np.logspace(1, 3, 1000)
-Budget = gwinc.load_budget('Aplus')
-traces = Budget(freq).run()
+budget = gwinc.load_budget('Aplus', freq)
+trace = budget.run()
 
 range = inspiral_range.range(
-    freq, traces['Total'][0],
+    freq, trace.psd,
     m1=30, m2=30,
 )
 ```
 
-Note you need to extract the zeroth element of the `traces['Total']`
-tuple, which is the actual PSD data.
-
 See the [`inspiral_range`](https://git.ligo.org/gwinc/inspiral-range)
 package for more details.
 
diff --git a/gwinc/__init__.py b/gwinc/__init__.py
index 4839e800971beb8ec9d86982691cd55ff5c041d6..134bc3740319cc201aafaf5a2c1d53e6d7355fc3 100644
--- a/gwinc/__init__.py
+++ b/gwinc/__init__.py
@@ -7,6 +7,7 @@ import numpy as np
 
 from .ifo import IFOS
 from .struct import Struct
+from .plot import plot_budget
 from .plot import plot_noise
 
 
@@ -115,9 +116,10 @@ def gwinc(freq, ifo, source=None, plot=False, PRfixed=True):
 
     # construct matgwinc-compatible noises structure
     noises = {}
-    for name, (data, style) in traces.items():
-        noises[style.get('label', name)] = data
-    noises['Freq'] = freq
+    for name, trace in traces.items():
+        noises[name] = trace.psd
+    noises['Total'] = traces.psd
+    noises['Freq'] = traces.freq
 
     pbs = ifo.gwinc.pbs
     parm = ifo.gwinc.parm
@@ -164,6 +166,6 @@ def gwinc(freq, ifo, source=None, plot=False, PRfixed=True):
             logger.info('BBH Inspiral Range:     ' + str(score.effr0bh) + ' Mpc/ z = ' + str(score.zHorizonBH))
             logger.info('Stochastic Omega: %4.1g Universes' % score.Omega)
 
-        plot_noise(ifo, traces, **plot_style)
+        plot_budget(traces, **plot_style)
 
     return score, noises, ifo
diff --git a/gwinc/__main__.py b/gwinc/__main__.py
index c4edc9f14b692e44f7f19a7d1b02f39d7c395aa7..e2ab196698075e130bc2b1cb62016a124e06dfcb 100644
--- a/gwinc/__main__.py
+++ b/gwinc/__main__.py
@@ -5,7 +5,7 @@ import logging
 import argparse
 import numpy as np
 
-from . import IFOS, load_budget, plot_noise, logger
+from . import IFOS, load_budget, plot_budget, logger
 
 logger.setLevel(os.getenv('LOG_LEVEL', 'WARNING').upper())
 formatter = logging.Formatter('%(message)s')
@@ -123,11 +123,10 @@ def main():
             parser.exit(2, "IFO parameter specification not allowed when loading traces from file.\n")
         from .io import load_hdf5
         budget = None
-        freq, traces, attrs = load_hdf5(args.IFO)
-        ifo = attrs.get('ifo')
-        # FIXME: deprecate 'IFO'
-        ifo = attrs.get('IFO', ifo)
-        plot_style = attrs
+        trace = load_hdf5(args.IFO)
+        freq = trace.freq
+        ifo = trace.ifo
+        plot_style = trace.plot_style
 
     else:
         budget = load_budget(args.IFO)
@@ -140,7 +139,7 @@ def main():
         else:
             freq = getattr(budget, 'freq', freq_from_spec(FREQ))
         plot_style = getattr(budget, 'plot_style', {})
-        traces = None
+        trace = None
 
     if args.ifo:
         for paramval in args.ifo:
@@ -233,9 +232,9 @@ def main():
     ##########
     # main calculations
 
-    if not traces:
+    if not trace:
         logger.info("calculating budget...")
-        traces = budget.run(freq=freq)
+        trace = budget.run(freq=freq)
 
     if args.title:
         plot_style['title'] = args.title
@@ -250,15 +249,16 @@ def main():
         logger.info("calculating inspiral {}...".format(range_func))
         H = inspiral_range.CBCWaveform(freq, **range_params)
         logger.debug("waveform params: {}".format(H.params))
-        fom = eval('inspiral_range.{}'.format(range_func))(freq, traces['Total'][0], H=H)
+        fom = eval('inspiral_range.{}'.format(range_func))(freq, trace.psd, H=H)
         logger.info("{}({}) = {:.2f} Mpc".format(range_func, range_func_args, fom))
-        fom_title = 'inspiral {func} {m1}/{m2} $\mathrm{{M}}_\odot$: {fom:.0f} Mpc'.format(
+        subtitle = 'inspiral {func} {m1}/{m2} $\mathrm{{M}}_\odot$: {fom:.0f} Mpc'.format(
             func=range_func,
             m1=H.params['m1'],
             m2=H.params['m2'],
             fom=fom,
             )
-        plot_style['title'] += '\n{}'.format(fom_title)
+    else:
+        subtitle = None
 
     ##########
     # interactive
@@ -267,14 +267,14 @@ def main():
     if args.interactive:
         banner = """GWINC interactive shell
 
-The 'ifo' Struct and 'traces' data objects are available for
+The 'ifo' Struct and 'budget' trace data objects are available for
 inspection.  Use the 'whos' command to view the workspace.
 """
         if not args.plot:
             banner += """
-You may plot the budget using the 'plot_noise()' function:
+You may plot the budget using the 'plot_budget()' function:
 
-In [.]: plot_noise(freq, traces, **plot_style)
+In [.]: plot_budget(budget, **plot_style)
 """
         banner += """
 You may interact with the plot using the 'plt' functions, e.g.:
@@ -286,34 +286,31 @@ In [.]: plt.savefig("foo.pdf")
         ipshell = InteractiveShellEmbed(
             banner1=banner,
             user_ns={
-                'freq': freq,
-                'traces': traces,
+                'budget': trace,
                 'ifo': ifo,
                 'plot_style': plot_style,
-                'plot_noise': plot_noise,
+                'plot_budget': plot_budget,
             },
         )
         ipshell.enable_pylab()
         if args.plot:
-            ipshell.ex("fig = plot_noise(freq, traces, **plot_style)")
+            ipshell.ex("fig = plot_budget(budget, **plot_style)")
             ipshell.ex("plt.title(plot_style['title'])")
         ipshell()
 
     ##########
     # output
 
-    # save noise traces to HDF5 file
+    # save noise trace to HDF5 file
     if out_data_files:
         from .io import save_hdf5
-        attrs = dict(plot_style)
         for path in out_data_files:
-            logger.info("saving budget traces: {}".format(path))
+            logger.info("saving budget trace: {}".format(path))
             save_hdf5(
                 path=path,
-                freq=freq,
-                traces=traces,
+                trace=trace,
                 ifo=ifo,
-                **attrs,
+                plot_style=plot_style,
             )
 
     # standard plotting
@@ -321,10 +318,10 @@ In [.]: plt.savefig("foo.pdf")
         logger.debug("plotting noises...")
         fig = plt.figure()
         ax = fig.add_subplot(1, 1, 1)
-        plot_noise(
-            freq,
-            traces,
+        plot_budget(
+            trace,
             ax=ax,
+            subtitle=subtitle,
             **plot_style
         )
         fig.tight_layout()
diff --git a/gwinc/ifo/__main__.py b/gwinc/ifo/__main__.py
index 6eb331b15424682dd07d8617e840ed5487b813a5..a049331ecd84f152e59a39caeeea618b26a443d0 100644
--- a/gwinc/ifo/__main__.py
+++ b/gwinc/ifo/__main__.py
@@ -35,11 +35,11 @@ def main():
         range_pad = max(len(name), range_pad)
 
     for name, budget in budgets.items():
-        data = budget.calc()
+        trace = budget.run()
         try:
             import inspiral_range
             label_range = ' {:>6.0f} Mpc'.format(
-                inspiral_range.range(freq, data),
+                inspiral_range.range(freq, trace.psd),
             )
         except ModuleNotFoundError:
             label_range = ''
@@ -49,7 +49,7 @@ def main():
             pad=range_pad,
             range=label_range,
         )
-        ax.loglog(freq, np.sqrt(data), label=label, lw=2)
+        ax.loglog(freq, trace.asd, label=label, lw=2)
 
     ax.grid(
         True,
diff --git a/gwinc/io.py b/gwinc/io.py
index fb407486d18a48d133240555c0fa1bcfe99e99ab..98b900e5bd02b4099d982fe9dc8f3b2aa1e2a65d 100644
--- a/gwinc/io.py
+++ b/gwinc/io.py
@@ -4,25 +4,23 @@ import datetime
 
 from . import logger
 from . import Struct
+from .trace import BudgetTrace
 
 
 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 _write_trace_recursive(f, trace):
+    f.attrs['style'] = yaml.dump(trace.style)
+    f.create_dataset('PSD', data=trace.psd)
+    if trace.budget:
+        bgrp = f.create_group('budget')
+    for t in trace:
+        tgrp = bgrp.create_group(t.name)
+        _write_trace_recursive(tgrp, t)
 
 
-def save_hdf5(path, freq, traces, **kwargs):
+def save_hdf5(path, trace, ifo=None, plot_style=None, **kwargs):
     """Save GWINC budget data to an HDF5 file.
 
     The `freq` argument should be the frequency array, and `traces`
@@ -36,46 +34,107 @@ def save_hdf5(path, freq, traces, **kwargs):
     """
     with h5py.File(path, 'w') as f:
         f.attrs['SCHEMA'] = SCHEMA
-        f.attrs['SCHEMA_VERSION'] = SCHEMA_VERSION
+        f.attrs['SCHEMA_VERSION'] = 2
         # FIXME: add budget code hash or something
         f.attrs['date'] = datetime.datetime.now().isoformat()
+        if ifo:
+            f.attrs['ifo'] = ifo.to_yaml()
+        if plot_style:
+            f.attrs['plot_style'] = yaml.dump(plot_style)
         for key, val in kwargs.items():
-            if key == 'ifo':
-                f.attrs['ifo'] = val.to_yaml()
+            f.attrs[key] = val
+        f.create_dataset('Freq', data=trace.freq)
+        tgrp = f.create_group(trace.name)
+        _write_trace_recursive(tgrp, trace)
+
+
+def _load_hdf5_v1(f):
+    def read_recursive(element):
+        budget = []
+        for name, item in element.items():
+            if name == 'Total':
+                total = item[:]
+                continue
+            if isinstance(item, h5py.Group):
+                trace = read_recursive(item)
+                trace.name = name
             else:
-                f.attrs[key] = val
-        f.create_dataset('Freq', data=freq)
-        tgrp = f.create_group('traces')
-        _write_trace_recursive(tgrp, traces)
+                trace = BudgetTrace(
+                    name=name,
+                    style=dict(item.attrs),
+                    psd=item[:],
+                    budget=[],
+                )
+            budget.append(trace)
+        return BudgetTrace(
+            psd=total,
+            budget=budget,
+        )
+    trace = read_recursive(f['/traces'])
+    trace.name = 'Total'
+    trace._freq = f['Freq'][:]
+    attrs = dict(f.attrs)
+    if 'ifo' in attrs:
+        try:
+            ifo = Struct.from_yaml(attrs['ifo'])
+            del attrs['ifo']
+        except yaml.constructor.ConstructorError:
+            logger.warning("HDF5 load warning: Could not de-serialize 'ifo' YAML attribute.")
+            ifo = None
+    trace.style = attrs
+    trace.ifo = ifo
+    return trace
 
 
-def _read_trace_recursive(element):
-    trace = {}
-    for name, item in element.items():
-        if isinstance(item, h5py.Group):
-            trace[name] = _read_trace_recursive(item)
+def _load_hdf5_v2(f):
+    def read_recursive(element):
+        psd = element['PSD'][:]
+        style = yaml.safe_load(element.attrs['style'])
+        budget = []
+        if 'budget' in element:
+            for name, item in element['budget'].items():
+                trace = read_recursive(item)
+                trace.name = name
+                budget.append(trace)
+        return BudgetTrace(
+            psd=psd,
+            budget=budget,
+            style=style,
+        )
+    for name, item in f.items():
+        if name == 'Freq':
+            freq = item[:]
         else:
-            trace[name] = item[:], dict(item.attrs.items())
+            trace = read_recursive(item)
+            trace.name = name
+    trace._freq = freq
+    trace.ifo = None
+    attrs = dict(f.attrs)
+    ifo = attrs.get('ifo')
+    if ifo:
+        try:
+            trace.ifo = Struct.from_yaml(ifo)
+            del attrs['ifo']
+        except yaml.constructor.ConstructorError:
+            logger.warning("HDF5 load warning: Could not de-serialize 'ifo' YAML attribute.")
+    trace.plot_style = yaml.safe_load(attrs['plot_style'])
     return trace
 
 
 def load_hdf5(path):
     """Load GWINC budget data from an HDF5 file.
 
-    Returns (freq, traces, attrs).  An 'ifo' attr will be
-    de-serialized from YAML into a Struct object.
+    Returns BudgetTrace.  An 'ifo' attr will be de-serialized from
+    YAML into a Struct object and assigned as an attribute to the
+    BudgetTrace.
 
     See HDF5_SCHEMA.
 
     """
+    loaders = {
+        1: _load_hdf5_v1,
+        2: _load_hdf5_v2,
+    }
     with h5py.File(path, 'r') as f:
-        # FIXME: check SCHEMA name/version
-        freq = f['Freq'][:]
-        traces = _read_trace_recursive(f['/traces'])
-        attrs = dict(f.attrs)
-        if 'ifo' in attrs:
-            try:
-                attrs['ifo'] = Struct.from_yaml(attrs['ifo'])
-            except yaml.constructor.ConstructorError:
-                logger.warning("HDF5 load warning: Could not de-serialize 'ifo' YAML attribute.")
-        return freq, traces, attrs
+        version = f.attrs.get('SCHEMA_VERSION', 1)
+        return loaders[version](f)
diff --git a/gwinc/nb.py b/gwinc/nb.py
index b9d371aaa9b5c1fb84d6592771e911e0df6e1c7c..1664887e23b1d6d605772d5068bad2b26d5d8fe6 100644
--- a/gwinc/nb.py
+++ b/gwinc/nb.py
@@ -1,11 +1,10 @@
-import operator
 import itertools
 import collections
 import numpy as np
 import scipy.interpolate
-from functools import reduce
 
 from . import logger
+from .trace import BudgetTrace
 
 
 def quadsum(data):
@@ -20,7 +19,6 @@ def quadsum(data):
     return np.nansum(data, 0)
 
 
-
 class BudgetItem:
     """GWINC BudgetItem class
 
@@ -37,6 +35,11 @@ class BudgetItem:
         By default any keyword arguments provided are written directly
         as attribute variables (as with __init__).
 
+        When overloading this method it is recommended to execute the
+        method from the base class with e.g.:
+
+          super().update(**kwargs)
+
         """
         for key, val in kwargs.items():
             setattr(self, key, val)
@@ -132,27 +135,31 @@ class Noise(BudgetItem):
     style = {}
     """Trace plot style dictionary"""
 
-    def calc_trace(self, calibrations=None, calc=True):
-        """Returns noise (PSD, style) tuple.
+    def _make_trace(self, psd=None, budget=None):
+        return BudgetTrace(
+            name=self.name,
+            style=self.style,
+            freq=self.freq,
+            psd=psd,
+            budget=budget,
+        )
+
+    def calc_trace(self, calibration=1, calc=True):
+        """Calculate noise and return BudgetTrace object
 
-        If `calibrations` is not None it is assumed to be a list of
-        len(self.freq) arrays that will be multiplied to the output
-        PSD.
+        `calibration` should either be a scalar or 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 the style.
+        If `calc` is False, the noise will not be calculated and the
+        trace PSD will be None.  This is useful for just getting the
+        trace style info.
 
         """
+        total = None
         if calc:
-            data = self.calc()
-            if calibrations:
-                data *= reduce(
-                    operator.mul,
-                    calibrations,
-                )
-        else:
-            data = None
-        return data, self.style
+            total = self.calc() * calibration
+        return self._make_trace(psd=total)
 
     def run(self, **kwargs):
         """Convenience method to load, update, and return calc traces.
@@ -361,95 +368,67 @@ class Budget(Noise):
 
     def update(self, **kwargs):
         """Update all noise and cal objects with supplied kwargs."""
+        super().update(**kwargs)
         for name, item in itertools.chain(
                 self._cal_objs.items(),
                 self._noise_objs.items()):
             logger.debug("update {}".format(item))
             item.update(**kwargs)
 
-    def calc_noise(self, name):
-        """Return calibrated individual noise term.
+    update.__doc__ = Noise.update.__doc__
 
-        The noise PSD and calibration transfer functions are
-        calculated, and the calibrated noise array is returned.
+    def calc_noise(self, name, calibration=1, calc=True, _cals=None):
+        """Return calibrated individual noise BudgetTrace.
 
-        """
-        noise = self._noise_objs[name]
-        cals = [self._cal_objs[cal].calc() for cal in self._noise_cals[name]]
-        data = noise.calc_trace(cals)
-        if isinstance(data, dict):
-            return data['Total'][0]
-        else:
-            return data[0]
-
-    def calc(self):
-        """Calculate sum of all noises.
+        The noise and calibration transfer functions are calculated,
+        and the calibrated noise BudgetTrace is returned.
+        `calibration` is an overall calculated calibration to apply to
+        the noise.
 
         """
-        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, calibrations=None, calc=True):
-        """Returns a dictionary of noises traces, keyed by noise names.
+        for cal in self._noise_cals[name]:
+            if _cals:
+                calibration *= _cals[cal]
+            else:
+                obj = self._cal_objs[cal]
+                logger.debug("calc {}".format(obj))
+                calibration *= obj.calc()
+        noise = self._noise_objs[name]
+        logger.debug("calc {}".format(noise))
+        return noise.calc_trace(
+            calibration=calibration,
+            calc=calc,
+        )
 
-        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.
+    def calc_trace(self, calibration=1, calc=True):
+        """Calculate all budget noises and return BudgetTrace object
 
-        If `calibrations` is not None it is assumed to be a list of
-        len(self.freq) array that will be multiplied to the output PSD
-        of the budget and all sub noises.
+        `calibration` should either be a scalar or 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.
+        If `calc` is False, the noise will not be calculated and the
+        trace PSD will be None.  This is useful for just getting the
+        trace style info.
 
         """
-        # 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 calibrations
-        c = {}
-        for name, cal in self._cal_objs.items():
-            logger.debug("calc {}".format(name))
-            c[name] = cal.calc()
-        # calc all noises
-        for name, noise in self._noise_objs.items():
-            cals = [c[cal] for cal in self._noise_cals[name]]
-            if calibrations:
-                cals += calibrations
-            logger.debug("calc {}".format(name))
-            d[name] = noise.calc_trace(
-                calibrations=cals,
+        _cals = {}
+        if calc:
+            for name, cal in self._cal_objs.items():
+                logger.debug("calc {}".format(cal))
+                _cals[name] = cal.calc()
+
+        budget = []
+        for name in self._noise_objs:
+            trace = self.calc_noise(
+                name,
+                calibration=calibration,
+                calc=calc,
+                _cals=_cals,
             )
-        # 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
+            budget.append(trace)
+
+        total = quadsum([trace.psd for trace in budget])
+        return self._make_trace(
+            psd=total, budget=budget
+        )
diff --git a/gwinc/plot.py b/gwinc/plot.py
index 3f296ab550bede1268378f22ef74276d5f4952d7..1753108d36102db91ee83d897b03853754e27fa3 100644
--- a/gwinc/plot.py
+++ b/gwinc/plot.py
@@ -1,14 +1,9 @@
-from numpy import sqrt
-from collections.abc import Mapping
-
-
-def plot_noise(
-        freq,
-        traces,
+def plot_budget(
+        budget,
         ax=None,
         **kwargs
 ):
-    """Plot a GWINC noise budget from calculated noises
+    """Plot a GWINC BudgetTrace noise budget from calculated noises
 
     If an axis handle is provided it will be used for the plot.
 
@@ -22,36 +17,29 @@ def plot_noise(
     else:
         fig = ax.figure
 
-    ylim = kwargs.get('ylim')
-
-    for name, trace in traces.items():
-        if isinstance(trace, Mapping):
-            trace = trace['Total']
+    total = budget.asd
+    ylim = [min(total)/10, max(total)]
+    style = dict(
+        color='#000000',
+        alpha=0.6,
+        lw=4,
+    )
+    style.update(getattr(budget, 'style', {}))
+    if 'label' in style:
+        style['label'] = 'Total ' + style['label']
+    else:
+        style['label'] = 'Total'
+    ax.loglog(budget.freq, total, **style)
 
-        try:
-            data = trace[0]
-            style = dict(**trace[1])
-        except TypeError:
-            data = trace
-            style = {}
-        # assuming all data is PSD
-        data = sqrt(data)
-        if name == 'Total' and not style:
-            style = dict(
-                color='#000000',
-                alpha=0.6,
-                lw=4,
-            )
-            if ylim is None:
-                ylim = [min(data)/10, max(data)]
+    for name, trace in budget.items():
+        style = trace.style
         if 'label' not in style:
-            style['label'] = name
+            style['label'] = budget.name
         if 'linewidth' in style:
             style['lw'] = style['linewidth']
         elif 'lw' not in style:
             style['lw'] = 3
-
-        ax.loglog(freq, data, **style)
+        ax.loglog(budget.freq, trace.asd, **style)
 
     ax.grid(
         True,
@@ -67,14 +55,19 @@ def plot_noise(
     )
 
     ax.autoscale(enable=True, axis='y', tight=True)
-    if ylim:
-        ax.set_ylim(ylim)
-    ax.set_xlim(freq[0], freq[-1])
-
+    ax.set_ylim(kwargs.get('ylim', ylim))
+    ax.set_xlim(budget.freq[0], budget.freq[-1])
     ax.set_xlabel('Frequency [Hz]')
     if 'ylabel' in kwargs:
         ax.set_ylabel(kwargs['ylabel'])
     if 'title' in kwargs:
-        ax.set_title(kwargs['title'])
+        title = kwargs['title']
+        if 'subtitle' in kwargs:
+            title += '\n' + kwargs['subtitle']
+        ax.set_title(title)
 
     return fig
+
+
+# FIXME: deprecate
+plot_noise = plot_budget
diff --git a/gwinc/test/__main__.py b/gwinc/test/__main__.py
index c344d9028bf8b9bc0859cda59b0019bd757a9d40..24a546792c604d54a939484d115c8d607514804a 100644
--- a/gwinc/test/__main__.py
+++ b/gwinc/test/__main__.py
@@ -6,10 +6,8 @@ import logging
 import tempfile
 import argparse
 import subprocess
-import numpy as np
 import matplotlib.pyplot as plt
 from collections import OrderedDict
-from collections.abc import Mapping
 from PyPDF2 import PdfFileReader, PdfFileWriter
 
 from .. import IFOS, load_budget
@@ -120,81 +118,45 @@ def load_cache(path):
     return cache
 
 
-def walk_traces(traces, root=()):
-    """recursively walk through traces
-
-    yields (key_tuple, value), where key_tuple is a tuple of nested
-    dict keys of the traces dict locating the given value.
-
-    """
-    for key, val in traces.items():
-        r = root+(key,)
-        if isinstance(val, Mapping):
-            for k, v in walk_traces(val, root=r):
-                yield k, v
-            continue
-        else:
-            yield r, val
-
-
-def zip_noises(tracesA, tracesB, skip):
+def zip_noises(traceA, traceB, skip):
     """zip matching noises from traces"""
-    for keys, (noiseA, _) in walk_traces(tracesA):
-        # keys is a tuple of nested keys for noise
-        name = keys[-1]
-        # skip keys we'll add at the end
-        if name in ['Total', 'int73']:
-            continue
+    B_dict = dict(traceB.walk())
+    for name, tA in traceA.walk():
         if skip and name in skip:
             logging.warning("SKIPPING TEST: '{}'".format(name))
             continue
-
         try:
-            t = tracesB
-            for key in keys:
-                t = t[key]
-            noiseB, style = t
+            tB = B_dict[name]
         except (KeyError, TypeError):
             logging.warning("MISSING B TRACE: '{}'".format(name))
             continue
+        yield name, tA, tB
 
-        yield name, noiseA, noiseB
 
-    yield 'Total', tracesA['Total'][0], tracesB['Total'][0]
-
-    if 'int73' in tracesA:
-        yield 'int73', tracesA['int73'][0], tracesB['int73'][0]
-
-
-def compare_traces(tracesA, tracesB, tolerance=TOLERANCE, skip=None):
-    """Compare two gwinc trace dictionaries
+def compare_traces(traceA, traceB, tolerance=TOLERANCE, skip=None):
+    """Compare two gwinc traces
 
     Noises listed in `skip` will not be compared.
 
     Returns a dictionary of noises that differ fractionally (on a
-    point-by-point basis) by more that `tolerance` between `tracesA`
-    and `tracesB`.
+    point-by-point basis) by more than `tolerance` between the traces
+    in `traceA` and `traceB`.
 
     """
     #name_width = max([len(n[0][-1]) for n in walk_traces(tracesA)])
     name_width = 15
     diffs = OrderedDict()
-    for name, noiseA, noiseB in zip_noises(tracesA, tracesB, skip):
+    for name, tA, tB in zip_noises(traceA, traceB, skip):
         logging.debug("comparing {}...".format(name))
-
-        ampA = np.sqrt(noiseA)
-        ampB = np.sqrt(noiseB)
+        ampA = tA.asd
+        ampB = tB.asd
         diff = ampB - ampA
         frac = abs(diff / ampA)
-
         if max(frac) < tolerance:
             continue
-
         logging.warning("EXCESSIVE DIFFERENCE: {:{w}} {:6.1f} ppm".format(
             name, max(frac)*1e6, w=name_width))
-
-        diffs[name] = (noiseA, noiseB, frac)
-
+        diffs[name] = (ampA, ampB, frac)
     return diffs
 
 
@@ -202,11 +164,11 @@ def plot_diffs(freq, diffs, styleA, styleB):
     spec = (len(diffs)+1, 2)
     sharex = None
     for i, nname in enumerate(diffs):
-        noiseA, noiseB, frac = diffs[nname]
+        ampA, ampB, frac = diffs[nname]
 
         axl = plt.subplot2grid(spec, (i, 0), sharex=None)
-        axl.loglog(freq, np.sqrt(noiseA), **styleA)
-        axl.loglog(freq, np.sqrt(noiseB), **styleB)
+        axl.loglog(freq, ampA, **styleA)
+        axl.loglog(freq, ampB, **styleB)
         axl.grid()
         axl.legend(loc='upper right')
         axl.set_ylabel(nname)
@@ -334,14 +296,17 @@ gwinc/test/cache/<SHA1>.  Old caches are automatically pruned.""",
             fail |= True
             continue
 
-        freq, traces_ref, attrs = load_hdf5(path)
-
+        traces_ref = load_hdf5(path)
+        traces_ref.name = 'Total'
+        freq = traces_ref.freq
         budget = load_budget(name, freq)
         traces_cur = budget.run()
+        traces_cur.name = 'Total'
 
-        if inspiral_range:
-            total_ref = traces_ref['Total'][0]
-            total_cur = traces_cur['Total'][0]
+        # FIXME: add this back
+        if False: #inspiral_range:
+            total_ref = traces_ref.psd
+            total_cur = traces_cur.psd
             range_func = inspiral_range.range
             H = inspiral_range.waveform.CBCWaveform(freq)
             fom_ref = range_func(freq, total_ref, H=H)
diff --git a/gwinc/trace.py b/gwinc/trace.py
new file mode 100644
index 0000000000000000000000000000000000000000..175d5e4628d89de2f2379dd948588cff7d3c5946
--- /dev/null
+++ b/gwinc/trace.py
@@ -0,0 +1,79 @@
+import numpy as np
+
+
+class BudgetTrace:
+    """Budget trace data calculated from a Noise or Budget
+
+
+    """
+    def __init__(self, name=None, style=None, freq=None, psd=None, budget=None):
+        self.name = name
+        self.style = style or {}
+        self._freq = freq
+        self._psd = psd
+        self.budget = budget or []
+
+    def __repr__(self):
+        if self.budget:
+            bs = ' [{}]'.format(', '.join([str(b.name) for b in self]))
+        else:
+            bs = ''
+        return '<{} {}{}>'.format(
+            self.__class__.__name__,
+            self.name,
+            bs,
+        )
+
+    @property
+    def freq(self):
+        """trace frequency array, in Hz"""
+        return self._freq
+
+    @property
+    def psd(self):
+        """trace power spectral density array"""
+        return self._psd
+
+    @property
+    def asd(self):
+        """trace amplitude spectral density array"""
+        return np.sqrt(self._psd)
+
+    def len(self):
+        """Length of data"""
+        return len(self._freq)
+
+    def __iter__(self):
+        """iterator of budget traces"""
+        return iter(self.budget)
+
+    @property
+    def _bdict(self):
+        bdict = {trace.name: trace for trace in self.budget}
+        return bdict
+
+    def __getattr__(self, name):
+        return self._bdict[name]
+
+    def __getitem__(self, name):
+        """get budget trace by name"""
+        return self._bdict[name]
+
+    def items(self):
+        """iterator of budget (name, trace) tuples"""
+        return self._bdict.items()
+
+    def keys(self):
+        """iterator of budget trace names"""
+        return self._bdict.keys()
+
+    def values(self):
+        """iterator of budget traces"""
+        return self._bdict.values()
+
+    def walk(self):
+        """walk recursively through all traces"""
+        yield self.name, self
+        for trace in self:
+            for t in trace.walk():
+                yield t