diff --git a/gradle/dependencies.gradle b/gradle/dependencies.gradle
index 2a81e3a09e2c2fb9f3dfbaa7a03a4c847efc020d..af19505cd1bef520ab22beb648c802e286e8c3b5 100644
--- a/gradle/dependencies.gradle
+++ b/gradle/dependencies.gradle
@@ -1,6 +1,8 @@
 
 dependencies {
+
   // NSHMP
+  // implementation files('../nshmp-lib/build/libs/nshmp-lib.jar')
   implementation "ghsc:nshmp-lib:${nshmpLibVersion}"
   implementation "ghsc:nshmp-ws-utils:${nshmpWsUtilsVersion}"
 
diff --git a/settings.gradle b/settings.gradle
index 90c2faad1fbd25d62f6b46e92c5d1c0eaf72b53d..0daffad9b55fb3fc714dee47c7498518e07b9de6 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -20,6 +20,9 @@ git {
     fetch("https://code.usgs.gov/ghsc/nshmp/nshms/nshm-hawaii.git", {
       name "nshmp-haz-dep--nshm-hi-2021"
       tag "2.0.0"
+      // fetch("https://code.usgs.gov/ghsc/nshmp/nshms/nshm-conus.git", {
+      //   name "nshmp-haz-dep--nshm-conus-2018"
+      //   tag "main"
     })
   }
 }
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/DisaggEpsilon.java b/src/main/java/gov/usgs/earthquake/nshmp/DisaggEpsilon.java
index d10e9eda757c0772d7979048479886afb6c1b511..e4d039601024e06a0ac5b2df0f01b3bf06aeaed1 100644
--- a/src/main/java/gov/usgs/earthquake/nshmp/DisaggEpsilon.java
+++ b/src/main/java/gov/usgs/earthquake/nshmp/DisaggEpsilon.java
@@ -146,6 +146,7 @@ public class DisaggEpsilon {
       log.info("Spectra: " + imtImlMaps.size());
 
       checkArgument(sites.size() == imtImlMaps.size(), "Sites and spectra lists different sizes");
+      // Spectra should be checked against IMTs supported by model GMMs
 
       Path out = calc(model, config, sites, imtImlMaps, log);
 
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/HazardController.java b/src/main/java/gov/usgs/earthquake/nshmp/www/HazardController.java
deleted file mode 100644
index fdc74747815374a10fb5977cf022ceb16a05319a..0000000000000000000000000000000000000000
--- a/src/main/java/gov/usgs/earthquake/nshmp/www/HazardController.java
+++ /dev/null
@@ -1,81 +0,0 @@
-package gov.usgs.earthquake.nshmp.www;
-
-import gov.usgs.earthquake.nshmp.www.services.HazardService;
-import gov.usgs.earthquake.nshmp.www.services.HazardService.QueryParameters;
-
-import io.micronaut.http.HttpRequest;
-import io.micronaut.http.HttpResponse;
-import io.micronaut.http.annotation.Controller;
-import io.micronaut.http.annotation.Get;
-import io.micronaut.http.annotation.PathVariable;
-import io.micronaut.http.annotation.QueryValue;
-import io.swagger.v3.oas.annotations.Operation;
-import io.swagger.v3.oas.annotations.media.Schema;
-import io.swagger.v3.oas.annotations.responses.ApiResponse;
-import io.swagger.v3.oas.annotations.tags.Tag;
-import jakarta.inject.Inject;
-
-/**
- * Micronaut controller for probabilistic seismic hazard calculations.
- *
- * @see HazardService
- * @author U.S. Geological Survey
- */
-@Tag(
-    name = "Hazard",
-    description = "USGS NSHMP hazard calculation service")
-@Controller("/hazard")
-public class HazardController {
-
-  @Inject
-  private NshmpMicronautServlet servlet;
-
-  @Operation(
-      summary = "Hazard model and service metadata",
-      description = "Returns details of the installed model and service request parameters",
-      operationId = "hazard_doGetMetadata")
-  @ApiResponse(
-      description = "Hazard service metadata",
-      responseCode = "200")
-  @Get
-  public HttpResponse<String> doGetMetadata(HttpRequest<?> request) {
-    return HazardService.handleDoGetMetadata(request);
-  }
-
-  /**
-   * @param longitude Longitude in decimal degrees [-360..360]
-   * @param latitude Latitude in decimal degrees [-90..90]
-   * @param vs30 Site Vs30 value in m/s [150..3000]
-   * @param truncate Truncate curves at return periods below ~10,000 years
-   * @param maxdir Apply max-direction scaling
-   */
-  @Operation(
-      summary = "Compute probabilisitic hazard at a site",
-      description = "Returns hazard curves computed from the installed model",
-      operationId = "hazard_doGetHazard")
-  @ApiResponse(
-      description = "Hazard curves",
-      responseCode = "200")
-  @Get(uri = "/{longitude}/{latitude}/{vs30}{?truncate,maxdir}")
-  public HttpResponse<String> doGetHazard(
-      HttpRequest<?> request,
-
-      @Schema(minimum = "-360", maximum = "360") @PathVariable double longitude,
-
-      @Schema(minimum = "-90", maximum = "90") @PathVariable double latitude,
-
-      @Schema(minimum = "150", maximum = "3000") @PathVariable int vs30,
-
-      @QueryValue(defaultValue = "false") boolean truncate,
-
-      @QueryValue(defaultValue = "false") boolean maxdir) {
-
-    /*
-     * @Schema annotation parameter constraints only affect Swagger service
-     * index page behavior; still need to validate against model. TODO
-     */
-
-    var query = new QueryParameters(longitude, latitude, vs30, truncate, maxdir);
-    return HazardService.handleDoGetHazard(request, query);
-  }
-}
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/services/ServicesUtil.java b/src/main/java/gov/usgs/earthquake/nshmp/www/ServicesUtil.java
similarity index 70%
rename from src/main/java/gov/usgs/earthquake/nshmp/www/services/ServicesUtil.java
rename to src/main/java/gov/usgs/earthquake/nshmp/www/ServicesUtil.java
index b2da3f0917178c67b8acb98f9da86f899e1ec652..3bdcab696e214e4d4940244a3ad557070552a54b 100644
--- a/src/main/java/gov/usgs/earthquake/nshmp/www/services/ServicesUtil.java
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/ServicesUtil.java
@@ -1,41 +1,19 @@
-package gov.usgs.earthquake.nshmp.www.services;
+package gov.usgs.earthquake.nshmp.www;
 
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ExecutionException;
 import java.util.function.Function;
 
-import com.google.gson.GsonBuilder;
-
 import gov.usgs.earthquake.nshmp.calc.CalcConfig;
 import gov.usgs.earthquake.nshmp.calc.Hazard;
 import gov.usgs.earthquake.nshmp.calc.HazardCalcs;
 import gov.usgs.earthquake.nshmp.calc.Site;
 import gov.usgs.earthquake.nshmp.model.HazardModel;
-import gov.usgs.earthquake.nshmp.www.ResponseBody;
-import gov.usgs.earthquake.nshmp.www.WsUtils;
-
-import io.micronaut.http.HttpResponse;
 
 public class ServicesUtil {
 
-  public static HttpResponse<String> handleError(
-      Throwable e,
-      String name,
-      String url) {
-    var msg = e.getMessage() + " (see logs)";
-    var svcResponse = ResponseBody.error()
-        .name(name)
-        .url(url)
-        .request(url)
-        .response(msg)
-        .build();
-    var gson = new GsonBuilder().setPrettyPrinting().create();
-    var response = gson.toJson(svcResponse);
-    e.printStackTrace();
-    return HttpResponse.serverError(response);
-  }
-
-  static Hazard calcHazard(
+  @Deprecated
+  public static Hazard calcHazard(
       Function<HazardModel, CalcConfig> configFunction,
       Function<CalcConfig, Site> siteFunction) throws InterruptedException, ExecutionException {
 
@@ -47,12 +25,12 @@ public class ServicesUtil {
   }
 
   @Deprecated
-  static class ServiceQueryData implements ServiceQuery {
+  public static class ServiceQueryData implements ServiceQuery {
 
     public final Double longitude;
     public final Double latitude;
 
-    ServiceQueryData(Double longitude, Double latitude) {
+    public ServiceQueryData(Double longitude, Double latitude) {
       this.longitude = longitude;
       this.latitude = latitude;
     }
@@ -70,7 +48,7 @@ public class ServicesUtil {
   }
 
   @Deprecated
-  static class ServiceRequestData {
+  public static class ServiceRequestData {
 
     public final double longitude;
     public final double latitude;
@@ -81,7 +59,7 @@ public class ServicesUtil {
     }
   }
 
-  enum Key {
+  public enum Key {
     EDITION,
     REGION,
     MODEL,
@@ -114,6 +92,7 @@ public class ServicesUtil {
     void checkValues();
   }
 
+  @Deprecated
   private static CompletableFuture<Hazard> calcHazard(
       HazardModel model,
       CalcConfig config,
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/services/ServletUtil.java b/src/main/java/gov/usgs/earthquake/nshmp/www/ServletUtil.java
similarity index 67%
rename from src/main/java/gov/usgs/earthquake/nshmp/www/services/ServletUtil.java
rename to src/main/java/gov/usgs/earthquake/nshmp/www/ServletUtil.java
index 81651afd7d44ac3d968008892b7e7d1dc03700eb..3e3b7425d8474fe6e6d111083ad8b550da182080 100644
--- a/src/main/java/gov/usgs/earthquake/nshmp/www/services/ServletUtil.java
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/ServletUtil.java
@@ -1,4 +1,4 @@
-package gov.usgs.earthquake.nshmp.www.services;
+package gov.usgs.earthquake.nshmp.www;
 
 import static java.lang.Runtime.getRuntime;
 
@@ -14,6 +14,10 @@ import java.util.HashMap;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.common.base.Stopwatch;
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.common.util.concurrent.MoreExecutors;
 import com.google.gson.Gson;
@@ -27,12 +31,12 @@ import gov.usgs.earthquake.nshmp.calc.Site;
 import gov.usgs.earthquake.nshmp.calc.ValueFormat;
 import gov.usgs.earthquake.nshmp.gmm.Imt;
 import gov.usgs.earthquake.nshmp.model.HazardModel;
-import gov.usgs.earthquake.nshmp.www.WsUtils;
 import gov.usgs.earthquake.nshmp.www.meta.MetaUtil;
 
 import io.micronaut.context.annotation.Value;
 import io.micronaut.context.event.ShutdownEvent;
 import io.micronaut.context.event.StartupEvent;
+import io.micronaut.http.HttpResponse;
 import io.micronaut.runtime.event.annotation.EventListener;
 import jakarta.inject.Singleton;
 
@@ -45,11 +49,14 @@ import jakarta.inject.Singleton;
 public class ServletUtil {
 
   public static final Gson GSON;
+  public static final Gson GSON2;
+
+  public static final ListeningExecutorService CALC_EXECUTOR;
+  public static final ExecutorService TASK_EXECUTOR;
 
-  static final ListeningExecutorService CALC_EXECUTOR;
-  static final ExecutorService TASK_EXECUTOR;
+  public static final int THREAD_COUNT;
 
-  static final int THREAD_COUNT;
+  private static final Logger LOGGER = LoggerFactory.getLogger(ServletUtil.class);
 
   @Value("${nshmp-haz.model-path}")
   private Path modelPath;
@@ -71,9 +78,20 @@ public class ServletUtil {
         .serializeNulls()
         .setPrettyPrinting()
         .create();
+
+    // removed old IMT and ValueFormat enum serialization
+    GSON2 = new GsonBuilder()
+        .registerTypeAdapter(Double.class, new WsUtils.DoubleSerializer())
+        .registerTypeAdapter(Site.class, new MetaUtil.SiteSerializer())
+        .registerTypeHierarchyAdapter(Path.class, new PathConverter())
+        .disableHtmlEscaping()
+        .serializeNulls()
+        .setPrettyPrinting()
+        .create();
+
   }
 
-  static HazardModel model() {
+  public static HazardModel model() {
     return HAZARD_MODEL;
   }
 
@@ -141,4 +159,47 @@ public class ServletUtil {
     }
   }
 
+  public static HttpResponse<String> error(
+      Logger logger,
+      Throwable e,
+      String name,
+      String url) {
+    var msg = e.getMessage() + " (see logs)";
+    var svcResponse = ResponseBody.error()
+        .name(name)
+        .url(url)
+        .request(url)
+        .response(msg)
+        .build();
+    var response = GSON2.toJson(svcResponse);
+    logger.error("Servlet error", e);
+    return HttpResponse.serverError(response);
+  }
+
+  public static String imtShortLabel(Imt imt) {
+    if (imt.equals(Imt.PGA) || imt.equals(Imt.PGV)) {
+      return imt.name();
+    } else if (imt.isSA()) {
+      return imt.period() + " s";
+    }
+    return imt.toString();
+  }
+
+  public static Object serverData(int threads, Stopwatch timer) {
+    return new Server(threads, timer);
+  }
+
+  private static class Server {
+
+    final int threads;
+    final String timer;
+    final String version;
+
+    Server(int threads, Stopwatch timer) {
+      this.threads = threads;
+      this.timer = timer.toString();
+      this.version = "TODO where to get version?";
+    }
+  }
+
 }
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/SwaggerController.java b/src/main/java/gov/usgs/earthquake/nshmp/www/SwaggerController.java
index c2251caba5d3ed3612ed43e1aa8ca14404f7f381..dc4d6fb3cdf414424b09634b25c7d34bdd67f97c 100644
--- a/src/main/java/gov/usgs/earthquake/nshmp/www/SwaggerController.java
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/SwaggerController.java
@@ -3,9 +3,9 @@ package gov.usgs.earthquake.nshmp.www;
 import java.nio.charset.StandardCharsets;
 import java.util.stream.Collectors;
 
-import com.google.common.io.Resources;
+import org.slf4j.LoggerFactory;
 
-import gov.usgs.earthquake.nshmp.www.services.ServicesUtil;
+import com.google.common.io.Resources;
 
 import io.micronaut.http.HttpRequest;
 import io.micronaut.http.HttpResponse;
@@ -40,7 +40,9 @@ public class SwaggerController {
           .collect(Collectors.joining("\n"));
       return HttpResponse.ok(yml);
     } catch (Exception e) {
-      return ServicesUtil.handleError(e, "Swagger", request.getUri().getPath());
+      return ServletUtil.error(
+          LoggerFactory.getLogger("Swagger"),
+          e, "Swagger", request.getUri().toString());
     }
   }
 }
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/hazard/DisaggController.java b/src/main/java/gov/usgs/earthquake/nshmp/www/hazard/DisaggController.java
new file mode 100644
index 0000000000000000000000000000000000000000..680d946ad73c54669b62afa1ebf762f3b5810331
--- /dev/null
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/hazard/DisaggController.java
@@ -0,0 +1,156 @@
+package gov.usgs.earthquake.nshmp.www.hazard;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import java.util.Map;
+import java.util.Set;
+
+import gov.usgs.earthquake.nshmp.gmm.Imt;
+import gov.usgs.earthquake.nshmp.www.NshmpMicronautServlet;
+import gov.usgs.earthquake.nshmp.www.ServletUtil;
+
+import io.micronaut.core.annotation.Nullable;
+import io.micronaut.http.HttpRequest;
+import io.micronaut.http.HttpResponse;
+import io.micronaut.http.annotation.Controller;
+import io.micronaut.http.annotation.Get;
+import io.micronaut.http.annotation.PathVariable;
+import io.micronaut.http.annotation.QueryValue;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.inject.Inject;
+
+/**
+ * Micronaut web service controller for disaggregation of probabilistic seismic
+ * hazard.
+ *
+ * @author U.S. Geological Survey
+ */
+@Tag(
+    name = "Disaggregation",
+    description = "USGS NSHMP hazard disaggregation service")
+@Controller("/disagg")
+public class DisaggController {
+
+  @Inject
+  private NshmpMicronautServlet servlet;
+
+  @Operation(
+      summary = "Disaggregation model and service metadata",
+      description = "Returns details of the installed model and service request parameters")
+  @ApiResponse(
+      description = "Disaggregation service metadata",
+      responseCode = "200")
+  @Get
+  public HttpResponse<String> doGetMetadata(HttpRequest<?> http) {
+    try {
+      return DisaggService.getMetadata(http);
+    } catch (Exception e) {
+      return ServletUtil.error(
+          DisaggService.LOG, e,
+          DisaggService.NAME,
+          http.getUri().toString());
+    }
+  }
+
+  /**
+   * @param longitude Longitude in the range [-360..360]°.
+   * @param latitude Latitude in the range [-90..90]°.
+   * @param vs30 Site Vs30 value in the range [150..3000] m/s.
+   * @param returnPeriod The return period of the target ground motion, or
+   *        intensity measure level (IML), in the range [1..20000] years.
+   * @param imt Optional IMTs at which to compute hazard. If none are supplied,
+   *        then the supported set for the installed model is used. Note that a
+   *        model may not support all the values listed below (see
+   *        disagreggation metadata). Responses for numerous IMT's are quite
+   *        large, on the order of MB. Multiple IMTs may be comma delimited,
+   *        e.g. ?imt=PGA,SA0p2,SA1P0.
+   */
+  @Operation(
+      summary = "Disaggregate hazard at a specified return period",
+      description = "Returns a hazard disaggregation computed from the installed model")
+  @ApiResponse(
+      description = "Disaggregation",
+      responseCode = "200")
+  @Get(uri = "rp/{longitude}/{latitude}/{vs30}/{returnPeriod}{?imt}")
+  public HttpResponse<String> doGetDisaggReturnPeriod(
+      HttpRequest<?> http,
+      @Schema(
+          minimum = "-360",
+          maximum = "360") @PathVariable double longitude,
+      @Schema(
+          minimum = "-90",
+          maximum = "90") @PathVariable double latitude,
+      @Schema(
+          minimum = "150",
+          maximum = "3000") @PathVariable double vs30,
+      @Schema(
+          minimum = "150",
+          maximum = "3000") @PathVariable double returnPeriod,
+      @Schema() @QueryValue @Nullable Set<Imt> imt) {
+    try {
+      Set<Imt> imts = HazardService.readImts(http);
+      DisaggService.RequestRp request = new DisaggService.RequestRp(
+          http,
+          longitude, latitude, vs30,
+          returnPeriod,
+          imts);
+      return DisaggService.getDisaggRp(request);
+    } catch (Exception e) {
+      return ServletUtil.error(
+          DisaggService.LOG, e,
+          DisaggService.NAME,
+          http.getUri().toString());
+    }
+  }
+
+  /**
+   * @param longitude Longitude in the range [-360..360]°.
+   * @param latitude Latitude in decimal degrees [-90..90]°.
+   * @param vs30 Site Vs30 value in the range [150..3000] m/s.
+   */
+  @Operation(
+      summary = "Disaggregate hazard at specified IMLs",
+      description = "Returns a hazard disaggregation computed from the installed model")
+  @ApiResponse(
+      description = "Disaggregation",
+      responseCode = "200")
+  @Get(uri = "iml/{longitude}/{latitude}/{vs30}")
+  public HttpResponse<String> doGetDisaggIml(
+      HttpRequest<?> http,
+      @Schema(
+          minimum = "-360",
+          maximum = "360") @PathVariable double longitude,
+      @Schema(
+          minimum = "-90",
+          maximum = "90") @PathVariable double latitude,
+      @Schema(
+          minimum = "150",
+          maximum = "3000") @PathVariable double vs30) {
+
+    /*
+     * Developer notes:
+     *
+     * It is awkward to support IMT=#; numerous unique keys that may or may not
+     * be present yields a clunky swagger interface. The disagg-iml endpoint
+     * requires one or more IMT=# query arguments. Documented in example.
+     */
+
+    try {
+      Map<Imt, Double> imtImlMap = http.getParameters().asMap(Imt.class, Double.class);
+      checkArgument(!imtImlMap.isEmpty(), "No IMLs supplied");
+      DisaggService.RequestIml request = new DisaggService.RequestIml(
+          http,
+          longitude, latitude, vs30,
+          imtImlMap);
+      return DisaggService.getDisaggIml(request);
+    } catch (Exception e) {
+      return ServletUtil.error(
+          DisaggService.LOG, e,
+          DisaggService.NAME,
+          http.getUri().toString());
+    }
+  }
+}
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/hazard/DisaggService.java b/src/main/java/gov/usgs/earthquake/nshmp/www/hazard/DisaggService.java
new file mode 100644
index 0000000000000000000000000000000000000000..ac428621e93f6cbe4ac2dfcaf088ef0e9b2ca297
--- /dev/null
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/hazard/DisaggService.java
@@ -0,0 +1,319 @@
+package gov.usgs.earthquake.nshmp.www.hazard;
+
+import static java.util.stream.Collectors.toList;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.common.base.Stopwatch;
+import com.google.common.collect.Range;
+
+import gov.usgs.earthquake.nshmp.calc.CalcConfig;
+import gov.usgs.earthquake.nshmp.calc.Disaggregation;
+import gov.usgs.earthquake.nshmp.calc.Hazard;
+import gov.usgs.earthquake.nshmp.calc.HazardCalcs;
+import gov.usgs.earthquake.nshmp.calc.Site;
+import gov.usgs.earthquake.nshmp.geo.Location;
+import gov.usgs.earthquake.nshmp.gmm.Imt;
+import gov.usgs.earthquake.nshmp.model.HazardModel;
+import gov.usgs.earthquake.nshmp.www.ResponseBody;
+import gov.usgs.earthquake.nshmp.www.ServletUtil;
+import gov.usgs.earthquake.nshmp.www.hazard.HazardService.Metadata;
+import gov.usgs.earthquake.nshmp.www.meta.Parameter;
+
+import io.micronaut.http.HttpRequest;
+import io.micronaut.http.HttpResponse;
+import jakarta.inject.Singleton;
+
+/**
+ * Disaggregation service.
+ *
+ * @see DisaggController
+ * @author U.S. Geological Survey
+ */
+@Singleton
+public final class DisaggService {
+
+  /*
+   * Developer notes:
+   *
+   * Same query structure as hazard service, but either return period and imt(s)
+   * OR imt=iml pairs
+   */
+
+  static final String NAME = "Disaggregation Service";
+  static final Logger LOG = LoggerFactory.getLogger(DisaggService.class);
+
+  private static Range<Double> rpRange = Range.closed(1.0, 20000.0);
+  private static Range<Double> imlRange = Range.closed(0.0001, 8.0);
+
+  /** HazardController.doGetMetadata() handler. */
+  public static HttpResponse<String> getMetadata(HttpRequest<?> request) {
+    var url = request.getUri().toString();
+    var usage = new Metadata(ServletUtil.model());
+    var response = ResponseBody.usage()
+        .name(NAME)
+        .url(url)
+        .request(url)
+        .response(usage)
+        .build();
+    var svcResponse = ServletUtil.GSON.toJson(response);
+    return HttpResponse.ok(svcResponse);
+  }
+
+  /** HazardController.doGetDisaggIml() handler. */
+  public static HttpResponse<String> getDisaggIml(RequestIml request)
+      throws InterruptedException, ExecutionException {
+    var stopwatch = Stopwatch.createStarted();
+    var disagg = calcDisaggIml(request);
+    var response = new Response.Builder()
+        .timer(stopwatch)
+        .request(request)
+        .disagg(disagg)
+        .build();
+    var body = ResponseBody.success()
+        .name(NAME)
+        .url(request.http.getUri().toString())
+        .request(request)
+        .response(response)
+        .build();
+    String svcResponse = ServletUtil.GSON2.toJson(body);
+    return HttpResponse.ok(svcResponse);
+  }
+
+  /** HazardController.doGetDisaggRp() handler. */
+  public static HttpResponse<String> getDisaggRp(RequestRp request)
+      throws InterruptedException, ExecutionException {
+    var stopwatch = Stopwatch.createStarted();
+    var disagg = calcDisaggRp(request);
+    var response = new Response.Builder()
+        .timer(stopwatch)
+        .request(request)
+        .disagg(disagg)
+        .build();
+    var body = ResponseBody.success()
+        .name(NAME)
+        .url(request.http.getUri().toString())
+        .request(request)
+        .response(response)
+        .build();
+    String svcResponse = ServletUtil.GSON2.toJson(body);
+    return HttpResponse.ok(svcResponse);
+  }
+
+  /*
+   * Developer notes:
+   *
+   * If disaggIml, we need to do the calculation for single XySeqs if disaggRp,
+   * we don't know the imls so must compute hazard over the full curve
+   *
+   */
+
+  static Disaggregation calcDisaggIml(RequestIml request)
+      throws InterruptedException, ExecutionException {
+
+    HazardModel model = ServletUtil.model();
+
+    // modify config to include service endpoint arguments
+    CalcConfig config = CalcConfig.copyOf(model.config())
+        .imts(request.imls.keySet())
+        .build();
+
+    // TODO this needs to pick up SiteData, centralize
+    Site site = Site.builder()
+        .location(Location.create(request.longitude, request.latitude))
+        .vs30(request.vs30)
+        .build();
+
+    // use HazardService.calcHazard() instead?
+    CompletableFuture<Hazard> hazFuture = CompletableFuture.supplyAsync(
+        () -> HazardCalcs.hazard(
+            model, config, site,
+            ServletUtil.CALC_EXECUTOR),
+        ServletUtil.TASK_EXECUTOR);
+
+    Hazard hazard = hazFuture.get();
+
+    CompletableFuture<Disaggregation> disaggfuture = CompletableFuture.supplyAsync(
+        () -> Disaggregation.atImls(
+            hazard, request.imls,
+            ServletUtil.CALC_EXECUTOR),
+        ServletUtil.TASK_EXECUTOR);
+
+    Disaggregation disagg = disaggfuture.get();
+
+    return disagg;
+  }
+
+  static Disaggregation calcDisaggRp(RequestRp request)
+      throws InterruptedException, ExecutionException {
+
+    HazardModel model = ServletUtil.model();
+
+    // modify config to include service endpoint arguments
+    CalcConfig config = CalcConfig.copyOf(model.config())
+        .imts(request.imts)
+        .build();
+
+    // TODO this needs to pick up SiteData, centralize
+    Site site = Site.builder()
+        .location(Location.create(request.longitude, request.latitude))
+        .vs30(request.vs30)
+        .build();
+
+    CompletableFuture<Hazard> hazFuture = CompletableFuture.supplyAsync(
+        () -> HazardCalcs.hazard(
+            model, config, site,
+            ServletUtil.CALC_EXECUTOR),
+        ServletUtil.TASK_EXECUTOR);
+
+    Hazard hazard = hazFuture.get();
+
+    CompletableFuture<Disaggregation> disaggfuture = CompletableFuture.supplyAsync(
+        () -> Disaggregation.atReturnPeriod(
+            hazard, request.returnPeriod,
+            ServletUtil.CALC_EXECUTOR),
+        ServletUtil.TASK_EXECUTOR);
+
+    Disaggregation disagg = disaggfuture.get();
+
+    return disagg;
+  }
+
+  static final class RequestIml {
+
+    final transient HttpRequest<?> http;
+    final double longitude;
+    final double latitude;
+    final double vs30;
+    final Map<Imt, Double> imls;
+
+    RequestIml(
+        HttpRequest<?> http,
+        double longitude,
+        double latitude,
+        double vs30,
+        Map<Imt, Double> imls) {
+
+      this.http = http;
+      this.longitude = longitude;
+      this.latitude = latitude;
+      this.vs30 = vs30;
+      this.imls = imls;
+    }
+  }
+
+  static final class RequestRp {
+
+    final transient HttpRequest<?> http;
+    final double longitude;
+    final double latitude;
+    final double vs30;
+    final double returnPeriod;
+    final Set<Imt> imts;
+
+    RequestRp(
+        HttpRequest<?> http,
+        double longitude,
+        double latitude,
+        double vs30,
+        double returnPeriod,
+        Set<Imt> imts) {
+
+      this.http = http;
+      this.longitude = longitude;
+      this.latitude = latitude;
+      this.vs30 = vs30;
+      this.returnPeriod = returnPeriod;
+      this.imts = imts.isEmpty()
+          ? ServletUtil.model().config().hazard.imts
+          : imts;
+    }
+  }
+
+  private static final class Response {
+    final Response.Metadata metadata;
+    final List<ImtDisagg> disaggs;
+
+    Response(Response.Metadata metadata, List<ImtDisagg> disaggs) {
+      this.metadata = metadata;
+      this.disaggs = disaggs;
+    }
+
+    private static final class Metadata {
+      final Object server;
+      final String rlabel = "Closest Distance, rRup (km)";
+      final String mlabel = "Magnitude (Mw)";
+      final String εlabel = "% Contribution to Hazard";
+      final Object εbins;
+
+      Metadata(Object server, Object εbins) {
+        this.server = server;
+        this.εbins = εbins;
+      }
+    }
+
+    private static final class Builder {
+
+      Stopwatch timer;
+      Optional<RequestRp> requestRp = Optional.empty();
+      Optional<RequestIml> requestIml = Optional.empty();
+      Disaggregation disagg;
+
+      Builder timer(Stopwatch timer) {
+        this.timer = timer;
+        return this;
+      }
+
+      Builder request(Object request) {
+        if (request instanceof RequestRp) {
+          requestRp = Optional.of((RequestRp) request);
+          return this;
+        }
+        requestIml = Optional.of((RequestIml) request);
+        return this;
+      }
+
+      Builder disagg(Disaggregation disagg) {
+        this.disagg = disagg;
+        return this;
+      }
+
+      Response build() {
+
+        Set<Imt> imts = requestRp.isPresent()
+            ? requestRp.orElseThrow().imts
+            : requestIml.orElseThrow().imls.keySet();
+
+        List<ImtDisagg> disaggs = imts.stream()
+            .map(imt -> new ImtDisagg(imt, disagg.toJson(imt)))
+            .collect(toList());
+
+        Object server = ServletUtil.serverData(ServletUtil.THREAD_COUNT, timer);
+
+        return new Response(
+            new Response.Metadata(server, disagg.εBins()),
+            disaggs);
+      }
+    }
+  }
+
+  private static final class ImtDisagg {
+    final Parameter imt;
+    final Object data;
+
+    ImtDisagg(Imt imt, Object data) {
+      this.imt = new Parameter(
+          ServletUtil.imtShortLabel(imt),
+          imt.name());
+      this.data = data;
+    }
+  }
+}
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/hazard/HazardController.java b/src/main/java/gov/usgs/earthquake/nshmp/www/hazard/HazardController.java
new file mode 100644
index 0000000000000000000000000000000000000000..0fe36de8adaceffb0519da84c7c51e24e7ce5cf1
--- /dev/null
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/hazard/HazardController.java
@@ -0,0 +1,107 @@
+package gov.usgs.earthquake.nshmp.www.hazard;
+
+import java.util.Set;
+
+import gov.usgs.earthquake.nshmp.gmm.Imt;
+import gov.usgs.earthquake.nshmp.www.NshmpMicronautServlet;
+import gov.usgs.earthquake.nshmp.www.ServletUtil;
+
+import io.micronaut.core.annotation.Nullable;
+import io.micronaut.http.HttpRequest;
+import io.micronaut.http.HttpResponse;
+import io.micronaut.http.annotation.Controller;
+import io.micronaut.http.annotation.Get;
+import io.micronaut.http.annotation.PathVariable;
+import io.micronaut.http.annotation.QueryValue;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.inject.Inject;
+
+/**
+ * Micronaut web service controller for probabilistic seismic hazard
+ * calculations.
+ *
+ * @author U.S. Geological Survey
+ */
+@Tag(
+    name = "Hazard",
+    description = "USGS NSHMP hazard calculation service")
+@Controller("/hazard")
+public class HazardController {
+
+  @Inject
+  private NshmpMicronautServlet servlet;
+
+  @Operation(
+      summary = "Hazard model and service metadata",
+      description = "Returns details of the installed model and service request parameters")
+  @ApiResponse(
+      description = "Hazard service metadata",
+      responseCode = "200")
+  @Get
+  public HttpResponse<String> doGetMetadata(HttpRequest<?> http) {
+    try {
+      return HazardService.getMetadata(http);
+    } catch (Exception e) {
+      return ServletUtil.error(
+          HazardService.LOG, e,
+          HazardService.NAME,
+          http.getUri().toString());
+    }
+  }
+
+  /**
+   * @param longitude Longitude in decimal degrees [-360..360]
+   * @param latitude Latitude in decimal degrees [-90..90]
+   * @param vs30 Site Vs30 value in m/s [150..3000]
+   * @param truncate Truncate curves at return periods below ~10,000 years
+   * @param maxdir Apply max-direction scaling
+   * @param imt Optional IMTs at which to compute hazard. If none are supplied,
+   *        then the supported set for the installed model is used. Note that a
+   *        model may not support all the values listed below (see
+   *        disagreggation metadata). Responses for numerous IMT's are quite
+   *        large, on the order of MB. Multiple IMTs may be comma delimited,
+   *        e.g. ?imt=PGA,SA0p2,SA1P0.
+   *
+   */
+  @Operation(
+      summary = "Compute probabilisitic hazard at a site",
+      description = "Returns hazard curves computed from the installed model")
+  @ApiResponse(
+      description = "Hazard curves",
+      responseCode = "200")
+  @Get(uri = "/{longitude}/{latitude}/{vs30}{?truncate,maxdir,imt}")
+  public HttpResponse<String> doGetHazard(
+      HttpRequest<?> http,
+      @Schema(
+          minimum = "-360",
+          maximum = "360") @PathVariable double longitude,
+      @Schema(
+          minimum = "-90",
+          maximum = "90") @PathVariable double latitude,
+      @Schema(
+          minimum = "150",
+          maximum = "3000") @PathVariable int vs30,
+      @QueryValue(
+          defaultValue = "false") @Nullable Boolean truncate,
+      @QueryValue(
+          defaultValue = "false") @Nullable Boolean maxdir,
+      @QueryValue @Nullable Set<Imt> imt) {
+    try {
+      Set<Imt> imts = HazardService.readImts(http);
+      HazardService.Request request = new HazardService.Request(
+          http,
+          longitude, latitude, vs30,
+          truncate, maxdir,
+          imts);
+      return HazardService.getHazard(request);
+    } catch (Exception e) {
+      return ServletUtil.error(
+          HazardService.LOG, e,
+          HazardService.NAME,
+          http.getUri().toString());
+    }
+  }
+}
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/hazard/HazardService.java b/src/main/java/gov/usgs/earthquake/nshmp/www/hazard/HazardService.java
new file mode 100644
index 0000000000000000000000000000000000000000..f668b016e76468c696e0759fa3cee55077c2451e
--- /dev/null
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/hazard/HazardService.java
@@ -0,0 +1,358 @@
+package gov.usgs.earthquake.nshmp.www.hazard;
+
+import static com.google.common.base.Preconditions.checkState;
+import static gov.usgs.earthquake.nshmp.calc.HazardExport.curvesBySource;
+import static gov.usgs.earthquake.nshmp.data.DoubleData.checkInRange;
+import static gov.usgs.earthquake.nshmp.geo.Coordinates.checkLatitude;
+import static gov.usgs.earthquake.nshmp.geo.Coordinates.checkLongitude;
+import static java.util.stream.Collectors.toCollection;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.EnumMap;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.common.base.Stopwatch;
+
+import gov.usgs.earthquake.nshmp.calc.CalcConfig;
+import gov.usgs.earthquake.nshmp.calc.Hazard;
+import gov.usgs.earthquake.nshmp.calc.HazardCalcs;
+import gov.usgs.earthquake.nshmp.calc.Site;
+import gov.usgs.earthquake.nshmp.data.MutableXySequence;
+import gov.usgs.earthquake.nshmp.data.XySequence;
+import gov.usgs.earthquake.nshmp.geo.Coordinates;
+import gov.usgs.earthquake.nshmp.geo.Location;
+import gov.usgs.earthquake.nshmp.gmm.Imt;
+import gov.usgs.earthquake.nshmp.model.HazardModel;
+import gov.usgs.earthquake.nshmp.model.SourceType;
+import gov.usgs.earthquake.nshmp.www.ResponseBody;
+import gov.usgs.earthquake.nshmp.www.ServletUtil;
+import gov.usgs.earthquake.nshmp.www.meta.DoubleParameter;
+import gov.usgs.earthquake.nshmp.www.meta.Parameter;
+import gov.usgs.earthquake.nshmp.www.services.SourceServices.SourceModel;
+
+import io.micronaut.http.HttpRequest;
+import io.micronaut.http.HttpResponse;
+import jakarta.inject.Singleton;
+
+/**
+ * Hazard service.
+ *
+ * @see HazardController
+ * @author U.S. Geological Survey
+ */
+@Singleton
+public final class HazardService {
+
+  static final String NAME = "Hazard Service";
+  static final Logger LOG = LoggerFactory.getLogger(HazardService.class);
+
+  private static final String TOTAL_KEY = "Total";
+
+  /** HazardController.doGetUsage() handler. */
+  public static HttpResponse<String> getMetadata(HttpRequest<?> request) {
+    var url = request.getUri().toString();
+    var usage = new Metadata(ServletUtil.model());
+    var body = ResponseBody.usage()
+        .name(NAME)
+        .url(url)
+        .request(url)
+        .response(usage)
+        .build();
+    var json = ServletUtil.GSON2.toJson(body);
+    return HttpResponse.ok(json);
+  }
+
+  /** HazardController.doGetHazard() handler. */
+  public static HttpResponse<String> getHazard(Request request)
+      throws InterruptedException, ExecutionException {
+    var stopwatch = Stopwatch.createStarted();
+    var hazard = calcHazard(request);
+    var response = new Response.Builder()
+        .timer(stopwatch)
+        .request(request)
+        .hazard(hazard)
+        .build();
+    var body = ResponseBody.success()
+        .name(NAME)
+        .url(request.http.getUri().toString())
+        .request(request)
+        .response(response)
+        .build();
+    String json = ServletUtil.GSON2.toJson(body);
+    return HttpResponse.ok(json);
+  }
+
+  /*
+   * Developer notes:
+   *
+   * Future calculation configuration options: vertical GMs
+   *
+   * NSHM Hazard Tool will not pass truncation and maxdir args/flags as the apps
+   * apply truncation and scaling on the client.
+   */
+
+  public static Hazard calcHazard(Request request)
+      throws InterruptedException, ExecutionException {
+
+    HazardModel model = ServletUtil.model();
+
+    // modify config to include service endpoint arguments
+    CalcConfig config = CalcConfig.copyOf(model.config())
+        .imts(request.imts)
+        .build();
+
+    // TODO this needs to pick up SiteData, centralize
+    Site site = Site.builder()
+        .location(Location.create(request.longitude, request.latitude))
+        .vs30(request.vs30)
+        .build();
+
+    CompletableFuture<Hazard> future = CompletableFuture.supplyAsync(
+        () -> HazardCalcs.hazard(
+            model, config, site,
+            ServletUtil.CALC_EXECUTOR),
+        ServletUtil.TASK_EXECUTOR);
+
+    return future.get();
+  }
+
+  static class Metadata {
+
+    final SourceModel model;
+    final DoubleParameter longitude;
+    final DoubleParameter latitude;
+    final DoubleParameter vs30;
+
+    Metadata(HazardModel model) {
+      this.model = new SourceModel(model);
+      // TODO need min max from model
+      longitude = new DoubleParameter(
+          "Longitude",
+          "°",
+          Coordinates.LON_RANGE.lowerEndpoint(),
+          Coordinates.LON_RANGE.upperEndpoint());
+
+      latitude = new DoubleParameter(
+          "Latitude",
+          "°",
+          Coordinates.LAT_RANGE.lowerEndpoint(),
+          Coordinates.LAT_RANGE.upperEndpoint());
+
+      vs30 = new DoubleParameter(
+          "Vs30",
+          "m/s",
+          150,
+          1500);
+    }
+  }
+
+  public static final class Request {
+
+    final transient HttpRequest<?> http;
+    final double longitude;
+    final double latitude;
+    final double vs30;
+    final boolean truncate;
+    final boolean maxdir;
+    final Set<Imt> imts;
+
+    public Request(
+        HttpRequest<?> http,
+        double longitude,
+        double latitude,
+        int vs30,
+        boolean truncate,
+        boolean maxdir,
+        Set<Imt> imts) {
+
+      this.http = http;
+      this.longitude = checkLongitude(longitude);
+      this.latitude = checkLatitude(latitude);
+      this.vs30 = checkInRange(Site.VS30_RANGE, Site.Key.VS30, vs30);
+      this.truncate = truncate;
+      this.maxdir = maxdir;
+      this.imts = imts.isEmpty()
+          ? ServletUtil.model().config().hazard.imts
+          : imts;
+    }
+  }
+
+  private static final class Response {
+
+    final Metadata metadata;
+    final List<ImtCurves> hazardCurves;
+
+    Response(Metadata metadata, List<ImtCurves> hazardCurves) {
+      this.metadata = metadata;
+      this.hazardCurves = hazardCurves;
+    }
+
+    private static final class Metadata {
+      final Object server;
+      final String xlabel = "Ground Motion (g)";
+      final String ylabel = "Annual Frequency of Exceedence";
+
+      Metadata(Object server) {
+        this.server = server;
+      }
+    }
+
+    private static final class Builder {
+
+      Stopwatch timer;
+      Request request;
+      Map<Imt, Map<SourceType, MutableXySequence>> componentMaps;
+      Map<Imt, MutableXySequence> totalMap;
+
+      Builder timer(Stopwatch timer) {
+        this.timer = timer;
+        return this;
+      }
+
+      Builder request(Request request) {
+        this.request = request;
+        return this;
+      }
+
+      Builder hazard(Hazard hazard) {
+        // TODO necessary??
+        checkState(totalMap == null, "Hazard has already been added to this builder");
+
+        componentMaps = new EnumMap<>(Imt.class);
+        totalMap = new EnumMap<>(Imt.class);
+
+        var typeTotalMaps = curvesBySource(hazard);
+
+        for (var imt : hazard.curves().keySet()) {
+
+          /* Total curve for IMT. */
+          XySequence.addToMap(imt, totalMap, hazard.curves().get(imt));
+
+          /* Source component curves for IMT. */
+          var typeTotalMap = typeTotalMaps.get(imt);
+          var componentMap = componentMaps.get(imt);
+
+          if (componentMap == null) {
+            componentMap = new EnumMap<>(SourceType.class);
+            componentMaps.put(imt, componentMap);
+          }
+
+          for (var type : typeTotalMap.keySet()) {
+            XySequence.addToMap(type, componentMap, typeTotalMap.get(type));
+          }
+        }
+
+        return this;
+      }
+
+      Response build() {
+        var hazards = new ArrayList<ImtCurves>();
+
+        for (Imt imt : totalMap.keySet()) {
+          var curves = new ArrayList<Curve>();
+
+          // total curve
+          curves.add(new Curve(
+              TOTAL_KEY,
+              updateCurve(request, totalMap.get(imt), imt)));
+
+          // component curves
+          var typeMap = componentMaps.get(imt);
+          for (SourceType type : typeMap.keySet()) {
+            curves.add(new Curve(
+                type.toString(),
+                updateCurve(request, typeMap.get(type), imt)));
+          }
+
+          hazards.add(new ImtCurves(imt, curves));
+        }
+
+        Object server = ServletUtil.serverData(ServletUtil.THREAD_COUNT, timer);
+        var response = new Response(
+            new Response.Metadata(server),
+            hazards);
+
+        return response;
+      }
+    }
+
+  }
+
+  private static final class ImtCurves {
+    final Parameter imt;
+    final List<Curve> data;
+
+    ImtCurves(Imt imt, List<Curve> data) {
+      this.imt = new Parameter(ServletUtil.imtShortLabel(imt), imt.name());
+      this.data = data;
+    }
+  }
+
+  private static final class Curve {
+    final String component;
+    final XySequence values;
+
+    Curve(String component, XySequence values) {
+      this.component = component;
+      this.values = values;
+    }
+  }
+
+  private static final double TRUNCATION_LIMIT = 1e-4;
+
+  /* Convert to linear and possibly truncate and scale to max-direction. */
+  private static XySequence updateCurve(
+      Request request,
+      XySequence curve,
+      Imt imt) {
+
+    /*
+     * If entire curve is <1e-4, this method will return a curve consisting of
+     * just the first point in the supplied curve.
+     *
+     * TODO We probably want to move the TRUNCATION_LIMIT out to a config.
+     */
+
+    double[] yValues = curve.yValues().toArray();
+    int limit = request.truncate ? truncationLimit(yValues) : yValues.length;
+    yValues = Arrays.copyOf(yValues, limit);
+
+    double scale = request.maxdir ? MaxDirection.FACTORS.get(imt) : 1.0;
+    double[] xValues = curve.xValues()
+        .limit(yValues.length)
+        .map(Math::exp)
+        .map(x -> x * scale)
+        .toArray();
+
+    return XySequence.create(xValues, yValues);
+  }
+
+  private static int truncationLimit(double[] yValues) {
+    int limit = 1;
+    double y = yValues[0];
+    while (y > TRUNCATION_LIMIT && limit < yValues.length) {
+      y = yValues[limit++];
+    }
+    return limit;
+  }
+
+  /* Read the 'imt' query values; can be comma-delimited. */
+  static Set<Imt> readImts(HttpRequest<?> http) {
+    return http.getParameters()
+        .getAll("imt")// TODO where are key strings?
+        .stream()
+        .map(s -> s.split(","))
+        .flatMap(Arrays::stream)
+        .map(Imt::valueOf)
+        .collect(toCollection(() -> EnumSet.noneOf(Imt.class)));
+  }
+}
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/services/MaxDirection.java b/src/main/java/gov/usgs/earthquake/nshmp/www/hazard/MaxDirection.java
similarity index 97%
rename from src/main/java/gov/usgs/earthquake/nshmp/www/services/MaxDirection.java
rename to src/main/java/gov/usgs/earthquake/nshmp/www/hazard/MaxDirection.java
index e35c59355037416482937ce865aeee59400af2cf..99c40c42d3127fcca3bb176c6fb837d20d0d6c3d 100644
--- a/src/main/java/gov/usgs/earthquake/nshmp/www/services/MaxDirection.java
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/hazard/MaxDirection.java
@@ -1,4 +1,4 @@
-package gov.usgs.earthquake.nshmp.www.services;
+package gov.usgs.earthquake.nshmp.www.hazard;
 
 import static gov.usgs.earthquake.nshmp.gmm.Imt.PGA;
 import static gov.usgs.earthquake.nshmp.gmm.Imt.SA0P01;
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/meta/MetaUtil.java b/src/main/java/gov/usgs/earthquake/nshmp/www/meta/MetaUtil.java
index 74c7c7ab48943d014553be9d53d8046790d38701..a1a2c068400b60b52532e01acb7d912b1dbde6e0 100644
--- a/src/main/java/gov/usgs/earthquake/nshmp/www/meta/MetaUtil.java
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/meta/MetaUtil.java
@@ -39,7 +39,7 @@ public final class MetaUtil {
       JsonObject json = new JsonObject();
       json.add("location", loc);
       json.addProperty("vs30", site.vs30());
-      json.addProperty("vsInfered", site.vsInferred());
+      json.addProperty("vsInferred", site.vsInferred());
       json.addProperty("z1p0", Double.isNaN(site.z1p0()) ? null : site.z1p0());
       json.addProperty("z2p5", Double.isNaN(site.z2p5()) ? null : site.z2p5());
 
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/meta/Metadata.java b/src/main/java/gov/usgs/earthquake/nshmp/www/meta/Metadata.java
index 1120be8504341bf5e2b843fb4b3615f2f9216869..84227bdce0090294ed736dc7c52b3ecb55bd7e90 100644
--- a/src/main/java/gov/usgs/earthquake/nshmp/www/meta/Metadata.java
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/meta/Metadata.java
@@ -4,7 +4,7 @@ import com.google.common.base.Stopwatch;
 import com.google.common.base.Throwables;
 
 import gov.usgs.earthquake.nshmp.geo.Coordinates;
-import gov.usgs.earthquake.nshmp.www.services.ServletUtil;
+import gov.usgs.earthquake.nshmp.www.ServletUtil;
 
 /**
  * Service metadata, parameterization, and constraint strings, in JSON format.
@@ -28,43 +28,11 @@ public final class Metadata {
       this.status = Status.USAGE.toString();
       this.description = description;
       this.syntax = syntax;
-      this.server = serverData(1, Stopwatch.createStarted());
+      this.server = ServletUtil.serverData(1, Stopwatch.createStarted());
       this.parameters = parameters;
     }
   }
 
-  public static Object serverData(int threads, Stopwatch timer) {
-    return new Server(threads, timer);
-  }
-
-  private static class Server {
-
-    final int threads;
-    final String timer;
-    final String version;
-
-    Server(int threads, Stopwatch timer) {
-      this.threads = threads;
-      this.timer = timer.toString();
-      this.version = "TODO where to get version?";
-    }
-
-    // static Component NSHMP_HAZ_COMPONENT = new Component(
-    // NSHMP_HAZ_URL,
-    // Versions.NSHMP_HAZ_VERSION);
-    //
-    // static final class Component {
-    //
-    // final String url;
-    // final String version;
-    //
-    // Component(String url, String version) {
-    // this.url = url;
-    // this.version = version;
-    // }
-    // }
-  }
-
   public static class DefaultParameters {
 
     // final EnumParameter<Edition> edition;
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/services/HazardService.java b/src/main/java/gov/usgs/earthquake/nshmp/www/services/HazardService.java
deleted file mode 100644
index 89f03a87b3a4d52e36b209be6f79799729b46a51..0000000000000000000000000000000000000000
--- a/src/main/java/gov/usgs/earthquake/nshmp/www/services/HazardService.java
+++ /dev/null
@@ -1,422 +0,0 @@
-package gov.usgs.earthquake.nshmp.www.services;
-
-import static com.google.common.base.Preconditions.checkState;
-import static gov.usgs.earthquake.nshmp.calc.HazardExport.curvesBySource;
-
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.EnumMap;
-import java.util.List;
-import java.util.Map;
-import java.util.concurrent.ExecutionException;
-import java.util.function.Function;
-
-import com.google.common.base.Stopwatch;
-
-import gov.usgs.earthquake.nshmp.calc.CalcConfig;
-import gov.usgs.earthquake.nshmp.calc.Hazard;
-import gov.usgs.earthquake.nshmp.calc.Site;
-import gov.usgs.earthquake.nshmp.data.MutableXySequence;
-import gov.usgs.earthquake.nshmp.data.XySequence;
-import gov.usgs.earthquake.nshmp.geo.Coordinates;
-import gov.usgs.earthquake.nshmp.geo.Location;
-import gov.usgs.earthquake.nshmp.gmm.Imt;
-import gov.usgs.earthquake.nshmp.model.HazardModel;
-import gov.usgs.earthquake.nshmp.model.SourceType;
-import gov.usgs.earthquake.nshmp.www.HazardController;
-import gov.usgs.earthquake.nshmp.www.ResponseBody;
-import gov.usgs.earthquake.nshmp.www.WsUtils;
-import gov.usgs.earthquake.nshmp.www.meta.DoubleParameter;
-import gov.usgs.earthquake.nshmp.www.meta.Metadata;
-import gov.usgs.earthquake.nshmp.www.meta.Parameter;
-import gov.usgs.earthquake.nshmp.www.services.ServicesUtil.ServiceQueryData;
-import gov.usgs.earthquake.nshmp.www.services.ServicesUtil.ServiceRequestData;
-import gov.usgs.earthquake.nshmp.www.services.SourceServices.SourceModel;
-
-import io.micronaut.http.HttpRequest;
-import io.micronaut.http.HttpResponse;
-import jakarta.inject.Singleton;
-
-/**
- * Probabilistic seismic hazard calculation handler for
- * {@link HazardController}.
- *
- * @author U.S. Geological Survey
- */
-@Singleton
-public final class HazardService {
-
-  private static final String NAME = "Hazard Service";
-
-  /** HazardController.doGetUsage() handler. */
-  public static HttpResponse<String> handleDoGetMetadata(HttpRequest<?> request) {
-    var url = request.getUri().getPath();
-    try {
-      var usage = new RequestMetadata(ServletUtil.model());
-      var response = ResponseBody.usage()
-          .name(NAME)
-          .url(url)
-          .request(url)
-          .response(usage)
-          .build();
-      var svcResponse = ServletUtil.GSON.toJson(response);
-      return HttpResponse.ok(svcResponse);
-    } catch (Exception e) {
-      return ServicesUtil.handleError(e, NAME, url);
-    }
-  }
-
-  /** HazardController.doGetHazard() handler. */
-  public static HttpResponse<String> handleDoGetHazard(
-      HttpRequest<?> request,
-      QueryParameters query) {
-
-    try {
-      // TODO still need to validate
-      // if (query.isEmpty()) {
-      // return handleDoGetUsage(urlHelper);
-      // }
-      // query.checkParameters();
-      var data = new RequestData(query);
-      var response = process(request, data);
-      var svcResponse = ServletUtil.GSON.toJson(response);
-      return HttpResponse.ok(svcResponse);
-    } catch (Exception e) {
-      return ServicesUtil.handleError(e, NAME, request.getUri().getPath());
-    }
-  }
-
-  static ResponseBody<RequestData, ResponseData> process(
-      HttpRequest<?> request,
-      RequestData data)
-      throws InterruptedException, ExecutionException {
-
-    var configFunction = new ConfigFunction();
-    var siteFunction = new SiteFunction(data);
-    var stopwatch = Stopwatch.createStarted();
-    var hazard = ServicesUtil.calcHazard(configFunction, siteFunction);
-
-    return new ResultBuilder()
-        .hazard(hazard)
-        .requestData(data)
-        .timer(stopwatch)
-        .url(request)
-        .build();
-  }
-
-  static class ConfigFunction implements Function<HazardModel, CalcConfig> {
-    @Override
-    public CalcConfig apply(HazardModel model) {
-      var configBuilder = CalcConfig.copyOf(model.config());
-      return configBuilder.build();
-    }
-  }
-
-  static class SiteFunction implements Function<CalcConfig, Site> {
-    final RequestData data;
-
-    private SiteFunction(RequestData data) {
-      this.data = data;
-    }
-
-    @Override // TODO this needs to pick up SiteData
-    public Site apply(CalcConfig config) {
-      return Site.builder()
-          .location(Location.create(data.longitude, data.latitude))
-          .vs30(data.vs30)
-          .build();
-    }
-  }
-
-  public static class QueryParameters {
-
-    final double longitude;
-    final double latitude;
-    final int vs30;
-    final boolean truncate;
-    final boolean maxdir;
-
-    public QueryParameters(
-        double longitude,
-        double latitude,
-        int vs30,
-        boolean truncate,
-        boolean maxdir) {
-
-      this.longitude = longitude;
-      this.latitude = latitude;
-      this.vs30 = vs30;
-      this.truncate = truncate;
-      this.maxdir = maxdir;
-    }
-
-    // void checkParameters() {
-    // checkParameter(longitude, "longitude");
-    // checkParameter(latitude, "latitude");
-    // checkParameter(vs30, "vs30");
-    // }
-  }
-
-  // private static void checkParameter(Object param, String id) {
-  // checkNotNull(param, "Missing parameter: %s", id);
-  // // TODO check range here
-  // }
-
-  /* Service request and model metadata */
-  static class RequestMetadata {
-
-    final SourceModel model;
-    final DoubleParameter longitude;
-    final DoubleParameter latitude;
-    final DoubleParameter vs30;
-
-    RequestMetadata(HazardModel model) {
-      this.model = new SourceModel(model);
-      // TODO need min max from model
-      longitude = new DoubleParameter(
-          "Longitude",
-          "°",
-          Coordinates.LON_RANGE.lowerEndpoint(),
-          Coordinates.LON_RANGE.upperEndpoint());
-
-      latitude = new DoubleParameter(
-          "Latitude",
-          "°",
-          Coordinates.LAT_RANGE.lowerEndpoint(),
-          Coordinates.LAT_RANGE.upperEndpoint());
-
-      vs30 = new DoubleParameter(
-          "Latitude",
-          "m/s",
-          150,
-          1500);
-    }
-  }
-
-  static class RequestData {
-
-    final double longitude;
-    final double latitude;
-    final double vs30;
-    final boolean truncate;
-    final boolean maxdir;
-
-    RequestData(QueryParameters query) {
-      this.longitude = query.longitude;
-      this.latitude = query.latitude;
-      this.vs30 = query.vs30;
-      this.truncate = query.truncate;
-      this.maxdir = query.maxdir;
-    }
-  }
-
-  private static final class ResponseMetadata {
-    final String xlabel = "Ground Motion (g)";
-    final String ylabel = "Annual Frequency of Exceedence";
-    final Object server;
-
-    ResponseMetadata(Object server) {
-      this.server = server;
-    }
-  }
-
-  private static String imtShortLabel(Imt imt) {
-    if (imt.equals(Imt.PGA) || imt.equals(Imt.PGV)) {
-      return imt.name();
-    } else if (imt.isSA()) {
-      return imt.period() + " s";
-    }
-    return imt.toString();
-  }
-
-  @Deprecated
-  static class RequestDataOld extends ServiceRequestData {
-    final double vs30;
-
-    RequestDataOld(Query query, double vs30) {
-      super(query);
-      this.vs30 = vs30;
-    }
-  }
-
-  private static final class ResponseData {
-    final ResponseMetadata metadata;
-    final List<HazardResponse> hazardCurves;
-
-    ResponseData(ResponseMetadata metadata, List<HazardResponse> hazardCurves) {
-      this.metadata = metadata;
-      this.hazardCurves = hazardCurves;
-    }
-  }
-
-  private static final class HazardResponse {
-    final Parameter imt;
-    final List<Curve> data;
-
-    HazardResponse(Imt imt, List<Curve> data) {
-      this.imt = new Parameter(imtShortLabel(imt), imt.name());
-      this.data = data;
-    }
-  }
-
-  private static final class Curve {
-    final String component;
-    final XySequence values;
-
-    Curve(String component, XySequence values) {
-      this.component = component;
-      this.values = values;
-    }
-  }
-
-  private static final String TOTAL_KEY = "Total";
-
-  private static final class ResultBuilder {
-
-    String url;
-    Stopwatch timer;
-    RequestData request;
-
-    Map<Imt, Map<SourceType, MutableXySequence>> componentMaps;
-    Map<Imt, MutableXySequence> totalMap;
-
-    ResultBuilder hazard(Hazard hazardResult) {
-      // TODO necessary??
-      checkState(totalMap == null, "Hazard has already been added to this builder");
-
-      componentMaps = new EnumMap<>(Imt.class);
-      totalMap = new EnumMap<>(Imt.class);
-
-      var typeTotalMaps = curvesBySource(hazardResult);
-
-      for (var imt : hazardResult.curves().keySet()) {
-
-        /* Total curve for IMT. */
-        XySequence.addToMap(imt, totalMap, hazardResult.curves().get(imt));
-
-        /* Source component curves for IMT. */
-        var typeTotalMap = typeTotalMaps.get(imt);
-        var componentMap = componentMaps.get(imt);
-
-        if (componentMap == null) {
-          componentMap = new EnumMap<>(SourceType.class);
-          componentMaps.put(imt, componentMap);
-        }
-
-        for (var type : typeTotalMap.keySet()) {
-          XySequence.addToMap(type, componentMap, typeTotalMap.get(type));
-        }
-      }
-
-      return this;
-    }
-
-    ResultBuilder url(HttpRequest<?> request) {
-      url = request.getUri().getPath();
-      return this;
-    }
-
-    ResultBuilder timer(Stopwatch timer) {
-      this.timer = timer;
-      return this;
-    }
-
-    ResultBuilder requestData(RequestData request) {
-      this.request = request;
-      return this;
-    }
-
-    ResponseBody<RequestData, ResponseData> build() {
-      var hazards = new ArrayList<HazardResponse>();
-
-      for (Imt imt : totalMap.keySet()) {
-        var curves = new ArrayList<Curve>();
-
-        // total curve
-        curves.add(new Curve(
-            TOTAL_KEY,
-            updateCurve(request, totalMap.get(imt), imt)));
-
-        // component curves
-        var typeMap = componentMaps.get(imt);
-        for (SourceType type : typeMap.keySet()) {
-          curves.add(new Curve(
-              type.toString(),
-              updateCurve(request, typeMap.get(type), imt)));
-        }
-
-        hazards.add(new HazardResponse(imt, List.copyOf(curves)));
-      }
-
-      Object server = Metadata.serverData(ServletUtil.THREAD_COUNT, timer);
-      var response = new ResponseData(new ResponseMetadata(server), List.copyOf(hazards));
-
-      return ResponseBody.<RequestData, ResponseData> success()
-          .name(NAME)
-          .url(url)
-          .request(request)
-          .response(response)
-          .build();
-    }
-  }
-
-  private static final double TRUNCATION_LIMIT = 1e-4;
-
-  /* Convert to linear and possibly truncate and scale to max-direction. */
-  private static XySequence updateCurve(
-      RequestData request,
-      XySequence curve,
-      Imt imt) {
-
-    /*
-     * If entire curve is <1e-4, this method will return a curve consisting of
-     * just the first point in the supplied curve.
-     *
-     * TODO We probably want to move the TRUNCATION_LIMIT out to a config.
-     */
-
-    double[] yValues = curve.yValues().toArray();
-    int limit = request.truncate ? truncationLimit(yValues) : yValues.length;
-    yValues = Arrays.copyOf(yValues, limit);
-
-    double scale = request.maxdir ? MaxDirection.FACTORS.get(imt) : 1.0;
-    double[] xValues = curve.xValues()
-        .limit(yValues.length)
-        .map(Math::exp)
-        .map(x -> x * scale)
-        .toArray();
-
-    return XySequence.create(xValues, yValues);
-  }
-
-  private static int truncationLimit(double[] yValues) {
-    int limit = 1;
-    double y = yValues[0];
-    while (y > TRUNCATION_LIMIT && limit < yValues.length) {
-      y = yValues[limit++];
-    }
-    return limit;
-  }
-
-  @Deprecated
-  public static class Query extends ServiceQueryData {
-    Integer vs30;
-
-    public Query(Double longitude, Double latitude, Integer vs30) {
-      super(longitude, latitude);
-      this.vs30 = vs30;
-    }
-
-    @Override
-    public boolean isNull() {
-      return super.isNull() && vs30 == null;
-    }
-
-    @Override
-    public void checkValues() {
-      super.checkValues();
-      WsUtils.checkValue(ServicesUtil.Key.VS30, vs30);
-    }
-  }
-
-}
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/services/HazardService2.java b/src/main/java/gov/usgs/earthquake/nshmp/www/services/HazardService2.java
deleted file mode 100644
index 63fb4261bd5c38b37aea9926f00c787e3cdbd1d1..0000000000000000000000000000000000000000
--- a/src/main/java/gov/usgs/earthquake/nshmp/www/services/HazardService2.java
+++ /dev/null
@@ -1,452 +0,0 @@
-package gov.usgs.earthquake.nshmp.www.services;
-
-import static com.google.common.base.Preconditions.checkState;
-import static gov.usgs.earthquake.nshmp.calc.HazardExport.curvesBySource;
-
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.EnumMap;
-import java.util.List;
-import java.util.Map;
-import java.util.concurrent.ExecutionException;
-import java.util.function.Function;
-
-import com.google.common.base.Stopwatch;
-
-import gov.usgs.earthquake.nshmp.calc.CalcConfig;
-import gov.usgs.earthquake.nshmp.calc.Hazard;
-import gov.usgs.earthquake.nshmp.calc.Site;
-import gov.usgs.earthquake.nshmp.data.MutableXySequence;
-import gov.usgs.earthquake.nshmp.data.XySequence;
-import gov.usgs.earthquake.nshmp.geo.Coordinates;
-import gov.usgs.earthquake.nshmp.geo.Location;
-import gov.usgs.earthquake.nshmp.gmm.Imt;
-import gov.usgs.earthquake.nshmp.model.HazardModel;
-import gov.usgs.earthquake.nshmp.model.SourceType;
-import gov.usgs.earthquake.nshmp.www.HazardController;
-import gov.usgs.earthquake.nshmp.www.ResponseBody;
-import gov.usgs.earthquake.nshmp.www.WsUtils;
-import gov.usgs.earthquake.nshmp.www.meta.DoubleParameter;
-import gov.usgs.earthquake.nshmp.www.meta.Metadata;
-import gov.usgs.earthquake.nshmp.www.meta.Parameter;
-import gov.usgs.earthquake.nshmp.www.services.ServicesUtil.ServiceQueryData;
-import gov.usgs.earthquake.nshmp.www.services.SourceServices.SourceModel;
-
-import io.micronaut.http.HttpRequest;
-import io.micronaut.http.HttpResponse;
-import jakarta.inject.Singleton;
-
-/**
- * Probabilistic seismic hazard calculation handler for
- * {@link HazardController}.
- *
- * @author U.S. Geological Survey
- */
-@Singleton
-public final class HazardService2 {
-
-  private static final String NAME = "Hazard Service";
-
-  /** HazardController.doGetUsage() handler. */
-  public static HttpResponse<String> handleDoGetMetadata(HttpRequest<?> request) {
-    var url = request.getUri().getPath();
-    try {
-      var usage = new RequestMetadata(ServletUtil.model());// SourceServices.ResponseData();
-      var response = ResponseBody.usage()
-          .name(NAME)
-          .url(url)
-          .request(url)
-          .response(usage)
-          .build();
-      var svcResponse = ServletUtil.GSON.toJson(response);
-      return HttpResponse.ok(svcResponse);
-    } catch (Exception e) {
-      return ServicesUtil.handleError(e, NAME, url);
-    }
-  }
-
-  /** HazardController.doGetHazard() handler. */
-  public static HttpResponse<String> handleDoGetHazard(
-      HttpRequest<?> request,
-      RequestData args) {
-
-    try {
-      // TODO still need to validate
-      // if (query.isEmpty()) {
-      // return handleDoGetUsage(urlHelper);
-      // }
-      // query.checkParameters();
-
-      // var data = new RequestData(query);
-
-      ResponseBody<RequestData, ResponseData> response = process(request, args);
-      String svcResponse = ServletUtil.GSON.toJson(response);
-      return HttpResponse.ok(svcResponse);
-
-    } catch (Exception e) {
-      return ServicesUtil.handleError(e, NAME, request.getUri().getPath());
-    }
-  }
-
-  static ResponseBody<RequestData, ResponseData> process(
-      HttpRequest<?> request,
-      RequestData data) throws InterruptedException, ExecutionException {
-
-    var configFunction = new ConfigFunction();
-    var siteFunction = new SiteFunction(data);
-    var stopwatch = Stopwatch.createStarted();
-    var hazard = ServicesUtil.calcHazard(configFunction, siteFunction);
-
-    return new ResultBuilder()
-        .hazard(hazard)
-        .requestData(data)
-        .timer(stopwatch)
-        .url(request)
-        .build();
-  }
-
-  static class ConfigFunction implements Function<HazardModel, CalcConfig> {
-    @Override
-    public CalcConfig apply(HazardModel model) {
-      var configBuilder = CalcConfig.copyOf(model.config());
-      return configBuilder.build();
-    }
-  }
-
-  static class SiteFunction implements Function<CalcConfig, Site> {
-    final RequestData data;
-
-    private SiteFunction(RequestData data) {
-      this.data = data;
-    }
-
-    @Override // TODO this needs to pick up SiteData
-    public Site apply(CalcConfig config) {
-      return Site.builder()
-          .location(Location.create(data.longitude, data.latitude))
-          .vs30(data.vs30)
-          .build();
-    }
-  }
-
-  // public static class QueryParameters {
-  //
-  // final double longitude;
-  // final double latitude;
-  // final int vs30;
-  // final boolean truncate;
-  // final boolean maxdir;
-  //
-  // public QueryParameters(
-  // double longitude,
-  // double latitude,
-  // int vs30,
-  // boolean truncate,
-  // boolean maxdir) {
-  //
-  // this.longitude = longitude;
-  // this.latitude = latitude;
-  // this.vs30 = vs30;
-  // this.truncate = truncate;
-  // this.maxdir = maxdir;
-  // }
-  //
-  // // void checkParameters() {
-  // // checkParameter(longitude, "longitude");
-  // // checkParameter(latitude, "latitude");
-  // // checkParameter(vs30, "vs30");
-  // // }
-  // }
-
-  // private static void checkParameter(Object param, String id) {
-  // checkNotNull(param, "Missing parameter: %s", id);
-  // // TODO check range here
-  // }
-
-  /* Service request and model metadata */
-  static class RequestMetadata {
-
-    final SourceModel model;
-    final DoubleParameter longitude;
-    final DoubleParameter latitude;
-    final DoubleParameter vs30;
-
-    RequestMetadata(HazardModel model) {
-      this.model = new SourceModel(model);
-      // TODO need min max from model
-      longitude = new DoubleParameter(
-          "Longitude",
-          "°",
-          Coordinates.LON_RANGE.lowerEndpoint(),
-          Coordinates.LON_RANGE.upperEndpoint());
-
-      latitude = new DoubleParameter(
-          "Latitude",
-          "°",
-          Coordinates.LAT_RANGE.lowerEndpoint(),
-          Coordinates.LAT_RANGE.upperEndpoint());
-
-      vs30 = new DoubleParameter(
-          "Latitude",
-          "m/s",
-          150,
-          1500);
-    }
-  }
-
-  // static class RequestData {
-  //
-  // final double longitude;
-  // final double latitude;
-  // final double vs30;
-  // final boolean truncate;
-  // final boolean maxdir;
-  //
-  // RequestData(QueryParameters query) {
-  // this.longitude = query.longitude;
-  // this.latitude = query.latitude;
-  // this.vs30 = query.vs30;
-  // this.truncate = query.truncate;
-  // this.maxdir = query.maxdir;
-  // }
-  // }
-
-  private static final class ResponseMetadata {
-    final String xlabel = "Ground Motion (g)";
-    final String ylabel = "Annual Frequency of Exceedence";
-    final Object server;
-
-    ResponseMetadata(Object server) {
-      this.server = server;
-    }
-  }
-
-  private static String imtShortLabel(Imt imt) {
-    if (imt.equals(Imt.PGA) || imt.equals(Imt.PGV)) {
-      return imt.name();
-    } else if (imt.isSA()) {
-      return imt.period() + " s";
-    }
-    return imt.toString();
-  }
-
-  // @Deprecated
-  // static class RequestDataOld extends ServiceRequestData {
-  // final double vs30;
-  //
-  // RequestDataOld(Query query, double vs30) {
-  // super(query);
-  // this.vs30 = vs30;
-  // }
-  // }
-
-  private static final class ResponseData {
-    final ResponseMetadata metadata;
-    final List<HazardResponse> hazardCurves;
-
-    ResponseData(ResponseMetadata metadata, List<HazardResponse> hazardCurves) {
-      this.metadata = metadata;
-      this.hazardCurves = hazardCurves;
-    }
-  }
-
-  private static final class HazardResponse {
-    final Parameter imt;
-    final List<Curve> data;
-
-    HazardResponse(Imt imt, List<Curve> data) {
-      this.imt = new Parameter(imtShortLabel(imt), imt.name());
-      this.data = data;
-    }
-  }
-
-  private static final class Curve {
-    final String component;
-    final XySequence values;
-
-    Curve(String component, XySequence values) {
-      this.component = component;
-      this.values = values;
-    }
-  }
-
-  private static final String TOTAL_KEY = "Total";
-
-  private static final class ResultBuilder {
-
-    String url;
-    Stopwatch timer;
-    RequestData request;
-
-    Map<Imt, Map<SourceType, MutableXySequence>> componentMaps;
-    Map<Imt, MutableXySequence> totalMap;
-
-    ResultBuilder hazard(Hazard hazardResult) {
-      // TODO necessary??
-      checkState(totalMap == null, "Hazard has already been added to this builder");
-
-      componentMaps = new EnumMap<>(Imt.class);
-      totalMap = new EnumMap<>(Imt.class);
-
-      var typeTotalMaps = curvesBySource(hazardResult);
-
-      for (var imt : hazardResult.curves().keySet()) {
-
-        /* Total curve for IMT. */
-        XySequence.addToMap(imt, totalMap, hazardResult.curves().get(imt));
-
-        /* Source component curves for IMT. */
-        var typeTotalMap = typeTotalMaps.get(imt);
-        var componentMap = componentMaps.get(imt);
-
-        if (componentMap == null) {
-          componentMap = new EnumMap<>(SourceType.class);
-          componentMaps.put(imt, componentMap);
-        }
-
-        for (var type : typeTotalMap.keySet()) {
-          XySequence.addToMap(type, componentMap, typeTotalMap.get(type));
-        }
-      }
-
-      return this;
-    }
-
-    ResultBuilder url(HttpRequest<?> request) {
-      url = request.getUri().getPath();
-      return this;
-    }
-
-    ResultBuilder timer(Stopwatch timer) {
-      this.timer = timer;
-      return this;
-    }
-
-    ResultBuilder requestData(RequestData request) {
-      this.request = request;
-      return this;
-    }
-
-    ResponseBody<RequestData, ResponseData> build() {
-      var hazards = new ArrayList<HazardResponse>();
-
-      for (Imt imt : totalMap.keySet()) {
-        var curves = new ArrayList<Curve>();
-
-        // total curve
-        curves.add(new Curve(
-            TOTAL_KEY,
-            updateCurve(request, totalMap.get(imt), imt)));
-
-        // component curves
-        var typeMap = componentMaps.get(imt);
-        for (SourceType type : typeMap.keySet()) {
-          curves.add(new Curve(
-              type.toString(),
-              updateCurve(request, typeMap.get(type), imt)));
-        }
-
-        hazards.add(new HazardResponse(imt, List.copyOf(curves)));
-      }
-
-      Object server = Metadata.serverData(ServletUtil.THREAD_COUNT, timer);
-      var response = new ResponseData(new ResponseMetadata(server), List.copyOf(hazards));
-
-      return ResponseBody.<RequestData, ResponseData> success()
-          .name(NAME)
-          .url(url)
-          .request(request)
-          .response(response)
-          .build();
-    }
-  }
-
-  private static final double TRUNCATION_LIMIT = 1e-4;
-
-  /* Convert to linear and possibly truncate and scale to max-direction. */
-  private static XySequence updateCurve(
-      RequestData request,
-      XySequence curve,
-      Imt imt) {
-
-    /*
-     * If entire curve is <1e-4, this method will return a curve consisting of
-     * just the first point in the supplied curve.
-     *
-     * TODO We probably want to move the TRUNCATION_LIMIT out to a config.
-     */
-
-    double[] yValues = curve.yValues().toArray();
-    int limit = request.truncate ? truncationLimit(yValues) : yValues.length;
-    yValues = Arrays.copyOf(yValues, limit);
-
-    double scale = request.maxdir ? MaxDirection.FACTORS.get(imt) : 1.0;
-    double[] xValues = curve.xValues()
-        .limit(yValues.length)
-        .map(Math::exp)
-        .map(x -> x * scale)
-        .toArray();
-
-    return XySequence.create(xValues, yValues);
-  }
-
-  private static int truncationLimit(double[] yValues) {
-    int limit = 1;
-    double y = yValues[0];
-    while (y > TRUNCATION_LIMIT && limit < yValues.length) {
-      y = yValues[limit++];
-    }
-    return limit;
-  }
-
-  @Deprecated
-  public static class Query extends ServiceQueryData {
-    Integer vs30;
-
-    public Query(Double longitude, Double latitude, Integer vs30) {
-      super(longitude, latitude);
-      this.vs30 = vs30;
-    }
-
-    @Override
-    public boolean isNull() {
-      return super.isNull() && vs30 == null;
-    }
-
-    @Override
-    public void checkValues() {
-      super.checkValues();
-      WsUtils.checkValue(ServicesUtil.Key.VS30, vs30);
-    }
-  }
-
-  public static final class RequestData {
-
-    final double longitude;
-    final double latitude;
-    final int vs30;
-    final boolean truncate;
-    final boolean maxdir;
-
-    public RequestData(
-        double longitude,
-        double latitude,
-        int vs30,
-        boolean truncate,
-        boolean maxdir) {
-
-      this.longitude = longitude;
-      this.latitude = latitude;
-      this.vs30 = vs30;
-      this.truncate = truncate;
-      this.maxdir = maxdir;
-    }
-
-    // void checkParameters() {
-    // checkParameter(longitude, "longitude");
-    // checkParameter(latitude, "latitude");
-    // checkParameter(vs30, "vs30");
-    // }
-  }
-
-}
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/services/RateService.java b/src/main/java/gov/usgs/earthquake/nshmp/www/services/RateService.java
index 0312e7359a0af379d43fdd55c8a5903edce67a2a..d4b81df814966cef2ba072dfbd42062d40f68b5b 100644
--- a/src/main/java/gov/usgs/earthquake/nshmp/www/services/RateService.java
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/services/RateService.java
@@ -6,6 +6,9 @@ import java.util.Optional;
 import java.util.concurrent.ExecutionException;
 import java.util.stream.Collectors;
 
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
 import com.google.common.base.Stopwatch;
 import com.google.common.util.concurrent.ListenableFuture;
 
@@ -17,13 +20,13 @@ import gov.usgs.earthquake.nshmp.geo.Location;
 import gov.usgs.earthquake.nshmp.model.HazardModel;
 import gov.usgs.earthquake.nshmp.www.RateController;
 import gov.usgs.earthquake.nshmp.www.ResponseBody;
+import gov.usgs.earthquake.nshmp.www.ServicesUtil.Key;
+import gov.usgs.earthquake.nshmp.www.ServicesUtil.ServiceQueryData;
+import gov.usgs.earthquake.nshmp.www.ServicesUtil.ServiceRequestData;
+import gov.usgs.earthquake.nshmp.www.ServletUtil;
 import gov.usgs.earthquake.nshmp.www.WsUtils;
 import gov.usgs.earthquake.nshmp.www.meta.DoubleParameter;
-import gov.usgs.earthquake.nshmp.www.meta.Metadata;
 import gov.usgs.earthquake.nshmp.www.meta.Metadata.DefaultParameters;
-import gov.usgs.earthquake.nshmp.www.services.ServicesUtil.Key;
-import gov.usgs.earthquake.nshmp.www.services.ServicesUtil.ServiceQueryData;
-import gov.usgs.earthquake.nshmp.www.services.ServicesUtil.ServiceRequestData;
 
 import io.micronaut.http.HttpRequest;
 import io.micronaut.http.HttpResponse;
@@ -38,6 +41,8 @@ import jakarta.inject.Singleton;
 @Singleton
 public final class RateService {
 
+  static final Logger LOG = LoggerFactory.getLogger(RateService.class);
+
   /*
    * Developer notes:
    *
@@ -62,7 +67,7 @@ public final class RateService {
       var json = ServletUtil.GSON.toJson(response);
       return HttpResponse.ok(json);
     } catch (Exception e) {
-      return ServicesUtil.handleError(e, service.name, request.getUri().getPath());
+      return ServletUtil.error(LOG, e, service.name, request.getUri().getPath());
     }
   }
 
@@ -91,7 +96,7 @@ public final class RateService {
       var svcResponse = ServletUtil.GSON.toJson(response);
       return HttpResponse.ok(svcResponse);
     } catch (Exception e) {
-      return ServicesUtil.handleError(e, service.name, request.getUri().getPath());
+      return ServletUtil.error(LOG, e, service.name, request.getUri().getPath());
     }
   }
 
@@ -271,7 +276,7 @@ public final class RateService {
     final List<Sequence> data;
 
     ResponseData(ResponseMetadata metadata, EqRate rates, Stopwatch timer) {
-      server = Metadata.serverData(ServletUtil.THREAD_COUNT, timer);
+      server = ServletUtil.serverData(ServletUtil.THREAD_COUNT, timer);
       this.metadata = metadata;
       this.data = buildSequence(rates);
     }
@@ -330,7 +335,7 @@ public final class RateService {
     private Usage(Service service, DefaultParameters parameters) {
       description = service.description;
       this.syntax = service.syntax;
-      server = Metadata.serverData(1, Stopwatch.createStarted());
+      server = ServletUtil.serverData(1, Stopwatch.createStarted());
       this.parameters = parameters;
     }
   }
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/services/SourceLogicTreesService.java b/src/main/java/gov/usgs/earthquake/nshmp/www/services/SourceLogicTreesService.java
index 3d0dfe0a7055376d3b5e764e252f23ca80f4f265..01e2d4c2186ce6f09a74094576a6b9f489a34e11 100644
--- a/src/main/java/gov/usgs/earthquake/nshmp/www/services/SourceLogicTreesService.java
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/services/SourceLogicTreesService.java
@@ -1,7 +1,11 @@
 package gov.usgs.earthquake.nshmp.www.services;
 
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
 import gov.usgs.earthquake.nshmp.model.Models;
 import gov.usgs.earthquake.nshmp.www.ResponseBody;
+import gov.usgs.earthquake.nshmp.www.ServletUtil;
 import gov.usgs.earthquake.nshmp.www.SourceLogicTreesController;
 
 import io.micronaut.http.HttpRequest;
@@ -16,6 +20,8 @@ import jakarta.inject.Singleton;
 @Singleton
 public class SourceLogicTreesService {
 
+  static final Logger LOG = LoggerFactory.getLogger(SourceLogicTreesService.class);
+
   private static final String NAME = "Source Logic Trees";
 
   /** SourceLogicTreesController.doGetMetadata() handler */
@@ -32,7 +38,7 @@ public class SourceLogicTreesService {
           .build();
       return HttpResponse.ok(ServletUtil.GSON.toJson(response));
     } catch (Exception e) {
-      return ServicesUtil.handleError(e, NAME, url);
+      return ServletUtil.error(LOG, e, NAME, url);
     }
   }
 
@@ -51,7 +57,7 @@ public class SourceLogicTreesService {
           .build();
       return HttpResponse.ok(ServletUtil.GSON.toJson(response));
     } catch (Exception e) {
-      return ServicesUtil.handleError(e, NAME, url);
+      return ServletUtil.error(LOG, e, NAME, url);
     }
   }
 
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/services/SourceServices.java b/src/main/java/gov/usgs/earthquake/nshmp/www/services/SourceServices.java
index 83910b2fc623cd7b27e81855da5263435cb87d65..b473fd92f80d3cc808aa1419511dfa247b729d2e 100644
--- a/src/main/java/gov/usgs/earthquake/nshmp/www/services/SourceServices.java
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/services/SourceServices.java
@@ -1,8 +1,14 @@
 package gov.usgs.earthquake.nshmp.www.services;
 
+import static java.util.stream.Collectors.toList;
+
+import java.util.List;
 import java.util.Map;
 import java.util.Set;
 
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
 import com.google.common.base.Stopwatch;
 import com.google.gson.Gson;
 import com.google.gson.GsonBuilder;
@@ -12,8 +18,9 @@ import gov.usgs.earthquake.nshmp.gmm.Imt;
 import gov.usgs.earthquake.nshmp.gmm.NehrpSiteClass;
 import gov.usgs.earthquake.nshmp.model.HazardModel;
 import gov.usgs.earthquake.nshmp.www.ResponseBody;
+import gov.usgs.earthquake.nshmp.www.ServletUtil;
 import gov.usgs.earthquake.nshmp.www.WsUtils;
-import gov.usgs.earthquake.nshmp.www.meta.Metadata;
+import gov.usgs.earthquake.nshmp.www.meta.Parameter;
 
 import io.micronaut.http.HttpRequest;
 import io.micronaut.http.HttpResponse;
@@ -33,6 +40,8 @@ public class SourceServices {
   private static final String SERVICE_DESCRIPTION =
       "Utilities for querying earthquake source models";
 
+  static final Logger LOG = LoggerFactory.getLogger(RateService.class);
+
   public static final Gson GSON;
 
   static {
@@ -56,7 +65,7 @@ public class SourceServices {
       var json = GSON.toJson(response);
       return HttpResponse.ok(json);
     } catch (Exception e) {
-      return ServicesUtil.handleError(e, NAME, url);
+      return ServletUtil.error(LOG, e, NAME, url);
     }
   }
 
@@ -71,7 +80,9 @@ public class SourceServices {
 
     public ResponseData() {
       this.description = "Installed source model listing";
-      this.server = Metadata.serverData(ServletUtil.THREAD_COUNT, Stopwatch.createStarted());
+      this.server = ServletUtil.serverData(
+          ServletUtil.THREAD_COUNT,
+          Stopwatch.createStarted());
       // this.parameters = new Parameters();
     }
   }
@@ -104,11 +115,19 @@ public class SourceServices {
     String name;
     Set<Gmm> gmms;
     Map<NehrpSiteClass, Double> siteClasses;
+    List<Parameter> imts;
 
-    SourceModel(HazardModel model) {
+    public SourceModel(HazardModel model) {
       name = model.name();
       gmms = model.gmms();
       siteClasses = model.siteClasses();
+      imts = model.gmms().stream()
+          .map(Gmm::supportedImts)
+          .flatMap(Set::stream)
+          .distinct()
+          .sorted()
+          .map(imt -> new Parameter(ServletUtil.imtShortLabel(imt), imt.name()))
+          .collect(toList());
     }
 
     // public static List<SourceModel> getList() {
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index d4d11ef0c76a2efa5215bacfdf56dc4f1d56d3e9..5ca3350641b37f5b8d35d94bb59f1bea3dc5b00b 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -20,4 +20,6 @@ nshmp-haz:
   # The path to the models.
   # To specify the model to use:
   #     java -jar build/libs/nshmp-haz.jar --models=<path/to/models>
+  #
   model-path: ${models:libs/nshmp-haz-dep--nshm-hi-2021}
+  # model-path: ${models:libs/nshmp-haz-dep--nshm-conus-2018}