diff --git a/etc/examples/2-custom-config/config.json b/etc/examples/2-custom-config/config.json
index 2911536f6dd2f3fdfac6bb9c7b756ee185667d0c..36b53806121cf46ef9f089a5b98f731671d9087a 100644
--- a/etc/examples/2-custom-config/config.json
+++ b/etc/examples/2-custom-config/config.json
@@ -1,10 +1,12 @@
 {
-  "exceedanceModel": "TRUNCATION_UPPER_ONLY",
-  "truncationLevel": 3.0,
-  "imts": ["PGA", "SA0P2", "SA1P0"],
-  "customImls": {
-    "PGA":   [0.0050, 0.0070, 0.0098, 0.0137, 0.0192, 0.0269, 0.0376, 0.0527, 0.0738, 0.103, 0.145, 0.203, 0.284, 0.397, 0.556, 0.778, 1.09, 1.52, 2.2, 3.3],
-    "SA0P2": [0.0050, 0.0075, 0.0113, 0.0169, 0.0253, 0.0380, 0.0570, 0.0854, 0.128, 0.192, 0.288, 0.432, 0.649, 0.973, 1.46, 2.19, 3.28, 4.92, 7.38],
-    "SA1P0": [0.0025, 0.00375, 0.00563, 0.00844, 0.0127, 0.0190, 0.0285, 0.0427, 0.0641, 0.0961, 0.144, 0.216, 0.324, 0.487, 0.730, 1.09, 1.64, 2.46, 3.69, 5.54]
+  "curve": {
+    "exceedanceModel": "TRUNCATION_UPPER_ONLY",
+    "truncationLevel": 3.0,
+    "imts": ["PGA", "SA0P2", "SA1P0"],
+    "customImls": {
+      "PGA":   [0.0050, 0.0070, 0.0098, 0.0137, 0.0192, 0.0269, 0.0376, 0.0527, 0.0738, 0.103, 0.145, 0.203, 0.284, 0.397, 0.556, 0.778, 1.09, 1.52, 2.2, 3.3],
+      "SA0P2": [0.0050, 0.0075, 0.0113, 0.0169, 0.0253, 0.0380, 0.0570, 0.0854, 0.128, 0.192, 0.288, 0.432, 0.649, 0.973, 1.46, 2.19, 3.28, 4.92, 7.38],
+      "SA1P0": [0.0025, 0.00375, 0.00563, 0.00844, 0.0127, 0.0190, 0.0285, 0.0427, 0.0641, 0.0961, 0.144, 0.216, 0.324, 0.487, 0.730, 1.09, 1.64, 2.46, 3.69, 5.54]
+    }
   }
 }
diff --git a/etc/examples/3-sites-file/config.json b/etc/examples/3-sites-file/config.json
index 2911536f6dd2f3fdfac6bb9c7b756ee185667d0c..36b53806121cf46ef9f089a5b98f731671d9087a 100644
--- a/etc/examples/3-sites-file/config.json
+++ b/etc/examples/3-sites-file/config.json
@@ -1,10 +1,12 @@
 {
-  "exceedanceModel": "TRUNCATION_UPPER_ONLY",
-  "truncationLevel": 3.0,
-  "imts": ["PGA", "SA0P2", "SA1P0"],
-  "customImls": {
-    "PGA":   [0.0050, 0.0070, 0.0098, 0.0137, 0.0192, 0.0269, 0.0376, 0.0527, 0.0738, 0.103, 0.145, 0.203, 0.284, 0.397, 0.556, 0.778, 1.09, 1.52, 2.2, 3.3],
-    "SA0P2": [0.0050, 0.0075, 0.0113, 0.0169, 0.0253, 0.0380, 0.0570, 0.0854, 0.128, 0.192, 0.288, 0.432, 0.649, 0.973, 1.46, 2.19, 3.28, 4.92, 7.38],
-    "SA1P0": [0.0025, 0.00375, 0.00563, 0.00844, 0.0127, 0.0190, 0.0285, 0.0427, 0.0641, 0.0961, 0.144, 0.216, 0.324, 0.487, 0.730, 1.09, 1.64, 2.46, 3.69, 5.54]
+  "curve": {
+    "exceedanceModel": "TRUNCATION_UPPER_ONLY",
+    "truncationLevel": 3.0,
+    "imts": ["PGA", "SA0P2", "SA1P0"],
+    "customImls": {
+      "PGA":   [0.0050, 0.0070, 0.0098, 0.0137, 0.0192, 0.0269, 0.0376, 0.0527, 0.0738, 0.103, 0.145, 0.203, 0.284, 0.397, 0.556, 0.778, 1.09, 1.52, 2.2, 3.3],
+      "SA0P2": [0.0050, 0.0075, 0.0113, 0.0169, 0.0253, 0.0380, 0.0570, 0.0854, 0.128, 0.192, 0.288, 0.432, 0.649, 0.973, 1.46, 2.19, 3.28, 4.92, 7.38],
+      "SA1P0": [0.0025, 0.00375, 0.00563, 0.00844, 0.0127, 0.0190, 0.0285, 0.0427, 0.0641, 0.0961, 0.144, 0.216, 0.324, 0.487, 0.730, 1.09, 1.64, 2.46, 3.69, 5.54]
+    }
   }
 }
diff --git a/etc/examples/4-hazard-map/config.json b/etc/examples/4-hazard-map/config.json
index 5615e912db28178002a05eeffb499ffd8b0098cd..3f0c8770259373994e61c9bf4de2461f024522b6 100644
--- a/etc/examples/4-hazard-map/config.json
+++ b/etc/examples/4-hazard-map/config.json
@@ -1,5 +1,7 @@
 {
-  "exceedanceModel": "TRUNCATION_UPPER_ONLY",
-  "truncationLevel": 3.0,
-  "imts": ["PGA", "SA0P2", "SA1P0"]
+  "curve": {
+    "exceedanceModel": "TRUNCATION_UPPER_ONLY",
+    "truncationLevel": 3.0,
+    "imts": ["PGA", "SA0P2", "SA1P0"]
+  }
 }
diff --git a/etc/examples/5-complex-model/config-map.json b/etc/examples/5-complex-model/config-map.json
index 741d912969225b993424e110036f7d8205aba5b4..9a474d5bcef9dbf81d33aa4bf3eab631c0463e3d 100644
--- a/etc/examples/5-complex-model/config-map.json
+++ b/etc/examples/5-complex-model/config-map.json
@@ -1,4 +1,8 @@
 {
-  "imts": ["SA1P0", "SA2P0"],
-  "outputDir": "curves-map"
+  "curve": {
+    "imts": ["SA1P0", "SA2P0"]
+  },
+  "output": {
+    "directory": "curves-map"
+  }
 }
diff --git a/etc/examples/5-complex-model/config-sites.json b/etc/examples/5-complex-model/config-sites.json
index 6093b260372efb3866702f4afd8d56117c0594a4..40ac7f7bf71f38c257f6a606dd21e1448510cf49 100644
--- a/etc/examples/5-complex-model/config-sites.json
+++ b/etc/examples/5-complex-model/config-sites.json
@@ -1,4 +1,8 @@
 {
-  "imts": ["SA1P0", "SA2P0"],
-  "outputDir": "curves-sites"
+  "curve": {
+    "imts": ["SA1P0", "SA2P0"]
+  },
+  "output": {
+    "directory": "curves-sites"
+  }
 }
diff --git a/etc/examples/6-enhanced-output/config.json b/etc/examples/6-enhanced-output/config.json
index cb4a34a48251c035a9ed803a0f9b5808e8bab771..b42a3f7334446ee973c7cd64f994e9df7ae110b8 100644
--- a/etc/examples/6-enhanced-output/config.json
+++ b/etc/examples/6-enhanced-output/config.json
@@ -1,3 +1,5 @@
 {
-  "hazardFormat": "DETAILED"
+  "output": {
+    "curveTypes": ["TOTAL", "GMM", "SOURCE"]
+  }
 }
diff --git a/src/org/opensha2/calc/CalcConfig.java b/src/org/opensha2/calc/CalcConfig.java
index a702f4b6ab572d52a77b66be83fb3a2a91f5ccfd..14716679941d57d561200892fcbb27a43d674abd 100644
--- a/src/org/opensha2/calc/CalcConfig.java
+++ b/src/org/opensha2/calc/CalcConfig.java
@@ -4,11 +4,15 @@ import static com.google.common.base.CaseFormat.LOWER_CAMEL;
 import static com.google.common.base.CaseFormat.UPPER_UNDERSCORE;
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.base.Strings.padEnd;
+import static com.google.common.base.Strings.repeat;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.opensha2.data.XySequence.create;
 import static org.opensha2.data.XySequence.immutableCopyOf;
-import static org.opensha2.util.TextUtils.format;
-import static org.opensha2.util.TextUtils.wrap;
+import static org.opensha2.util.Parsing.enumsToString;
+import static org.opensha2.util.TextUtils.NEWLINE;
+
+// import static org.opensha2.util.TextUtils.*;
 
 import java.io.IOException;
 import java.io.Reader;
@@ -22,14 +26,14 @@ import java.util.Map;
 import java.util.Map.Entry;
 import java.util.Set;
 
-import org.opensha2.calc.Results.HazardFormat;
 import org.opensha2.data.Data;
 import org.opensha2.data.XySequence;
 import org.opensha2.eq.model.SourceType;
 import org.opensha2.gmm.Gmm;
 import org.opensha2.gmm.Imt;
-import org.opensha2.util.Parsing;
+import org.opensha2.util.TextUtils;
 
+import com.google.common.base.Optional;
 import com.google.common.collect.Maps;
 import com.google.common.collect.Sets;
 import com.google.gson.Gson;
@@ -41,229 +45,607 @@ import com.google.gson.JsonParseException;
 
 /**
  * Calculation configuration.
+ * 
  * @author Peter Powers
  */
 public final class CalcConfig {
 
 	static final String FILE_NAME = "config.json";
-
-	private final Path resource;
+	private static final String ID = CalcConfig.class.getSimpleName();
+	private static final String STATE_ERROR = "%s %s not set";
+	private static final String DEFAULT_OUT = "curves";
 
 	/**
-	 * The probability distribution model to use when computing hazard curves.
+	 * The resource from which {@code this} was derived. If this configuration
+	 * was built using {@link Builder#extend(Builder)}, this field will reflect
+	 * the field of the extending resource. If this configuration was built only
+	 * from {@link Builder#withDefaults()}, this field will be empty.
 	 */
-	public final ExceedanceModel exceedanceModel;
-	// TODO refactor to probabilitModel
+	public final Optional<Path> resource;
 
-	/**
-	 * The number of standard deviations at which to truncate a distribution.
-	 * This field is ignored if a model does not implement truncation.
-	 */
-	public final double truncationLevel;
-
-	/**
-	 * The {@code Set} of IMTs for which calculations should be performed.
-	 */
-	public final Set<Imt> imts;
+	/** Hazard curve calculation settings */
+	public final Curve curve;
 
-	/**
-	 * Whether to optimize grid source sets, or not.
-	 */
-	public final boolean optimizeGrids;
+	/** Performance and optimization settings. */
+	public final Performance performance;
 
-	/**
-	 * The partition or batch size to use when distributing
-	 * {@link SourceType#SYSTEM} calculations.
-	 */
-	public final int systemPartition;
+	/** Output configuration. */
+	public final Output output;
 
-	/**
-	 * Whether to consider additional ground motion model uncertainty, or not.
-	 * Currently this is only applicable when using the PEER NGA-West or
-	 * NGA-West2 {@link Gmm}s with USGS hazard models.
-	 */
-	public final boolean gmmUncertainty;
+	/** Deaggregation configuration. */
+	public final Deagg deagg;
 
-	/** The hazard output format. */
-	public final HazardFormat hazardFormat;
+	private CalcConfig(
+			Optional<Path> resource,
+			Curve curve,
+			Performance performance,
+			Output output,
+			Deagg deagg) {
 
-	/** The directory to write any results to. */
-	public final Path outputDir;
+		this.resource = resource;
+		this.curve = curve;
+		this.performance = performance;
+		this.output = output;
+		this.deagg = deagg;
+	}
 
 	/**
-	 * The number of results to write at a time. A larger number requires more
-	 * memory.
+	 * Hazard curve calculation configuration.
 	 */
-	public final int outputBatchSize;
+	public final static class Curve {
 
-	/**
-	 * Deaggregation configuration data.
-	 */
-	public final DeaggData deagg;
+		static final String ID = CalcConfig.ID + "." + Curve.class.getSimpleName();
 
-	/*
-	 * Iml fields preserved for toString() exclusively. Imls should be retrieved
-	 * using modelCurves() or logModelCurves().
-	 */
-	private final double[] defaultImls;
-	private final Map<Imt, double[]> customImls;
-	private final Map<Imt, XySequence> modelCurves;
-	private final Map<Imt, XySequence> logModelCurves;
+		/**
+		 * The probability distribution model to use when computing hazard
+		 * curves.
+		 * 
+		 * <p><b>Default:</b> {@link ExceedanceModel#TRUNCATION_UPPER_ONLY}
+		 */
+		public final ExceedanceModel exceedanceModel;
+		// TODO refactor to probabilityModel
 
-	private CalcConfig(
-			Path resource,
-			ExceedanceModel exceedanceModel,
-			double truncationLevel,
-			Set<Imt> imts,
-			boolean optimizeGrids,
-			int systemPartition,
-			boolean gmmUncertainty,
-			HazardFormat hazardFormat,
-			Path outputDir,
-			int outputBatchSize,
-			DeaggData deagg,
-			double[] defaultImls,
-			Map<Imt, double[]> customImls,
-			Map<Imt, XySequence> modelCurves,
-			Map<Imt, XySequence> logModelCurves) {
+		/**
+		 * The number of standard deviations (σ) at which to truncate a
+		 * distribution. This field is ignored if an {@link ExceedanceModel}
+		 * does not implement truncation.
+		 * 
+		 * <p><b>Default:</b> {@code 3.0}
+		 */
+		public final double truncationLevel;
 
-		this.resource = resource;
-		this.exceedanceModel = exceedanceModel;
-		this.truncationLevel = truncationLevel;
-		this.imts = imts;
-		this.optimizeGrids = optimizeGrids;
-		this.systemPartition = systemPartition;
-		this.gmmUncertainty = gmmUncertainty;
-		this.hazardFormat = hazardFormat;
-		this.outputDir = outputDir;
-		this.outputBatchSize = outputBatchSize;
-		this.deagg = deagg;
-		this.defaultImls = defaultImls;
-		this.customImls = customImls;
-		this.modelCurves = modelCurves;
-		this.logModelCurves = logModelCurves;
-	}
+		/**
+		 * The {@code Set} of IMTs for which calculations should be performed.
+		 *
+		 * <p><b>Default:</b> [{@link Imt#PGA}, {@link Imt#SA0P2},
+		 * {@link Imt#SA1P0}]
+		 */
+		public final Set<Imt> imts;
 
-	private enum Key {
-		RESOURCE,
-		EXCEEDANCE_MODEL,
-		TRUNCATION_LEVEL,
-		IMTS,
-		OPTIMIZE_GRIDS,
-		GMM_UNCERTAINTY,
-		SYSTEM_PARTITION,
-		HAZARD_FORMAT,
-		OUTPUT_DIR,
-		OUTPUT_BATCH_SIZE,
-		DEAGG,
-		DEFAULT_IMLS,
-		CUSTOM_IMLS;
+		/**
+		 * Whether to consider additional ground motion model uncertainty, or
+		 * not. Currently this is only applicable when using the PEER NGA-West
+		 * or NGA-West2 {@link Gmm}s with USGS hazard models.
+		 * 
+		 * <p><b>Default:</b> {@code false}
+		 */
+		public final boolean gmmUncertainty;
 
-		private String label;
+		/**
+		 * The value format for hazard curves.
+		 * 
+		 * <p><b>Default:</b> {@link CurveValue#ANNUAL_RATE}
+		 */
+		public final CurveValue valueType;
+
+		private final double[] defaultImls;
+		private final Map<Imt, double[]> customImls;
+
+		private final Map<Imt, XySequence> modelCurves;
+		private final Map<Imt, XySequence> logModelCurves;
+
+		private Curve(
+				ExceedanceModel exceedanceModel,
+				double truncationLevel,
+				Set<Imt> imts,
+				boolean gmmUncertainty,
+				CurveValue valueType,
+				double[] defaultImls,
+				Map<Imt, double[]> customImls,
+				Map<Imt, XySequence> modelCurves,
+				Map<Imt, XySequence> logModelCurves) {
+
+			this.exceedanceModel = exceedanceModel;
+			this.truncationLevel = truncationLevel;
+			this.imts = imts;
+			this.gmmUncertainty = gmmUncertainty;
+			this.valueType = valueType;
+
+			this.defaultImls = defaultImls;
+			this.customImls = customImls;
+			this.modelCurves = modelCurves;
+			this.logModelCurves = logModelCurves;
+		}
 
-		private Key() {
-			this.label = UPPER_UNDERSCORE.to(LOWER_CAMEL, name());
+		/**
+		 * An empty linear curve for the requested {@code Imt}.
+		 * @param imt to get curve for
+		 */
+		public XySequence modelCurve(Imt imt) {
+			return modelCurves.get(imt);
 		}
 
-		@Override
-		public String toString() {
-			return label;
+		/**
+		 * An immutable map of model curves where x-values are in linear space.
+		 */
+		public Map<Imt, XySequence> modelCurves() {
+			return modelCurves;
 		}
-	}
 
-	@Override
-	public String toString() {
-		String customImlStr = "";
-		if (!customImls.isEmpty()) {
-			StringBuilder sb = new StringBuilder();
-			for (Entry<Imt, double[]> entry : customImls.entrySet()) {
-				String imtStr = "(override) " + entry.getKey().name();
-				sb.append(format(imtStr)).append(wrap(Arrays.toString(entry.getValue())));
+		/**
+		 * An immutable map of model curves where x-values are in natural-log
+		 * space.
+		 */
+		public Map<Imt, XySequence> logModelCurves() {
+			return logModelCurves;
+		}
+
+		private StringBuilder asString() {
+			StringBuilder imlSb = new StringBuilder();
+			if (!customImls.isEmpty()) {
+				for (Entry<Imt, double[]> entry : customImls.entrySet()) {
+					String imtStr = "imls (" + entry.getKey().name() + ")";
+					imlSb.append(formatEntry(imtStr))
+						.append(wrap(Arrays.toString(entry.getValue()), false));
+				}
 			}
-			customImlStr = sb.toString();
+			return new StringBuilder()
+				.append(formatGroup("Curve"))
+				.append(formatEntry(Key.EXCEEDANCE_MODEL, exceedanceModel))
+				.append(formatEntry(Key.TRUNCATION_LEVEL, truncationLevel))
+				.append(formatEntry(Key.IMTS, enumsToString(imts, Imt.class)))
+				.append(formatEntry(Key.GMM_UNCERTAINTY, gmmUncertainty))
+				.append(formatEntry(Key.VALUE_TYPE, valueType))
+				.append(formatEntry(Key.DEFAULT_IMLS, wrap(Arrays.toString(defaultImls), false)))
+				.append(imlSb);
 		}
 
-		return new StringBuilder("Calc config:")
-			.append(format(Key.RESOURCE)).append(resource.toAbsolutePath().normalize())
-			.append(format(Key.EXCEEDANCE_MODEL)).append(exceedanceModel)
-			.append(format(Key.TRUNCATION_LEVEL)).append(truncationLevel)
-			.append(format(Key.IMTS)).append(Parsing.enumsToString(imts, Imt.class))
-			.append(format(Key.DEFAULT_IMLS)).append(wrap(Arrays.toString(defaultImls)))
-			.append(customImlStr)
-			.append(format(Key.OPTIMIZE_GRIDS)).append(optimizeGrids)
-			.append(format(Key.SYSTEM_PARTITION)).append(systemPartition)
-			.append(format(Key.GMM_UNCERTAINTY)).append(gmmUncertainty)
-			.append(format(Key.HAZARD_FORMAT)).append(hazardFormat)
-			.append(format(Key.OUTPUT_DIR)).append(outputDir.toAbsolutePath().normalize())
-			.append(format(Key.OUTPUT_BATCH_SIZE)).append(outputBatchSize)
-			.append(format("Deaggregation R"))
-			.append("min=").append(deagg.rMin).append(", ")
-			.append("max=").append(deagg.rMax).append(", ")
-			.append("Δ=").append(deagg.Δr)
-			.append(format("Deaggregation M"))
-			.append("min=").append(deagg.mMin).append(", ")
-			.append("max=").append(deagg.mMax).append(", ")
-			.append("Δ=").append(deagg.Δm)
-			.append(format("Deaggregation ε"))
-			.append("min=").append(deagg.εMin).append(", ")
-			.append("max=").append(deagg.εMax).append(", ")
-			.append("Δ=").append(deagg.Δε)
-			.toString();
-	}
+		private static final class Builder {
+
+			ExceedanceModel exceedanceModel;
+			Double truncationLevel;
+			Set<Imt> imts;
+			Boolean gmmUncertainty;
+			CurveValue valueType;
+			double[] defaultImls;
+			Map<Imt, double[]> customImls;
+
+			Curve build() {
+				return new Curve(
+					exceedanceModel,
+					truncationLevel,
+					Sets.immutableEnumSet(imts),
+					gmmUncertainty,
+					valueType,
+					defaultImls,
+					customImls,
+					createCurveMap(),
+					createLogCurveMap());
+			}
 
-	/**
-	 * An empty linear curve for the requested {@code Imt}.
-	 * @param imt to get curve for
-	 */
-	public XySequence modelCurve(Imt imt) {
-		return modelCurves.get(imt);
+			void copy(Curve that) {
+				this.exceedanceModel = that.exceedanceModel;
+				this.truncationLevel = that.truncationLevel;
+				this.imts = that.imts;
+				this.gmmUncertainty = that.gmmUncertainty;
+				this.valueType = that.valueType;
+				this.defaultImls = that.defaultImls;
+				this.customImls = that.customImls;
+			}
+
+			void extend(Builder that) {
+				if (that.exceedanceModel != null) this.exceedanceModel = that.exceedanceModel;
+				if (that.truncationLevel != null) this.truncationLevel = that.truncationLevel;
+				if (that.imts != null) this.imts = that.imts;
+				if (that.gmmUncertainty != null) this.gmmUncertainty = that.gmmUncertainty;
+				if (that.valueType != null) this.valueType = that.valueType;
+				if (that.defaultImls != null) this.defaultImls = that.defaultImls;
+				if (that.customImls != null) this.customImls = that.customImls;
+			}
+
+			static Builder defaults() {
+				Builder b = new Builder();
+				b.exceedanceModel = ExceedanceModel.TRUNCATION_UPPER_ONLY;
+				b.truncationLevel = 3.0;
+				b.imts = EnumSet.of(Imt.PGA, Imt.SA0P2, Imt.SA1P0);
+				b.gmmUncertainty = false;
+				b.valueType = CurveValue.ANNUAL_RATE;
+				// Slightly modified version of NSHM 5Hz curve, size = 20
+				b.defaultImls = new double[] { 0.0025, 0.0045, 0.0075, 0.0113, 0.0169, 0.0253,
+						0.0380, 0.0570, 0.0854, 0.128, 0.192, 0.288, 0.432, 0.649, 0.973, 1.46,
+						2.19, 3.28, 4.92, 7.38 };
+				b.customImls = Maps.newHashMap();
+				return b;
+			}
+
+			void validate() {
+				checkNotNull(exceedanceModel, STATE_ERROR, Curve.ID, Key.EXCEEDANCE_MODEL);
+				checkNotNull(truncationLevel, STATE_ERROR, Curve.ID, Key.TRUNCATION_LEVEL);
+				checkNotNull(imts, STATE_ERROR, Curve.ID, Key.IMTS);
+				checkNotNull(defaultImls, STATE_ERROR, Curve.ID, Key.DEFAULT_IMLS);
+				checkNotNull(customImls, STATE_ERROR, Curve.ID, Key.CUSTOM_IMLS);
+			}
+
+			Map<Imt, XySequence> createLogCurveMap() {
+				Map<Imt, XySequence> curveMap = Maps.newEnumMap(Imt.class);
+				for (Imt imt : imts) {
+					double[] imls = imlsForImt(imt);
+					imls = Arrays.copyOf(imls, imls.length);
+					Data.ln(imls);
+					curveMap.put(imt, immutableCopyOf(create(imls, null)));
+				}
+				return Maps.immutableEnumMap(curveMap);
+			}
+
+			Map<Imt, XySequence> createCurveMap() {
+				Map<Imt, XySequence> curveMap = Maps.newEnumMap(Imt.class);
+				for (Imt imt : imts) {
+					double[] imls = imlsForImt(imt);
+					imls = Arrays.copyOf(imls, imls.length);
+					curveMap.put(imt, immutableCopyOf(create(imls, null)));
+				}
+				return Maps.immutableEnumMap(curveMap);
+			}
+
+			double[] imlsForImt(Imt imt) {
+				return customImls.containsKey(imt) ? customImls.get(imt) : defaultImls;
+			}
+		}
 	}
 
 	/**
-	 * An immutable map of model curves where x-values are in linear space.
+	 * Performance and optimization settings.
 	 */
-	public Map<Imt, XySequence> modelCurves() {
-		return modelCurves;
+	public static final class Performance {
+
+		static final String ID = CalcConfig.ID + "." + Performance.class.getSimpleName();
+
+		/**
+		 * Whether to optimize grid source sets, or not.
+		 * 
+		 * <p><b>Default:</b> {@code true}
+		 */
+		public final boolean optimizeGrids;
+
+		/**
+		 * Whether to collapse/combine magnitude-frequency distributions, or
+		 * not. Doing so prevents uncertainty analysis as logic-tree branches
+		 * are obscured.
+		 * 
+		 * <p><b>Default:</b> {@code true}
+		 */
+		public final boolean collapseMfds;
+
+		/**
+		 * The partition or batch size to use when distributing
+		 * {@link SourceType#SYSTEM} calculations.
+		 * 
+		 * <p><b>Default:</b> {@code 1000}
+		 */
+		public final int systemPartition;
+
+		/**
+		 * The number of threads to use when distributing calculations.
+		 * 
+		 * <p><b>Default:</b> {@link ThreadCount#ALL}
+		 */
+		public final ThreadCount threadCount;
+
+		private Performance(
+				boolean optimizeGrids,
+				boolean collapseMfds,
+				int systemPartition,
+				ThreadCount threadCount) {
+
+			this.optimizeGrids = optimizeGrids;
+			this.collapseMfds = collapseMfds;
+			this.systemPartition = systemPartition;
+			this.threadCount = threadCount;
+		}
+
+		private StringBuilder asString() {
+			return new StringBuilder()
+				.append(formatGroup("Performance"))
+				.append(formatEntry(Key.OPTIMIZE_GRIDS, optimizeGrids))
+				.append(formatEntry(Key.COLLAPSE_MFDS, collapseMfds))
+				.append(formatEntry(Key.SYSTEM_PARTITION, systemPartition))
+				.append(formatEntry(Key.THREAD_COUNT, threadCount));
+		}
+
+		private static final class Builder {
+
+			Boolean optimizeGrids;
+			Boolean collapseMfds;
+			Integer systemPartition;
+			ThreadCount threadCount;
+
+			Performance build() {
+				return new Performance(
+					optimizeGrids,
+					collapseMfds,
+					systemPartition,
+					threadCount);
+			}
+
+			void copy(Performance that) {
+				this.optimizeGrids = that.optimizeGrids;
+				this.collapseMfds = that.collapseMfds;
+				this.systemPartition = that.systemPartition;
+				this.threadCount = that.threadCount;
+			}
+
+			void extend(Builder that) {
+				if (that.optimizeGrids != null) this.optimizeGrids = that.optimizeGrids;
+				if (that.collapseMfds != null) this.collapseMfds = that.collapseMfds;
+				if (that.systemPartition != null) this.systemPartition = that.systemPartition;
+				if (that.threadCount != null) this.threadCount = that.threadCount;
+			}
+
+			static Builder defaults() {
+				Builder b = new Builder();
+				b.optimizeGrids = true;
+				b.collapseMfds = true;
+				b.systemPartition = 1000;
+				b.threadCount = ThreadCount.ALL;
+				return b;
+			}
+
+			void validate() {
+				checkNotNull(optimizeGrids, STATE_ERROR, Performance.ID, Key.OPTIMIZE_GRIDS);
+				checkNotNull(collapseMfds, STATE_ERROR, Performance.ID, Key.COLLAPSE_MFDS);
+				checkNotNull(systemPartition, STATE_ERROR, Performance.ID, Key.SYSTEM_PARTITION);
+				checkNotNull(threadCount, STATE_ERROR, Performance.ID, Key.THREAD_COUNT);
+			}
+		}
 	}
 
 	/**
-	 * An immutable map of model curves where x-values are in natural-log space.
+	 * Hazard curve and file output settings.
 	 */
-	public Map<Imt, XySequence> logModelCurves() {
-		return logModelCurves;
+	public static final class Output {
+
+		static final String ID = CalcConfig.ID + "." + Output.class.getSimpleName();
+
+		/**
+		 * The directory to write any results to.
+		 * 
+		 * <p><b>Default:</b> {@code "curves"}
+		 */
+		public final Path directory;
+
+		/**
+		 * The different {@linkplain CurveType types} of curves to save. Note
+		 * that {@link CurveType#TOTAL} will <i>always</i> be included in this
+		 * set, regardless of any user settings.
+		 * 
+		 * <p><b>Default:</b> [{@link CurveType#TOTAL}]
+		 */
+		public final Set<CurveType> curveTypes;
+
+		/**
+		 * The number of results (one per {@code Site}) to store before writing
+		 * to file(s). A larger number requires more memory.
+		 * 
+		 * <p><b>Default:</b> {@code 20}
+		 */
+		public final int flushLimit;
+
+		private Output(
+				Path directory,
+				Set<CurveType> curveTypes,
+				int flushLimit) {
+
+			this.directory = directory;
+			curveTypes.add(CurveType.TOTAL);
+			this.curveTypes = Sets.immutableEnumSet(curveTypes);
+			this.flushLimit = flushLimit;
+		}
+
+		private StringBuilder asString() {
+			return new StringBuilder()
+				.append(formatGroup("Output"))
+				.append(formatEntry(Key.DIRECTORY, directory.toAbsolutePath().normalize()))
+				.append(formatEntry(Key.CURVE_TYPES, enumsToString(curveTypes, CurveType.class)))
+				.append(formatEntry(Key.FLUSH_LIMIT, flushLimit));
+		}
+
+		private static final class Builder {
+
+			Path directory;
+			Set<CurveType> curveTypes;
+			Integer flushLimit;
+
+			Output build() {
+				return new Output(
+					directory,
+					curveTypes,
+					flushLimit);
+			}
+
+			void copy(Output that) {
+				this.directory = that.directory;
+				this.curveTypes = that.curveTypes;
+				this.flushLimit = that.flushLimit;
+			}
+
+			void extend(Builder that) {
+				if (that.directory != null) this.directory = that.directory;
+				if (that.curveTypes != null) this.curveTypes = that.curveTypes;
+				if (that.flushLimit != null) this.flushLimit = that.flushLimit;
+			}
+
+			static Builder defaults() {
+				Builder b = new Builder();
+				b.directory = Paths.get(DEFAULT_OUT);
+				b.curveTypes = EnumSet.of(CurveType.TOTAL);
+				b.flushLimit = 20;
+				return b;
+			}
+
+			void validate() {
+				checkNotNull(directory, STATE_ERROR, Performance.ID, Key.DIRECTORY);
+				checkNotNull(curveTypes, STATE_ERROR, Performance.ID, Key.CURVE_TYPES);
+				checkNotNull(flushLimit, STATE_ERROR, Performance.ID, Key.FLUSH_LIMIT);
+			}
+		}
 	}
 
 	/**
 	 * Deaggregation configuration data container.
 	 */
-	@SuppressWarnings("javadoc")
-	public static final class DeaggData {
+	public static final class Deagg {
 
+		static final String ID = CalcConfig.ID + "." + Deagg.class.getSimpleName();
+
+		/** Minimum distance. Lower edge of smallest distance bin. */
 		public final double rMin;
+
+		/** Maximum distance. Upper edge of largest distance bin. */
 		public final double rMax;
+
+		/** Distance bin width. */
 		public final double Δr;
 
+		/** Minimum magnitude. Lower edge of smallest magnitude bin. */
 		public final double mMin;
+
+		/** Maximum magnitude. Upper edge of largest magnitude bin. */
 		public final double mMax;
+
+		/** Magnitude bin width. */
 		public final double Δm;
 
+		/** Minimum epsilon. Lower edge of smallest epsilon bin. */
 		public final double εMin;
+
+		/** Maximum epsilon. Upper edge of largest epsilon bin. */
 		public final double εMax;
+
+		/** Epsilon bin width. */
 		public final double Δε;
 
-		DeaggData() {
+		Deagg() {
 			rMin = 0.0;
 			rMax = 100.0;
 			Δr = 10.0;
-
 			mMin = 5.0;
 			mMax = 7.0;
 			Δm = 0.1;
-
 			εMin = -3;
 			εMax = 3.0;
 			Δε = 0.5;
 		}
+
+		private StringBuilder asString() {
+			return new StringBuilder()
+				.append(formatGroup("Deaggregation"))
+				.append(formatEntry("R"))
+				.append("min=").append(rMin).append(", ")
+				.append("max=").append(rMax).append(", ")
+				.append("Δ=").append(Δr)
+				.append(formatEntry("M"))
+				.append("min=").append(mMin).append(", ")
+				.append("max=").append(mMax).append(", ")
+				.append("Δ=").append(Δm)
+				.append(formatEntry("ε"))
+				.append("min=").append(εMin).append(", ")
+				.append("max=").append(εMax).append(", ")
+				.append("Δ=").append(Δε);
+		}
+	}
+
+	private enum Key {
+		RESOURCE,
+		/* curve */
+		EXCEEDANCE_MODEL,
+		TRUNCATION_LEVEL,
+		IMTS,
+		GMM_UNCERTAINTY,
+		VALUE_TYPE,
+		DEFAULT_IMLS,
+		CUSTOM_IMLS,
+		/* performance */
+		OPTIMIZE_GRIDS,
+		COLLAPSE_MFDS,
+		SYSTEM_PARTITION,
+		THREAD_COUNT,
+		/* output */
+		DIRECTORY,
+		CURVE_TYPES,
+		FLUSH_LIMIT,
+		/* deagg */
+		DEAGG;
+
+		private String label;
+
+		private Key() {
+			this.label = UPPER_UNDERSCORE.to(LOWER_CAMEL, name());
+		}
+
+		@Override
+		public String toString() {
+			return label;
+		}
+	}
+
+	@Override
+	public String toString() {
+		return new StringBuilder(padEnd("Calc Configuration:", VALUE_INDENT_SIZE, ' '))
+			.append(resource.isPresent()
+				? resource.get().toAbsolutePath().normalize()
+				: "(from defaults)")
+			.append(curve.asString())
+			.append(performance.asString())
+			.append(output.asString())
+			.append(deagg.asString())
+			.toString();
+	}
+
+	// public static <E extends Enum<E>> String format(E id) {
+	// return format(id.toString());
+	// }
+
+	private static final int GROUP_INDENT_SIZE = 8;
+	private static final int KEY_INDENT_SIZE = 10;
+	private static final int VALUE_INDENT_SIZE = 28;
+	private static final int MAX_COL = 100;
+	private static final int VALUE_WIDTH = MAX_COL - VALUE_INDENT_SIZE;
+	private static final String S = " ";
+	private static final String GROUP_INDENT = repeat(S, GROUP_INDENT_SIZE);
+	private static final String KEY_INDENT = repeat(S, KEY_INDENT_SIZE);
+	private static final String VALUE_INDENT = repeat(S, VALUE_INDENT_SIZE);
+
+	private static String formatGroup(String group) {
+		return TextUtils.NEWLINE + GROUP_INDENT + group;
+	}
+
+	private static String formatEntry(String key) {
+		return NEWLINE + padEnd(KEY_INDENT + '.' + key + ':', VALUE_INDENT_SIZE, ' ');
+	}
+
+	private static <E extends Enum<E>> String formatEntry(E key, Object value) {
+		return NEWLINE + padEnd(KEY_INDENT + '.' + key + ':', VALUE_INDENT_SIZE, ' ') + value;
+	}
+
+	/* wrap a commma-delimited string */
+	private static String wrap(String s, boolean pad) {
+		if (s.length() <= VALUE_WIDTH) return pad ? NEWLINE + VALUE_INDENT + s : s;
+		StringBuilder sb = new StringBuilder();
+		int lastCommaIndex = s.substring(0, VALUE_WIDTH).lastIndexOf(',') + 1;
+		if (pad) sb.append(NEWLINE).append(VALUE_INDENT);
+		sb.append(s.substring(0, lastCommaIndex));
+		sb.append(wrap(s.substring(lastCommaIndex).trim(), true));
+		return sb.toString();
 	}
 
 	private static final Gson GSON = new GsonBuilder()
@@ -278,101 +660,84 @@ public final class CalcConfig {
 		})
 		.create();
 
-	/**
-	 * Create a new calculation configuration builder from the resource at the
-	 * specified {@code path}.
-	 * 
-	 * @param path to configuration file or resource
-	 * @throws IOException
-	 */
-	public static Builder builder(Path path) throws IOException {
-		checkNotNull(path);
-		// TODO test with zip files
-		Path configPath = Files.isDirectory(path) ? path.resolve(FILE_NAME) : path;
-		Reader reader = Files.newBufferedReader(configPath, UTF_8);
-		Builder configBuilder = GSON.fromJson(reader, Builder.class);
-		configBuilder.resource = configPath;
-		reader.close();
-		return configBuilder;
-	}
-
+	// TODO clean
 	public static void main(String[] args) throws IOException {
-		CalcConfig cc = builder()
+
+		// CalcConfig cc =
+		// Builder.fromFile(Paths.get("etc/examples/5-complex-model/config-sites.json")).build();
+		// System.out.println(cc);
+
+		CalcConfig cc = Builder
 			.withDefaults()
-			.extend(builder(Paths.get("etc/examples/5-complex-model/config-sites.json")))
+			// .extend(Builder.fromFile(Paths.get("etc/examples/6-enhanced-output/config.json")))
+			.extend(Builder.fromFile(Paths.get("etc/examples/2-custom-config/config.json")))
 			.build();
 		System.out.println(cc);
 	}
 
 	/**
-	 * Create a new empty calculation configuration builder.
+	 * A builder of configuration instances.
 	 */
-	public static Builder builder() {
-		return new Builder();
-	}
-
 	public static final class Builder {
 
-		private static final String ID = "CalcConfig.Builder";
 		private boolean built = false;
 
 		private Path resource;
-		private ExceedanceModel exceedanceModel;
-		private Double truncationLevel;
-		private Set<Imt> imts;
-		private double[] defaultImls;
-		private Map<Imt, double[]> customImls;
-		private Boolean optimizeGrids;
-		private Integer systemPartition;
-		private Boolean gmmUncertainty;
-		private HazardFormat hazardFormat;
-		private Path outputDir;
-		private Integer outputBatchSize;
-		private DeaggData deagg;
-
-		private Builder() {}
+		private Curve.Builder curve;
+		private Performance.Builder performance;
+		private Output.Builder output;
+		private Deagg deagg;
+
+		private Builder() {
+			curve = new Curve.Builder();
+			performance = new Performance.Builder();
+			output = new Output.Builder();
+		}
 
 		/**
-		 * Initialize a new builder with a copy of that supplied.
+		 * Initialize a new builder with values copied from the supplied config.
 		 */
-		public Builder copy(CalcConfig config) {
-			checkNotNull(config);
-			this.resource = config.resource;
-			this.exceedanceModel = config.exceedanceModel;
-			this.truncationLevel = config.truncationLevel;
-			this.imts = config.imts;
-			this.defaultImls = config.defaultImls;
-			this.customImls = config.customImls;
-			this.optimizeGrids = config.optimizeGrids;
-			this.systemPartition = config.systemPartition;
-			this.gmmUncertainty = config.gmmUncertainty;
-			this.hazardFormat = config.hazardFormat;
-			this.outputDir = config.outputDir;
-			this.outputBatchSize = config.outputBatchSize;
-			this.deagg = config.deagg;
-			return this;
+		public static Builder copyOf(CalcConfig config) {
+			Builder b = new Builder();
+			if (config.resource.isPresent()) {
+				b.resource = config.resource.get();
+			}
+			b.curve.copy(config.curve);
+			b.performance.copy(config.performance);
+			b.output.copy(config.output);
+			b.deagg = config.deagg;
+			return b;
 		}
 
 		/**
-		 * Initialize a new builder with defaults.
+		 * Create a new builder from the resource at the specified path. This
+		 * will only set those fields that are explicitely defined.
+		 * 
+		 * @param path to configuration file or resource
+		 * @throws IOException
 		 */
-		public Builder withDefaults() {
-			this.exceedanceModel = ExceedanceModel.TRUNCATION_UPPER_ONLY;
-			this.truncationLevel = 3.0;
-			this.imts = EnumSet.of(Imt.PGA, Imt.SA0P2, Imt.SA1P0);
-			// Slightly modified version of NSHM 5Hz curve, size = 20
-			this.defaultImls = new double[] { 0.0025, 0.0045, 0.0075, 0.0113, 0.0169, 0.0253,
-					0.0380, 0.0570, 0.0854, 0.128, 0.192, 0.288, 0.432, 0.649, 0.973, 1.46, 2.19,
-					3.28, 4.92, 7.38 };
-			this.customImls = Maps.newHashMap();
-			this.optimizeGrids = true;
-			this.systemPartition = 1000;
-			this.gmmUncertainty = false;
-			this.hazardFormat = HazardFormat.TOTAL;
-			this.outputDir = Paths.get("curves");
-			this.outputBatchSize = 20;
-			this.deagg = new DeaggData();
-			return this;
+		public static Builder fromFile(Path path) throws IOException {
+			checkNotNull(path);
+			// TODO test with zip files
+			Path configPath = Files.isDirectory(path) ? path.resolve(FILE_NAME) : path;
+			Reader reader = Files.newBufferedReader(configPath, UTF_8);
+			Builder b = GSON.fromJson(reader, Builder.class);
+			reader.close();
+			b.resource = configPath;
+			return b;
+		}
+
+		/**
+		 * Initialize a new builder with all fields initialized to default
+		 * values.
+		 */
+		public static Builder withDefaults() {
+			Builder b = new Builder();
+			b.curve = Curve.Builder.defaults();
+			b.performance = Performance.Builder.defaults();
+			b.output = Output.Builder.defaults();
+			b.deagg = new Deagg();
+			return b;
 		}
 
 		/**
@@ -381,18 +746,10 @@ public final class CalcConfig {
 		 */
 		public Builder extend(final Builder that) {
 			checkNotNull(that);
-			if (that.resource != null) this.resource = that.resource;
-			if (that.exceedanceModel != null) this.exceedanceModel = that.exceedanceModel;
-			if (that.truncationLevel != null) this.truncationLevel = that.truncationLevel;
-			if (that.imts != null) this.imts = that.imts;
-			if (that.defaultImls != null) this.defaultImls = that.defaultImls;
-			if (that.customImls != null) this.customImls = that.customImls;
-			if (that.optimizeGrids != null) this.optimizeGrids = that.optimizeGrids;
-			if (that.systemPartition != null) this.systemPartition = that.systemPartition;
-			if (that.gmmUncertainty != null) this.gmmUncertainty = that.gmmUncertainty;
-			if (that.hazardFormat != null) this.hazardFormat = that.hazardFormat;
-			if (that.outputDir != null) this.outputDir = that.outputDir;
-			if (that.outputBatchSize != null) this.outputBatchSize = that.outputBatchSize;
+			this.resource = that.resource;
+			this.curve.extend(that.curve);
+			this.performance.extend(that.performance);
+			this.output.extend(that.output);
 			if (that.deagg != null) this.deagg = that.deagg;
 			return this;
 		}
@@ -401,51 +758,16 @@ public final class CalcConfig {
 		 * Set the IMTs for which results should be calculated.
 		 */
 		public Builder imts(Set<Imt> imts) {
-			this.imts = checkNotNull(imts);
+			this.curve.imts = checkNotNull(imts);
 			return this;
 		}
 
-		private Map<Imt, XySequence> createLogCurveMap() {
-			Map<Imt, XySequence> curveMap = Maps.newEnumMap(Imt.class);
-			for (Imt imt : imts) {
-				double[] imls = imlsForImt(imt);
-				imls = Arrays.copyOf(imls, imls.length);
-				Data.ln(imls);
-				curveMap.put(imt, immutableCopyOf(create(imls, null)));
-			}
-			return Maps.immutableEnumMap(curveMap);
-		}
-
-		private Map<Imt, XySequence> createCurveMap() {
-			Map<Imt, XySequence> curveMap = Maps.newEnumMap(Imt.class);
-			for (Imt imt : imts) {
-				double[] imls = imlsForImt(imt);
-				imls = Arrays.copyOf(imls, imls.length);
-				curveMap.put(imt, immutableCopyOf(create(imls, null)));
-			}
-			return Maps.immutableEnumMap(curveMap);
-		}
-
-		private double[] imlsForImt(Imt imt) {
-			return customImls.containsKey(imt) ? customImls.get(imt) : defaultImls;
-		}
-
-		private static final String MSSG = "%s %s not set";
-
-		private void validateState(String buildId) {
-			checkState(!built, "This %s instance as already been used", buildId);
-			checkNotNull(exceedanceModel, MSSG, buildId, Key.EXCEEDANCE_MODEL);
-			checkNotNull(truncationLevel, MSSG, buildId, Key.TRUNCATION_LEVEL);
-			checkNotNull(imts, MSSG, buildId, Key.IMTS);
-			checkNotNull(defaultImls, MSSG, buildId, Key.DEFAULT_IMLS);
-			checkNotNull(customImls, MSSG, buildId, Key.CUSTOM_IMLS);
-			checkNotNull(optimizeGrids, MSSG, buildId, Key.OPTIMIZE_GRIDS);
-			checkNotNull(systemPartition, MSSG, buildId, Key.SYSTEM_PARTITION);
-			checkNotNull(gmmUncertainty, MSSG, buildId, Key.GMM_UNCERTAINTY);
-			checkNotNull(hazardFormat, MSSG, buildId, Key.HAZARD_FORMAT);
-			checkNotNull(outputDir, MSSG, buildId, Key.OUTPUT_DIR);
-			checkNotNull(outputBatchSize, MSSG, buildId, Key.OUTPUT_BATCH_SIZE);
-			checkNotNull(deagg, MSSG, buildId, Key.DEAGG);
+		private void validateState() {
+			checkState(!built, "This %s instance as already been used", ID + ".Builder");
+			curve.validate();
+			performance.validate();
+			output.validate();
+			checkNotNull(deagg, STATE_ERROR, Deagg.ID, "deagg");
 			built = true;
 		}
 
@@ -453,36 +775,14 @@ public final class CalcConfig {
 		 * Build a new calculation configuration.
 		 */
 		public CalcConfig build() {
-			validateState(ID);
+			validateState();
 			return new CalcConfig(
-				resource,
-				exceedanceModel,
-				truncationLevel,
-				Sets.immutableEnumSet(imts),
-				optimizeGrids,
-				systemPartition,
-				gmmUncertainty,
-				hazardFormat,
-				outputDir,
-				outputBatchSize,
-				deagg,
-				defaultImls,
-				customImls,
-				createCurveMap(),
-				createLogCurveMap());
+				Optional.fromNullable(resource),
+				curve.build(),
+				performance.build(),
+				output.build(),
+				deagg);
 		}
 	}
 
-	// static final class PathDeserializer implements JsonDeserializer<Path> {
-	//
-	// @Override
-	// public Path deserialize(
-	// JsonElement json,
-	// Type type,
-	// JsonDeserializationContext context) {
-	//
-	//
-	// }
-	// }
-
 }
diff --git a/src/org/opensha2/calc/Calcs.java b/src/org/opensha2/calc/Calcs.java
index bf441b90bb690dedd831fe18e79280b52eeb7af2..df2a653888caa771b53751efe1cedd3292187e28 100644
--- a/src/org/opensha2/calc/Calcs.java
+++ b/src/org/opensha2/calc/Calcs.java
@@ -156,7 +156,7 @@ public class Calcs {
 
 				case GRID:
 					GridSourceSet gss = (GridSourceSet) sourceSet;
-					if (config.optimizeGrids && gss.sourceType() != FIXED_STRIKE) {
+					if (config.performance.optimizeGrids && gss.sourceType() != FIXED_STRIKE) {
 						gridTables.add(transform(immediateFuture(gss),
 							GridSourceSet.toTableFunction(site.location), ex));
 						break;
@@ -210,7 +210,7 @@ public class Calcs {
 			switch (sourceSet.type()) {
 				case GRID:
 					GridSourceSet gss = (GridSourceSet) sourceSet;
-					if (config.optimizeGrids && gss.sourceType() != FIXED_STRIKE) {
+					if (config.performance.optimizeGrids && gss.sourceType() != FIXED_STRIKE) {
 						sourceSet = GridSourceSet.toTableFunction(site.location).apply(gss);
 						log(log, MSSG_GRID_INIT, sourceSet.name(), duration(swSource));
 					}
diff --git a/src/org/opensha2/calc/CurveType.java b/src/org/opensha2/calc/CurveType.java
new file mode 100644
index 0000000000000000000000000000000000000000..a7172ba86ff39953588c0b34315e49f62b44bf2f
--- /dev/null
+++ b/src/org/opensha2/calc/CurveType.java
@@ -0,0 +1,31 @@
+package org.opensha2.calc;
+
+import org.opensha2.eq.model.SourceType;
+import org.opensha2.gmm.Gmm;
+
+/**
+ * Curve type identifiers. These are used to specify the different types of
+ * hazard curves that should be saved after a calculation is complete.
+ *
+ * @author Peter Powers
+ */
+public enum CurveType {
+
+	/** Total mean hazard curves. */
+	TOTAL,
+
+	/** {@linkplain Gmm Ground motion model} curves. */
+	GMM,
+
+	/** Hazard curves by {@link SourceType} */
+	SOURCE,
+
+	/**
+	 * Binary hazard curves. Binary curves may only be saved for map
+	 * calculations for which a map 'extents' region has been defined. See the
+	 * <a href=
+	 * "https://github.com/usgs/nshmp-haz/wiki/Sites#geojson-format-geojson"
+	 * target="_top"> site specification</a> page for more details.
+	 */
+	BINARY;
+}
diff --git a/src/org/opensha2/calc/Deaggregation.java b/src/org/opensha2/calc/Deaggregation.java
index 45f748cf8ba7d016218bf9b3484c6a0a54c483e6..0a34f659118598201872e9ef6b866a3ea6eca816 100644
--- a/src/org/opensha2/calc/Deaggregation.java
+++ b/src/org/opensha2/calc/Deaggregation.java
@@ -16,7 +16,7 @@ import java.util.Map;
 import java.util.Map.Entry;
 import java.util.Set;
 
-import org.opensha2.calc.CalcConfig.DeaggData;
+import org.opensha2.calc.CalcConfig;
 import org.opensha2.data.Data;
 import org.opensha2.data.DataTable;
 import org.opensha2.data.DataTables;
@@ -221,8 +221,8 @@ public final class Deaggregation {
 				.dataModel(
 					Dataset.builder(hazard.config).build())
 				.probabilityModel(
-					hazard.config.exceedanceModel,
-					hazard.config.truncationLevel);
+					hazard.config.curve.exceedanceModel,
+					hazard.config.curve.truncationLevel);
 		}
 
 		/* Reusable builder */
@@ -908,7 +908,7 @@ public final class Deaggregation {
 		 * @see CalcConfig
 		 */
 		static Builder builder(CalcConfig config) {
-			DeaggData d = config.deagg;
+			CalcConfig.Deagg d = config.deagg;
 			return builder(
 				d.rMin, d.rMax, d.Δr,
 				d.mMin, d.mMax, d.Δm,
diff --git a/src/org/opensha2/calc/Hazard.java b/src/org/opensha2/calc/Hazard.java
index 935fef1c845753e2fefd3efe392397f71e41a25e..d2ed5a780d35e61f34805ed1bd14919cfe8f45bb 100644
--- a/src/org/opensha2/calc/Hazard.java
+++ b/src/org/opensha2/calc/Hazard.java
@@ -72,7 +72,8 @@ public final class Hazard {
 						sb.append(curveSet.hazardGroundMotionsList.get(0).inputs.size());
 						break;
 					case GRID:
-						if (ss instanceof GridSourceSet.Table && config.optimizeGrids) {
+						boolean optimized = config.performance.optimizeGrids;
+						if (ss instanceof GridSourceSet.Table && optimized) {
 							GridSourceSet.Table gsst = (GridSourceSet.Table) curveSet.sourceSet;
 							sb.append(gsst.parentCount());
 							sb.append(" (").append(curveSet.hazardGroundMotionsList.size());
@@ -143,7 +144,7 @@ public final class Hazard {
 		private Builder(CalcConfig config) {
 			this.config = checkNotNull(config);
 			totalCurves = new EnumMap<>(Imt.class);
-			for (Entry<Imt, XySequence> entry : config.logModelCurves().entrySet()) {
+			for (Entry<Imt, XySequence> entry : config.curve.logModelCurves().entrySet()) {
 				totalCurves.put(entry.getKey(), emptyCopyOf(entry.getValue()));
 			}
 			curveMapBuilder = ImmutableSetMultimap.builder();
diff --git a/src/org/opensha2/calc/Results.java b/src/org/opensha2/calc/Results.java
index 8ab56900fd49e3c3b20c0662e3de9da1c7ae4d1d..1b94ad4646b3785f685585ffa2b3c4af7e5e73e5 100644
--- a/src/org/opensha2/calc/Results.java
+++ b/src/org/opensha2/calc/Results.java
@@ -43,30 +43,6 @@ public class Results {
 	private static final String CURVE_FILE_SUFFIX = ".csv";
 	private static final String RATE_FMT = "%.8e";
 
-	/**
-	 * Hazard output format.
-	 */
-	public enum HazardFormat {
-
-		/** Total mean hazard only. */
-		TOTAL,
-
-		/** Additional curves by {@link Gmm} and {@link SourceType}. */
-		DETAILED;
-	}
-	
-	/**
-	 * Curve format.
-	 */
-	public enum CurveFormat {
-		
-		/** Write curves as annual-rate. */
-		ANNUAL_RATE,
-		
-		/** Write curves as Poisson probabilities. */
-		POISSON;
-	}
-
 	/**
 	 * Write a {@code batch} of {@code HazardResult}s to files in the specified
 	 * directory, one for each {@link Imt} in the {@code batch}. See
@@ -105,7 +81,7 @@ public class Results {
 				headings.add("lat");
 				Iterable<?> header = Iterables.concat(
 					headings,
-					demo.config.modelCurves().get(imt).xValues());
+					demo.config.curve.modelCurves().get(imt).xValues());
 				lineList.add(Parsing.join(header, Delimiter.COMMA));
 			}
 			lineMap.put(imt, lineList);
@@ -170,7 +146,7 @@ public class Results {
 		Hazard demo = batch.get(0);
 		boolean newFile = options.length == 0;
 		boolean namedSites = demo.site.name != Site.NO_NAME;
-		boolean detailed = demo.config.hazardFormat.equals(HazardFormat.DETAILED);
+		boolean detailed = false; //demo.config.hazardFormat.equals(HazardFormat.DETAILED);
 
 		Map<Imt, List<String>> totalLineMap = Maps.newEnumMap(Imt.class);
 		Map<Imt, Map<SourceType, List<String>>> typeLineMap = Maps.newEnumMap(Imt.class);
@@ -182,7 +158,7 @@ public class Results {
 			if (newFile) {
 				Iterable<?> header = Iterables.concat(
 					Lists.newArrayList(namedSites ? "name" : null, "lon", "lat"),
-					demo.config.modelCurves().get(imt).xValues());
+					demo.config.curve.modelCurves().get(imt).xValues());
 				lines.add(Parsing.join(header, Delimiter.COMMA));
 			}
 			totalLineMap.put(imt, lines);
diff --git a/src/org/opensha2/calc/ThreadCount.java b/src/org/opensha2/calc/ThreadCount.java
index eaef8470fec2c89baf5e24aca1a7f9fe5c148614..cbae631351e8fee64532d85a9339b425e89788c0 100644
--- a/src/org/opensha2/calc/ThreadCount.java
+++ b/src/org/opensha2/calc/ThreadCount.java
@@ -14,7 +14,8 @@ public enum ThreadCount {
 	/**
 	 * A single thread. Use of a single thread will generally prevent an
 	 * {@link ExecutorService} from being used, and all calculations will be run
-	 * on the thread from which a program was called.
+	 * on the thread from which a program was called. This is useful for
+	 * debugging.
 	 */
 	ONE,
 
diff --git a/src/org/opensha2/calc/Transforms.java b/src/org/opensha2/calc/Transforms.java
index 6c27d5c22a49793b4fcc35b29adcdb7a005620f6..3f44327a2475da96991ad2b4ba93fc9f4d88b0bb 100644
--- a/src/org/opensha2/calc/Transforms.java
+++ b/src/org/opensha2/calc/Transforms.java
@@ -152,9 +152,9 @@ final class Transforms {
 		private final double truncationLevel;
 
 		GroundMotionsToCurves(CalcConfig config) {
-			this.modelCurves = config.logModelCurves();
-			this.exceedanceModel = config.exceedanceModel;
-			this.truncationLevel = config.truncationLevel;
+			this.modelCurves = config.curve.logModelCurves();
+			this.exceedanceModel = config.curve.exceedanceModel;
+			this.truncationLevel = config.curve.truncationLevel;
 		}
 
 		@Override
@@ -210,9 +210,9 @@ final class Transforms {
 
 		GroundMotionsToCurvesWithUncertainty(GmmSet gmmSet, CalcConfig config) {
 			this.gmmSet = gmmSet;
-			this.modelCurves = config.logModelCurves();
-			this.exceedanceModel = config.exceedanceModel;
-			this.truncationLevel = config.truncationLevel;
+			this.modelCurves = config.curve.logModelCurves();
+			this.exceedanceModel = config.curve.exceedanceModel;
+			this.truncationLevel = config.curve.truncationLevel;
 		}
 
 		@Override
@@ -307,12 +307,12 @@ final class Transforms {
 
 			GmmSet gmmSet = sources.groundMotionModels();
 			Map<Imt, Map<Gmm, GroundMotionModel>> gmmTable = instances(
-				config.imts,
+				config.curve.imts,
 				gmmSet.gmms());
 
 			this.sourceToInputs = new SourceToInputs(site);
 			this.inputsToGroundMotions = new InputsToGroundMotions(gmmTable);
-			this.groundMotionsToCurves = config.gmmUncertainty && gmmSet.epiUncertainty()
+			this.groundMotionsToCurves = config.curve.gmmUncertainty && gmmSet.epiUncertainty()
 				? new GroundMotionsToCurvesWithUncertainty(gmmSet, config)
 				: new GroundMotionsToCurves(config);
 		}
@@ -340,7 +340,7 @@ final class Transforms {
 				CalcConfig config) {
 
 			this.sources = sources;
-			this.modelCurves = config.logModelCurves();
+			this.modelCurves = config.curve.logModelCurves();
 		}
 
 		@Override
@@ -396,14 +396,14 @@ final class Transforms {
 
 			GmmSet gmmSet = sources.groundMotionModels();
 			Map<Imt, Map<Gmm, GroundMotionModel>> gmmTable = instances(
-				config.imts,
+				config.curve.imts,
 				gmmSet.gmms());
 
 			InputsToGroundMotions inputsToGm = new InputsToGroundMotions(gmmTable);
 			GroundMotions gms = inputsToGm.apply(inputs);
 
 			Function<GroundMotions, HazardCurves> gmToCurves =
-				config.gmmUncertainty && gmmSet.epiUncertainty()
+				config.curve.gmmUncertainty && gmmSet.epiUncertainty()
 					? new GroundMotionsToCurvesWithUncertainty(gmmSet, config)
 					: new GroundMotionsToCurves(config);
 			HazardCurves curves = gmToCurves.apply(gms);
@@ -447,7 +447,8 @@ final class Transforms {
 			// calculate curves from list in parallel
 			InputsToCurves inputsToCurves = new InputsToCurves(sources, config);
 			AsyncList<HazardCurves> asyncCurvesList = AsyncList.create();
-			for (InputList partition : master.partition(config.systemPartition)) {
+			int size = config.performance.systemPartition;
+			for (InputList partition : master.partition(size)) {
 				asyncCurvesList.add(transform(
 					immediateFuture(partition),
 					inputsToCurves,
@@ -481,11 +482,11 @@ final class Transforms {
 
 			GmmSet gmmSet = sources.groundMotionModels();
 			Map<Imt, Map<Gmm, GroundMotionModel>> gmmTable = instances(
-				config.imts,
+				config.curve.imts,
 				gmmSet.gmms());
 
 			this.inputsToGroundMotions = new InputsToGroundMotions(gmmTable);
-			this.groundMotionsToCurves = config.gmmUncertainty && gmmSet.epiUncertainty()
+			this.groundMotionsToCurves = config.curve.gmmUncertainty && gmmSet.epiUncertainty()
 				? new GroundMotionsToCurvesWithUncertainty(gmmSet, config)
 				: new GroundMotionsToCurves(config);
 		}
@@ -560,9 +561,9 @@ final class Transforms {
 		private final double truncationLevel;
 
 		ClusterGroundMotionsToCurves(CalcConfig config) {
-			this.logModelCurves = config.logModelCurves();
-			this.exceedanceModel = config.exceedanceModel;
-			this.truncationLevel = config.truncationLevel;
+			this.logModelCurves = config.curve.logModelCurves();
+			this.exceedanceModel = config.curve.exceedanceModel;
+			this.truncationLevel = config.curve.truncationLevel;
 		}
 
 		@Override
@@ -635,7 +636,7 @@ final class Transforms {
 				Site site) {
 
 			Set<Gmm> gmms = sources.groundMotionModels().gmms();
-			Map<Imt, Map<Gmm, GroundMotionModel>> gmmTable = instances(config.imts, gmms);
+			Map<Imt, Map<Gmm, GroundMotionModel>> gmmTable = instances(config.curve.imts, gmms);
 
 			this.sourceToInputs = new ClusterSourceToInputs(site);
 			this.inputsToGroundMotions = new ClusterInputsToGroundMotions(gmmTable);
@@ -666,7 +667,7 @@ final class Transforms {
 				CalcConfig config) {
 
 			this.sources = sources;
-			this.modelCurves = config.logModelCurves();
+			this.modelCurves = config.curve.logModelCurves();
 		}
 
 		@Override
diff --git a/src/org/opensha2/eq/model/Loader.java b/src/org/opensha2/eq/model/Loader.java
index 5c4e141f4af5bcb95e5c760cb77607e7de4c86bc..6b950cceed864696e814f52d300c611c50ed2640 100644
--- a/src/org/opensha2/eq/model/Loader.java
+++ b/src/org/opensha2/eq/model/Loader.java
@@ -87,12 +87,11 @@ class Loader {
 			checkArgument(Files.exists(path), "Specified model does not exist: %s", path);
 			Path typeDirPath = typeDirectory(path);
 
-			ModelConfig modelConfig = ModelConfig.builder(typeDirPath).build();
+			ModelConfig modelConfig = ModelConfig.Builder.fromFile(typeDirPath).build();
 			log.info(modelConfig.toString());
 
-			CalcConfig calcConfig = CalcConfig.builder()
-					.withDefaults()
-					.extend(CalcConfig.builder(typeDirPath))
+			CalcConfig calcConfig = CalcConfig.Builder.withDefaults()
+					.extend(CalcConfig.Builder.fromFile(typeDirPath))
 					.build();
 			builder.config(calcConfig);
 
@@ -190,9 +189,8 @@ class Loader {
 		ModelConfig config = modelConfig;
 		Path configPath = typeDir.resolve(ModelConfig.FILE_NAME);
 		if (Files.exists(configPath)) {
-			config = ModelConfig.builder()
-				.copy(modelConfig)
-				.extend(ModelConfig.builder(configPath))
+			config = ModelConfig.Builder.copyOf(modelConfig)
+				.extend(ModelConfig.Builder.fromFile(configPath))
 				.build();
 			log.info("(override) " + config.toString());
 		}
@@ -262,9 +260,8 @@ class Loader {
 			// config
 			Path nestedConfigPath = sourceDir.resolve(ModelConfig.FILE_NAME);
 			if (Files.exists(nestedConfigPath)) {
-				nestedConfig = ModelConfig.builder()
-					.copy(parentConfig)
-					.extend(ModelConfig.builder(nestedConfigPath))
+				nestedConfig = ModelConfig.Builder.copyOf(parentConfig)
+					.extend(ModelConfig.Builder.fromFile(nestedConfigPath))
 					.build();
 				log.info("(override) " + nestedConfig.toString());
 			}
diff --git a/src/org/opensha2/eq/model/ModelConfig.java b/src/org/opensha2/eq/model/ModelConfig.java
index 3cc8ad6c9ed413536c5ea18f06c08a7f704eda48..edd4f00afa87a8c20fc39b0550e48d8e1d9ba31e 100644
--- a/src/org/opensha2/eq/model/ModelConfig.java
+++ b/src/org/opensha2/eq/model/ModelConfig.java
@@ -12,11 +12,15 @@ import java.io.Reader;
 import java.nio.file.Files;
 import java.nio.file.Path;
 
+import org.opensha2.calc.CalcConfig;
 import org.opensha2.eq.fault.surface.RuptureFloating;
 import org.opensha2.eq.model.AreaSource.GridScaling;
 
 import com.google.gson.Gson;
 import com.google.gson.GsonBuilder;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonParser;
+import com.google.gson.JsonStreamParser;
 
 /**
  * Model and calculation configuration class. No defaults; 'config.json' must be
@@ -27,6 +31,9 @@ import com.google.gson.GsonBuilder;
 final class ModelConfig {
 
 	static final String FILE_NAME = "config.json";
+	private static final String ID = ModelConfig.class.getSimpleName();
+	private static final String STATE_ERROR = "%s %s not set";
+	private static final String ELEMENT_NAME = "model";
 
 	private static final Gson GSON = new GsonBuilder().create();
 
@@ -80,7 +87,7 @@ final class ModelConfig {
 
 	@Override
 	public String toString() {
-		return new StringBuilder("Model config:")
+		return new StringBuilder("Model Configuration:")
 			.append(format(Key.NAME)).append(name)
 			.append(format(Key.RESOURCE)).append(resource.toAbsolutePath().normalize())
 			.append(format(Key.SURFACE_SPACING)).append(surfaceSpacing)
@@ -91,31 +98,6 @@ final class ModelConfig {
 			.toString();
 	}
 
-	/**
-	 * Create a new model configuration builder from the resource at the
-	 * specified {@code path}.
-	 * 
-	 * @param path to configuration file or resource
-	 * @throws IOException
-	 */
-	static Builder builder(Path path) throws IOException {
-		// TODO test with zip files
-		checkNotNull(path);
-		Path configPath = Files.isDirectory(path) ? path.resolve(FILE_NAME) : path;
-		Reader reader = Files.newBufferedReader(configPath, UTF_8);
-		Builder configBuilder = GSON.fromJson(reader, Builder.class);
-		configBuilder.resource = configPath;
-		reader.close();
-		return configBuilder;
-	}
-
-	/**
-	 * Create a new empty model configuration builder.
-	 */
-	static Builder builder() {
-		return new Builder();
-	}
-
 	final static class Builder {
 
 		private static final String ID = "ModelConfig.Builder";
@@ -129,16 +111,38 @@ final class ModelConfig {
 		private PointSourceType pointSourceType;
 		private GridScaling areaGridScaling;
 
-		Builder copy(ModelConfig config) {
-			checkNotNull(config);
-			this.name = config.name;
-			this.resource = config.resource;
-			this.surfaceSpacing = config.surfaceSpacing;
-			this.ruptureFloating = config.ruptureFloating;
-			this.ruptureVariability = config.ruptureVariability;
-			this.pointSourceType = config.pointSourceType;
-			this.areaGridScaling = config.areaGridScaling;
-			return this;
+		static Builder copyOf(ModelConfig that) {
+			checkNotNull(that);
+			Builder b = new Builder();
+			b.name = that.name;
+			b.resource = that.resource;
+			b.surfaceSpacing = that.surfaceSpacing;
+			b.ruptureFloating = that.ruptureFloating;
+			b.ruptureVariability = that.ruptureVariability;
+			b.pointSourceType = that.pointSourceType;
+			b.areaGridScaling = that.areaGridScaling;
+			return b;
+		}
+
+		static Builder fromFile(Path path) throws IOException {
+			// TODO test with zip files
+			checkNotNull(path);
+			Path configPath = Files.isDirectory(path) ? path.resolve(FILE_NAME) : path;
+			Reader reader = Files.newBufferedReader(configPath, UTF_8);
+			JsonElement modelRoot = new JsonParser()
+				.parse(reader)
+				.getAsJsonObject()
+				.get(ELEMENT_NAME);
+			Builder configBuilder = GSON.fromJson(
+				checkNotNull(
+					modelRoot,
+					"'%s' element is missing from root of config: %s",
+					ELEMENT_NAME,
+					configPath),
+				Builder.class);
+			configBuilder.resource = configPath;
+			reader.close();
+			return configBuilder;
 		}
 
 		Builder extend(Builder that) {
@@ -153,20 +157,20 @@ final class ModelConfig {
 			return this;
 		}
 
-		private void validateState(String buildId) {
-			checkState(!built, "This %s instance has already been used", buildId);
-			checkNotNull(name, "%s %s not set", buildId, Key.NAME);
-			checkNotNull(resource, "%s %s not set", buildId, Key.RESOURCE);
-			checkNotNull(surfaceSpacing, "%s %s not set", buildId, Key.SURFACE_SPACING);
-			checkNotNull(ruptureFloating, "%s %s not set", buildId, Key.RUPTURE_FLOATING);
-			checkNotNull(ruptureVariability, "%s %s not set", buildId, Key.RUPTURE_VARIABILITY);
-			checkNotNull(pointSourceType, "%s %s not set", buildId, Key.POINT_SOURCE_TYPE);
-			checkNotNull(areaGridScaling, "%s %s not set", buildId, Key.AREA_GRID_SCALING);
+		private void validateState() {
+			checkState(!built, "This %s instance has already been used", ID + ".Builder");
+			checkNotNull(name, STATE_ERROR, ID, Key.NAME);
+			checkNotNull(resource, STATE_ERROR, ID, Key.RESOURCE);
+			checkNotNull(surfaceSpacing, STATE_ERROR, ID, Key.SURFACE_SPACING);
+			checkNotNull(ruptureFloating, STATE_ERROR, ID, Key.RUPTURE_FLOATING);
+			checkNotNull(ruptureVariability, STATE_ERROR, ID, Key.RUPTURE_VARIABILITY);
+			checkNotNull(pointSourceType, STATE_ERROR, ID, Key.POINT_SOURCE_TYPE);
+			checkNotNull(areaGridScaling, STATE_ERROR, ID, Key.AREA_GRID_SCALING);
 			built = true;
 		}
 
 		ModelConfig build() {
-			validateState(ID);
+			validateState();
 			return new ModelConfig(
 				name, resource, surfaceSpacing, ruptureFloating,
 				ruptureVariability, pointSourceType, areaGridScaling);
diff --git a/src/org/opensha2/mfd/Mfds.java b/src/org/opensha2/mfd/Mfds.java
index 3472431278579653d6b82486ebcddc4e61877013..6cfc442eda2c832dd063975df8c0e96a3a62f54a 100644
--- a/src/org/opensha2/mfd/Mfds.java
+++ b/src/org/opensha2/mfd/Mfds.java
@@ -336,7 +336,7 @@ public final class Mfds {
 	/**
 	 * Return a converter between annual rate and Poisson probability.
 	 */
-	public static Converter<Double, Double> rateToProbConverter() {
+	public static Converter<Double, Double> annualRateToProbabilityConverter() {
 		return AnnRateToPoissProbConverter.INSTANCE;
 	}
 
diff --git a/src/org/opensha2/programs/HazardCalc.java b/src/org/opensha2/programs/HazardCalc.java
index 5fff8c1919e2ba31e867c9c4bf69c078a981d0a2..eb05b0344ea07dae40f62e7afce3803a8892973e 100644
--- a/src/org/opensha2/programs/HazardCalc.java
+++ b/src/org/opensha2/programs/HazardCalc.java
@@ -96,9 +96,8 @@ public class HazardCalc {
 			CalcConfig config = model.config();
 			if (argCount == 3) {
 				Path userConfigPath = Paths.get(args[2]);
-				config = CalcConfig.builder()
-					.copy(model.config())
-					.extend(CalcConfig.builder(userConfigPath))
+				config = CalcConfig.Builder.copyOf(model.config())
+					.extend(CalcConfig.Builder.fromFile(userConfigPath))
 					.build();
 			}
 			log.info(config.toString());
@@ -167,10 +166,10 @@ public class HazardCalc {
 		for (Site site : sites) {
 			Hazard result = calc(model, config, site, executor);
 			results.add(result);
-			if (results.size() == config.outputBatchSize) {
+			if (results.size() == config.output.flushLimit) {
 				OpenOption[] opts = firstBatch ? WRITE_OPTIONS : APPEND_OPTIONS;
 				firstBatch = false;
-				Results.writeResults(config.outputDir, results, opts);
+				Results.writeResults(config.output.directory, results, opts);
 				log.info("     batch: " + (count + 1) + "  " + batchWatch +
 					"  total: " + totalWatch);
 				results.clear();
@@ -181,7 +180,7 @@ public class HazardCalc {
 		// write final batch
 		if (!results.isEmpty()) {
 			OpenOption[] opts = firstBatch ? WRITE_OPTIONS : APPEND_OPTIONS;
-			Results.writeResults(config.outputDir, results, opts);
+			Results.writeResults(config.output.directory, results, opts);
 		}
 		log.info(PROGRAM + ": " + count + " complete " + totalWatch);
 
diff --git a/src/org/opensha2/util/TextUtils.java b/src/org/opensha2/util/TextUtils.java
index a7cc5f146c5c3dcabef2faa8e8540fe3e5949506..28e243ef095951c9fa8b45ea763f05778c55abf5 100644
--- a/src/org/opensha2/util/TextUtils.java
+++ b/src/org/opensha2/util/TextUtils.java
@@ -17,7 +17,9 @@ import com.google.common.base.Strings;
  */
 public class TextUtils {
 
+	/** System specific newline string. */
 	public static final String NEWLINE = StandardSystemProperty.LINE_SEPARATOR.value();
+	
 	public static final int ALIGN_COL = 24;
 	private static final int MAX_COL = 100;
 	private static final int DELTA_COL = MAX_COL - ALIGN_COL - 2;
@@ -38,26 +40,10 @@ public class TextUtils {
 		return NEWLINE + padStart(s, ALIGN_COL, ' ') + ": ";
 	}
 	
-	public static String wrap(String s) {
-		return wrap(s, false);
-	}
-	
-	/*
-	 * Used internally; pad flag indents lines consistent with format(s)
-	 */
-	private static String wrap(String s, boolean pad) {
-		if (s.length() <= DELTA_COL) return pad ? INDENT + s : s;
-		StringBuilder sb = new StringBuilder();
-		int lastCommaIndex = s.substring(0, DELTA_COL).lastIndexOf(',') + 1;
-		if (pad) sb.append(INDENT);
-		sb.append(s.substring(0, lastCommaIndex));
-		sb.append(wrap(s.substring(lastCommaIndex).trim(), true));
-		return sb.toString();
-	}
-
 	/**
 	 * Verifies that the supplied {@code String} is neither {@code null} or
 	 * empty. Method returns the supplied value and can be used inline.
+	 * 
 	 * @param name to verify
 	 * @throws IllegalArgumentException if name is {@code null} or empty
 	 */
diff --git a/test/org/opensha2/eq/model/peer/PeerTest.java b/test/org/opensha2/eq/model/peer/PeerTest.java
index 4926b32db3cb4063669ac3d57dd55c946458141a..edd200fcf76834911e86387cdf6a39c8ee8db459 100644
--- a/test/org/opensha2/eq/model/peer/PeerTest.java
+++ b/test/org/opensha2/eq/model/peer/PeerTest.java
@@ -122,7 +122,7 @@ public class PeerTest {
 		// compute y-values converting to Poiss prob
 		double[] actual = Doubles.toArray(
 			FluentIterable.from(result.curves().get(Imt.PGA).yValues())
-				.transform(Mfds.rateToProbConverter())
+				.transform(Mfds.annualRateToProbabilityConverter())
 				.toList());
 		checkArgument(actual.length == expected.length);
 
@@ -146,8 +146,8 @@ public class PeerTest {
 		Iterable<Site> sites = Sites.fromCsv(MODEL_DIR.resolve(modelId).resolve("sites.csv"));
 
 		// ensure that only PGA is being used
-		checkState(config.imts.size() == 1);
-		checkState(config.imts.iterator().next() == Imt.PGA);
+		checkState(config.curve.imts.size() == 1);
+		checkState(config.curve.imts.iterator().next() == Imt.PGA);
 
 		List<Object[]> argsList = new ArrayList<>();
 		for (Site site : sites) {