diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
index aaf77a3775e86614190d55088b21a86a76faa373..ce3bee95b1f20cb2d14f1dd3a009cc91d2475af9 100644
--- a/CODE_OF_CONDUCT.md
+++ b/CODE_OF_CONDUCT.md
@@ -3,4 +3,4 @@
 All contributions to and interactions surrounding this project will abide by
 the [USGS Code of Scientific Conduct][1].
 
-[1]: https://www.usgs.gov/about/organization/science-support/office-science-quality-and-integrity/code-scientific-conduct
+[1]: https://www.usgs.gov/office-of-science-quality-and-integrity/fundamental-science-practices
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 13b639a18358d12bbe5ca7633a95d27ea47e0cad..677ba76f9a3905b72323889a3a0de7bc62e8bb89 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -11,5 +11,5 @@ into the project you can [fork this repository][2] and
 [submit a merge request][3] for review.
 
 [1]: https://code.usgs.gov/ghsc/nshmp/nshmp-haz/-/issues
-[2]: https://docs.gitlab.com/ee/gitlab-basics/fork-project.html
-[3]: https://docs.gitlab.com/ee/gitlab-basics/add-merge-request.html
+[2]: https://docs.gitlab.com/ee/user/project/repository/forking_workflow.html#creating-a-fork
+[3]: https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html
diff --git a/build.gradle b/build.gradle
index d501fb01b2424d06d35114748b54e062a5afa56f..c3b7e91b1a32ed3db5170289cb249e73cdcd848e 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,32 +1,3 @@
-/*
- * In order to build and run services locally,
- * one needs to include the following directorie(s) in the root project (they
- * are ignored by git):
- *
- *    models/
- *       ak/
- *          2007/
- *       wus/
- *          2008/
- *          2014/
- *          2014b/
- *          2018/
- *       ceus/
- *          2008/
- *          2014/
- *          2018/
- *
- * ...with each 'year' directory being an alias to the corresponding
- * git repository. One only needs to include the model they will run in the services.
- *
- * Then build the JAR file: ./gradlew assemble
- *
- * One can then specify the model to run in the services:
- *    java -jar build/libs/nshmp-haz.jar --models=/path/to/models
- *
- * Where <MODEL> is one of the model enums in nshmp.www.Model
- */
-
 plugins {
   id "application"
   id "com.diffplug.spotless" version "${spotlessVersion}"
diff --git a/etc/nshm/wus-ceus-attenuation-boundary-2023.geojson b/etc/nshm/wus-ceus-attenuation-boundary-2023.geojson
new file mode 100644
index 0000000000000000000000000000000000000000..a69f5638ce724f86f93f6c48f068855d871f2c95
--- /dev/null
+++ b/etc/nshm/wus-ceus-attenuation-boundary-2023.geojson
@@ -0,0 +1,57 @@
+{
+  "type": "Feature",
+  "geometry": {
+    "type": "LineString",
+    "coordinates": [
+      [-113.348, 48.997],
+      [-112.200, 47.550],
+      [-110.600, 46.850],
+      [-110.500, 46.450],
+      [-110.350, 45.750],
+      [-110.000, 45.500],
+      [-109.900, 44.900],
+      [-110.050, 44.150],
+      [-110.500, 43.000],
+      [-110.500, 42.180],
+      [-110.750, 41.000],
+      [-110.200, 40.000],
+      [-111.350, 39.550],
+      [-112.250, 38.000],
+      [-112.450, 37.000],
+      [-112.300, 36.200],
+      [-112.300, 35.700],
+      [-112.300, 35.250],
+      [-111.000, 34.500],
+      [-110.000, 34.400],
+      [-109.000, 34.250],
+      [-108.000, 34.600],
+      [-107.450, 35.000],
+      [-107.450, 36.500],
+      [-108.200, 37.000],
+      [-108.250, 38.000],
+      [-108.200, 38.750],
+      [-108.000, 39.100],
+      [-107.500, 39.550],
+      [-107.000, 39.650],
+      [-106.500, 39.800],
+      [-106.000, 39.850],
+      [-105.400, 39.800],
+      [-104.800, 38.450],
+      [-105.100, 37.900],
+      [-104.500, 37.100],
+      [-104.600, 36.950],
+      [-105.120, 36.810],
+      [-105.170, 36.590],
+      [-105.300, 35.950],
+      [-105.250, 34.450],
+      [-105.550, 33.850],
+      [-105.350, 33.250],
+      [-104.500, 31.700],
+      [-102.361, 29.828]
+    ]
+  },
+  "properties": {
+    "stroke": "#FC4445",
+    "title": "WUS-CEUS Attenuation Boundary (2023)"
+  }
+}
diff --git a/etc/peer/models/Set1-Case1/model-info.json b/etc/peer/models/Set1-Case1/model-info.json
index 3b829666c543624ee777c66c48f4f407b1e4b3c6..7df15efda610e7d8193a2a1f96fd8cb44a6414a2 100644
--- a/etc/peer/models/Set1-Case1/model-info.json
+++ b/etc/peer/models/Set1-Case1/model-info.json
@@ -1,4 +1,10 @@
 {
   "name": "PEER Set1-Case1",
-  "site-class-vs30": { "BC": 760 }
+  "site-class-vs30": { "BC": 760 },
+  "bounds":  {
+    "min-latitude": 36.0,
+    "min-longitude": -124.0,
+    "max-latitude": 40.0,
+    "max-longitude": -118.0
+  }
 }
diff --git a/etc/peer/models/Set1-Case10-fast/model-info.json b/etc/peer/models/Set1-Case10-fast/model-info.json
index 694ee43da3ff65e65d7be33b0f516302e9107ed2..c5fb0564afaa886d3c648c3a9697ef1de318d4c4 100644
--- a/etc/peer/models/Set1-Case10-fast/model-info.json
+++ b/etc/peer/models/Set1-Case10-fast/model-info.json
@@ -1,4 +1,10 @@
 {
   "name": "PEER Set1-Case10-fast",
-  "site-class-vs30": { "BC": 760 }
+  "site-class-vs30": { "BC": 760 },
+  "bounds":  {
+    "min-latitude": 36.0,
+    "min-longitude": -124.0,
+    "max-latitude": 40.0,
+    "max-longitude": -118.0
+  }
 }
diff --git a/etc/peer/models/Set1-Case10/model-info.json b/etc/peer/models/Set1-Case10/model-info.json
index de753fa4c1575fc4f90fd8724a0fad0cec39ef57..fe35bed24b011a14d8cde17a72f315dc1dd720d2 100644
--- a/etc/peer/models/Set1-Case10/model-info.json
+++ b/etc/peer/models/Set1-Case10/model-info.json
@@ -1,4 +1,10 @@
 {
   "name": "PEER Set1-Case10",
-  "site-class-vs30": { "BC": 760 }
+  "site-class-vs30": { "BC": 760 },
+  "bounds":  {
+    "min-latitude": 36.0,
+    "min-longitude": -124.0,
+    "max-latitude": 40.0,
+    "max-longitude": -118.0
+  }
 }
diff --git a/etc/peer/models/Set1-Case11-fast/model-info.json b/etc/peer/models/Set1-Case11-fast/model-info.json
index 2dacce72ac9a11ebb82894fcf6227a1f661e514f..cfd96ad84b0779309aa4d1585935ca3c3445d603 100644
--- a/etc/peer/models/Set1-Case11-fast/model-info.json
+++ b/etc/peer/models/Set1-Case11-fast/model-info.json
@@ -1,4 +1,10 @@
 {
   "name": "PEER Set1-Case11-fast",
-  "site-class-vs30": { "BC": 760 }
+  "site-class-vs30": { "BC": 760 },
+  "bounds":  {
+    "min-latitude": 36.0,
+    "min-longitude": -124.0,
+    "max-latitude": 40.0,
+    "max-longitude": -118.0
+  }
 }
diff --git a/etc/peer/models/Set1-Case11/model-info.json b/etc/peer/models/Set1-Case11/model-info.json
index 409dfa0b57238c2dcb29a9c0579000e7d637ac88..448442e2f2a14b9ab8f7306809ade2e4d5e6af62 100644
--- a/etc/peer/models/Set1-Case11/model-info.json
+++ b/etc/peer/models/Set1-Case11/model-info.json
@@ -1,4 +1,10 @@
 {
   "name": "PEER Set1-Case11",
-  "site-class-vs30": { "BC": 760 }
+  "site-class-vs30": { "BC": 760 },
+  "bounds":  {
+    "min-latitude": 36.0,
+    "min-longitude": -124.0,
+    "max-latitude": 40.0,
+    "max-longitude": -118.0
+  }
 }
diff --git a/etc/peer/models/Set1-Case2-fast/model-info.json b/etc/peer/models/Set1-Case2-fast/model-info.json
index 9370e654df21f5ee1d52871c0ce6076c0ac67cba..3a9a2be596ceafaf0a21afdd2abbab11ed4482be 100644
--- a/etc/peer/models/Set1-Case2-fast/model-info.json
+++ b/etc/peer/models/Set1-Case2-fast/model-info.json
@@ -1,4 +1,10 @@
 {
   "name": "PEER Set1-Case2-fast",
-  "site-class-vs30": { "BC": 760 }
+  "site-class-vs30": { "BC": 760 },
+  "bounds":  {
+    "min-latitude": 36.0,
+    "min-longitude": -124.0,
+    "max-latitude": 40.0,
+    "max-longitude": -118.0
+  }
 }
diff --git a/etc/peer/models/Set1-Case2/model-info.json b/etc/peer/models/Set1-Case2/model-info.json
index b3d26fbdd80d5cf69664e3cb0e555749f0ec2a80..70bc644a33064e124e67bd11fc5f79ceebf0abc1 100644
--- a/etc/peer/models/Set1-Case2/model-info.json
+++ b/etc/peer/models/Set1-Case2/model-info.json
@@ -1,4 +1,10 @@
 {
   "name": "PEER Set1-Case2",
-  "site-class-vs30": { "BC": 760 }
+  "site-class-vs30": { "BC": 760 },
+  "bounds":  {
+    "min-latitude": 36.0,
+    "min-longitude": -124.0,
+    "max-latitude": 40.0,
+    "max-longitude": -118.0
+  }
 }
diff --git a/etc/peer/models/Set1-Case3-fast/model-info.json b/etc/peer/models/Set1-Case3-fast/model-info.json
index d4eeae0b2fd3582181b117346de1e3f5d7ae6d8d..a89a4123a6b3ea15496eb853ca585c0579b632c1 100644
--- a/etc/peer/models/Set1-Case3-fast/model-info.json
+++ b/etc/peer/models/Set1-Case3-fast/model-info.json
@@ -1,4 +1,10 @@
 {
   "name": "PEER Set1-Case3-fast",
-  "site-class-vs30": { "BC": 760 }
+  "site-class-vs30": { "BC": 760 },
+  "bounds":  {
+    "min-latitude": 36.0,
+    "min-longitude": -124.0,
+    "max-latitude": 40.0,
+    "max-longitude": -118.0
+  }
 }
diff --git a/etc/peer/models/Set1-Case3/model-info.json b/etc/peer/models/Set1-Case3/model-info.json
index d4eeae0b2fd3582181b117346de1e3f5d7ae6d8d..a89a4123a6b3ea15496eb853ca585c0579b632c1 100644
--- a/etc/peer/models/Set1-Case3/model-info.json
+++ b/etc/peer/models/Set1-Case3/model-info.json
@@ -1,4 +1,10 @@
 {
   "name": "PEER Set1-Case3-fast",
-  "site-class-vs30": { "BC": 760 }
+  "site-class-vs30": { "BC": 760 },
+  "bounds":  {
+    "min-latitude": 36.0,
+    "min-longitude": -124.0,
+    "max-latitude": 40.0,
+    "max-longitude": -118.0
+  }
 }
diff --git a/etc/peer/models/Set1-Case4-fast/model-info.json b/etc/peer/models/Set1-Case4-fast/model-info.json
index 10ef01740fc44dacf402246d3a9c7c5cff1214d9..e6d0a7318a89120c9a0a9f0b81772c74e9158a34 100644
--- a/etc/peer/models/Set1-Case4-fast/model-info.json
+++ b/etc/peer/models/Set1-Case4-fast/model-info.json
@@ -1,4 +1,10 @@
 {
   "name": "PEER Set1-Case4-fast",
-  "site-class-vs30": { "BC": 760 }
+  "site-class-vs30": { "BC": 760 },
+  "bounds":  {
+    "min-latitude": 36.0,
+    "min-longitude": -124.0,
+    "max-latitude": 40.0,
+    "max-longitude": -118.0
+  }
 }
diff --git a/etc/peer/models/Set1-Case4/model-info.json b/etc/peer/models/Set1-Case4/model-info.json
index c21277c9ca57f672a405432eb535a17ab14e6fbf..f36badfadf82b8ea17f7619b1144219b800dba97 100644
--- a/etc/peer/models/Set1-Case4/model-info.json
+++ b/etc/peer/models/Set1-Case4/model-info.json
@@ -1,4 +1,10 @@
 {
   "name": "PEER Set1-Case4",
-  "site-class-vs30": { "BC": 760 }
+  "site-class-vs30": { "BC": 760 },
+  "bounds":  {
+    "min-latitude": 36.0,
+    "min-longitude": -124.0,
+    "max-latitude": 40.0,
+    "max-longitude": -118.0
+  }
 }
diff --git a/etc/peer/models/Set1-Case5-fast/model-info.json b/etc/peer/models/Set1-Case5-fast/model-info.json
index 853a2a13628992c654794ae55446e6252bdd8848..26f0d06516538ee3ae220071cc1c1316f2857f52 100644
--- a/etc/peer/models/Set1-Case5-fast/model-info.json
+++ b/etc/peer/models/Set1-Case5-fast/model-info.json
@@ -1,4 +1,10 @@
 {
   "name": "PEER Set1-Case5-fast",
-  "site-class-vs30": { "BC": 760 }
+  "site-class-vs30": { "BC": 760 },
+  "bounds":  {
+    "min-latitude": 36.0,
+    "min-longitude": -124.0,
+    "max-latitude": 40.0,
+    "max-longitude": -118.0
+  }
 }
diff --git a/etc/peer/models/Set1-Case5/model-info.json b/etc/peer/models/Set1-Case5/model-info.json
index 853a2a13628992c654794ae55446e6252bdd8848..26f0d06516538ee3ae220071cc1c1316f2857f52 100644
--- a/etc/peer/models/Set1-Case5/model-info.json
+++ b/etc/peer/models/Set1-Case5/model-info.json
@@ -1,4 +1,10 @@
 {
   "name": "PEER Set1-Case5-fast",
-  "site-class-vs30": { "BC": 760 }
+  "site-class-vs30": { "BC": 760 },
+  "bounds":  {
+    "min-latitude": 36.0,
+    "min-longitude": -124.0,
+    "max-latitude": 40.0,
+    "max-longitude": -118.0
+  }
 }
diff --git a/etc/peer/models/Set1-Case6-fast/model-info.json b/etc/peer/models/Set1-Case6-fast/model-info.json
index 796efa335615b9d152f54fe86af2601d2215bfc9..6d30fa7484831cbfe320bf8fabba3de6a06403bc 100644
--- a/etc/peer/models/Set1-Case6-fast/model-info.json
+++ b/etc/peer/models/Set1-Case6-fast/model-info.json
@@ -1,4 +1,10 @@
 {
   "name": "PEER Set1-Case6-fast",
-  "site-class-vs30": { "BC": 760 }
+  "site-class-vs30": { "BC": 760 },
+  "bounds":  {
+    "min-latitude": 36.0,
+    "min-longitude": -124.0,
+    "max-latitude": 40.0,
+    "max-longitude": -118.0
+  }
 }
diff --git a/etc/peer/models/Set1-Case6/model-info.json b/etc/peer/models/Set1-Case6/model-info.json
index 796efa335615b9d152f54fe86af2601d2215bfc9..6d30fa7484831cbfe320bf8fabba3de6a06403bc 100644
--- a/etc/peer/models/Set1-Case6/model-info.json
+++ b/etc/peer/models/Set1-Case6/model-info.json
@@ -1,4 +1,10 @@
 {
   "name": "PEER Set1-Case6-fast",
-  "site-class-vs30": { "BC": 760 }
+  "site-class-vs30": { "BC": 760 },
+  "bounds":  {
+    "min-latitude": 36.0,
+    "min-longitude": -124.0,
+    "max-latitude": 40.0,
+    "max-longitude": -118.0
+  }
 }
diff --git a/etc/peer/models/Set1-Case7-fast/model-info.json b/etc/peer/models/Set1-Case7-fast/model-info.json
index 89050cd2b33e3960e66cff4fa40bf732b27720e2..89447337c78aa29ced226a5a1cb8137f67f7d8ad 100644
--- a/etc/peer/models/Set1-Case7-fast/model-info.json
+++ b/etc/peer/models/Set1-Case7-fast/model-info.json
@@ -1,4 +1,10 @@
 {
   "name": "PEER Set1-Case7-fast",
-  "site-class-vs30": { "BC": 760 }
+  "site-class-vs30": { "BC": 760 },
+  "bounds":  {
+    "min-latitude": 36.0,
+    "min-longitude": -124.0,
+    "max-latitude": 40.0,
+    "max-longitude": -118.0
+  }
 }
diff --git a/etc/peer/models/Set1-Case7/model-info.json b/etc/peer/models/Set1-Case7/model-info.json
index 89050cd2b33e3960e66cff4fa40bf732b27720e2..89447337c78aa29ced226a5a1cb8137f67f7d8ad 100644
--- a/etc/peer/models/Set1-Case7/model-info.json
+++ b/etc/peer/models/Set1-Case7/model-info.json
@@ -1,4 +1,10 @@
 {
   "name": "PEER Set1-Case7-fast",
-  "site-class-vs30": { "BC": 760 }
+  "site-class-vs30": { "BC": 760 },
+  "bounds":  {
+    "min-latitude": 36.0,
+    "min-longitude": -124.0,
+    "max-latitude": 40.0,
+    "max-longitude": -118.0
+  }
 }
diff --git a/etc/peer/models/Set1-Case8a/model-info.json b/etc/peer/models/Set1-Case8a/model-info.json
index abb6a5ec286edf83e2bbd51a543c5483f2bfe935..bcb8f124a9b76e9f1e5f46e089e00a405c17d3ae 100644
--- a/etc/peer/models/Set1-Case8a/model-info.json
+++ b/etc/peer/models/Set1-Case8a/model-info.json
@@ -1,4 +1,10 @@
 {
   "name": "PEER Set1-Case8a",
-  "site-class-vs30": { "BC": 760 }
+  "site-class-vs30": { "BC": 760 },
+  "bounds":  {
+    "min-latitude": 36.0,
+    "min-longitude": -124.0,
+    "max-latitude": 40.0,
+    "max-longitude": -118.0
+  }
 }
diff --git a/etc/peer/models/Set1-Case8b/model-info.json b/etc/peer/models/Set1-Case8b/model-info.json
index 4aad25644fe75856adb6116af27b6932503981f3..b02d84ea62f5f8329c81774aa0500baee7e912ca 100644
--- a/etc/peer/models/Set1-Case8b/model-info.json
+++ b/etc/peer/models/Set1-Case8b/model-info.json
@@ -1,4 +1,10 @@
 {
   "name": "PEER Set1-Case8b",
-  "site-class-vs30": { "BC": 760 }
+  "site-class-vs30": { "BC": 760 },
+  "bounds":  {
+    "min-latitude": 36.0,
+    "min-longitude": -124.0,
+    "max-latitude": 40.0,
+    "max-longitude": -118.0
+  }
 }
diff --git a/etc/peer/models/Set1-Case8c/model-info.json b/etc/peer/models/Set1-Case8c/model-info.json
index 4aad25644fe75856adb6116af27b6932503981f3..b02d84ea62f5f8329c81774aa0500baee7e912ca 100644
--- a/etc/peer/models/Set1-Case8c/model-info.json
+++ b/etc/peer/models/Set1-Case8c/model-info.json
@@ -1,4 +1,10 @@
 {
   "name": "PEER Set1-Case8b",
-  "site-class-vs30": { "BC": 760 }
+  "site-class-vs30": { "BC": 760 },
+  "bounds":  {
+    "min-latitude": 36.0,
+    "min-longitude": -124.0,
+    "max-latitude": 40.0,
+    "max-longitude": -118.0
+  }
 }
diff --git a/etc/peer/models/Set2-Case2a-fast/model-info.json b/etc/peer/models/Set2-Case2a-fast/model-info.json
index 8cc0e12f4d8c1ebd2010ac401a5ef0ae684b9c4b..f8f95470ce2133dd43b33805bcbcc6df73345afa 100644
--- a/etc/peer/models/Set2-Case2a-fast/model-info.json
+++ b/etc/peer/models/Set2-Case2a-fast/model-info.json
@@ -1,4 +1,10 @@
 {
   "name": "PEER Set2-Case2a-fast",
-  "site-class-vs30": { "BC": 760 }
+  "site-class-vs30": { "BC": 760 },
+  "bounds":  {
+    "min-latitude": -5.0,
+    "min-longitude": -70.0,
+    "max-latitude": 5.0,
+    "max-longitude": -50.0
+  }
 }
diff --git a/etc/peer/models/Set2-Case2a/model-info.json b/etc/peer/models/Set2-Case2a/model-info.json
index 43538f814b659dad721afae06c3908f2e933cbfa..f4e4ce90d67c59ce1eba873085965a330a1d7c23 100644
--- a/etc/peer/models/Set2-Case2a/model-info.json
+++ b/etc/peer/models/Set2-Case2a/model-info.json
@@ -1,4 +1,10 @@
 {
   "name": "PEER Set2-Case2a",
-  "site-class-vs30": { "BC": 760 }
+  "site-class-vs30": { "BC": 760 },
+  "bounds":  {
+    "min-latitude": -5.0,
+    "min-longitude": -70.0,
+    "max-latitude": 5.0,
+    "max-longitude": -50.0
+  }
 }
diff --git a/etc/peer/models/Set2-Case2b-fast/model-info.json b/etc/peer/models/Set2-Case2b-fast/model-info.json
index f6794d3c6f1f7e6a4829d6ab3067b0cc8fa99235..5453bac3d20a3a41e2a1021378d4adcf6eb32107 100644
--- a/etc/peer/models/Set2-Case2b-fast/model-info.json
+++ b/etc/peer/models/Set2-Case2b-fast/model-info.json
@@ -1,4 +1,10 @@
 {
   "name": "PEER Set2-Case2b-fast",
-  "site-class-vs30": { "BC": 760 }
+  "site-class-vs30": { "BC": 760 },
+  "bounds":  {
+    "min-latitude": -5.0,
+    "min-longitude": -70.0,
+    "max-latitude": 5.0,
+    "max-longitude": -50.0
+  }
 }
diff --git a/etc/peer/models/Set2-Case2b/model-info.json b/etc/peer/models/Set2-Case2b/model-info.json
index f6794d3c6f1f7e6a4829d6ab3067b0cc8fa99235..5453bac3d20a3a41e2a1021378d4adcf6eb32107 100644
--- a/etc/peer/models/Set2-Case2b/model-info.json
+++ b/etc/peer/models/Set2-Case2b/model-info.json
@@ -1,4 +1,10 @@
 {
   "name": "PEER Set2-Case2b-fast",
-  "site-class-vs30": { "BC": 760 }
+  "site-class-vs30": { "BC": 760 },
+  "bounds":  {
+    "min-latitude": -5.0,
+    "min-longitude": -70.0,
+    "max-latitude": 5.0,
+    "max-longitude": -50.0
+  }
 }
diff --git a/etc/peer/models/Set2-Case2c-fast/model-info.json b/etc/peer/models/Set2-Case2c-fast/model-info.json
index fb52af86627159752d45eea41d2f1427cc793e4f..6a7b86f90c82246a80086783333a1e307545a595 100644
--- a/etc/peer/models/Set2-Case2c-fast/model-info.json
+++ b/etc/peer/models/Set2-Case2c-fast/model-info.json
@@ -1,4 +1,10 @@
 {
   "name": "PEER Set2-Case2c-fast",
-  "site-class-vs30": { "BC": 760 }
+  "site-class-vs30": { "BC": 760 },
+  "bounds":  {
+    "min-latitude": -5.0,
+    "min-longitude": -70.0,
+    "max-latitude": 5.0,
+    "max-longitude": -50.0
+  }
 }
diff --git a/etc/peer/models/Set2-Case2c/model-info.json b/etc/peer/models/Set2-Case2c/model-info.json
index fb52af86627159752d45eea41d2f1427cc793e4f..6a7b86f90c82246a80086783333a1e307545a595 100644
--- a/etc/peer/models/Set2-Case2c/model-info.json
+++ b/etc/peer/models/Set2-Case2c/model-info.json
@@ -1,4 +1,10 @@
 {
   "name": "PEER Set2-Case2c-fast",
-  "site-class-vs30": { "BC": 760 }
+  "site-class-vs30": { "BC": 760 },
+  "bounds":  {
+    "min-latitude": -5.0,
+    "min-longitude": -70.0,
+    "max-latitude": 5.0,
+    "max-longitude": -50.0
+  }
 }
diff --git a/etc/peer/models/Set2-Case2d-fast/model-info.json b/etc/peer/models/Set2-Case2d-fast/model-info.json
index 3bba48c392296f1d2973a36b2a410224083c3339..5175131265c70c6ca879c7ce3fdbcd605a70cec3 100644
--- a/etc/peer/models/Set2-Case2d-fast/model-info.json
+++ b/etc/peer/models/Set2-Case2d-fast/model-info.json
@@ -1,4 +1,10 @@
 {
   "name": "PEER Set2-Case2d-fast",
-  "site-class-vs30": { "BC": 760 }
+  "site-class-vs30": { "BC": 760 },
+  "bounds":  {
+    "min-latitude": -5.0,
+    "min-longitude": -70.0,
+    "max-latitude": 5.0,
+    "max-longitude": -50.0
+  }
 }
diff --git a/etc/peer/models/Set2-Case2d/model-info.json b/etc/peer/models/Set2-Case2d/model-info.json
index 3bba48c392296f1d2973a36b2a410224083c3339..5175131265c70c6ca879c7ce3fdbcd605a70cec3 100644
--- a/etc/peer/models/Set2-Case2d/model-info.json
+++ b/etc/peer/models/Set2-Case2d/model-info.json
@@ -1,4 +1,10 @@
 {
   "name": "PEER Set2-Case2d-fast",
-  "site-class-vs30": { "BC": 760 }
+  "site-class-vs30": { "BC": 760 },
+  "bounds":  {
+    "min-latitude": -5.0,
+    "min-longitude": -70.0,
+    "max-latitude": 5.0,
+    "max-longitude": -50.0
+  }
 }
diff --git a/etc/peer/models/Set2-Case3a-fast/model-info.json b/etc/peer/models/Set2-Case3a-fast/model-info.json
index 07da7d081f527bcddbb86afad8bce6d35a732d8a..6bb7beb0eb34db76447f69179018880dcaec0ce3 100644
--- a/etc/peer/models/Set2-Case3a-fast/model-info.json
+++ b/etc/peer/models/Set2-Case3a-fast/model-info.json
@@ -1,4 +1,10 @@
 {
   "name": "PEER Set2-Case3a-fast",
-  "site-class-vs30": { "BC": 760 }
+  "site-class-vs30": { "BC": 760 },
+  "bounds":  {
+    "min-latitude": -5.0,
+    "min-longitude": -70.0,
+    "max-latitude": 5.0,
+    "max-longitude": -50.0
+  }
 }
diff --git a/etc/peer/models/Set2-Case3a/model-info.json b/etc/peer/models/Set2-Case3a/model-info.json
index 42cd89f3572129d5036a59c69f9a99bb9e1fcea2..55edbc36e031880f3fd30eb463f826566762c298 100644
--- a/etc/peer/models/Set2-Case3a/model-info.json
+++ b/etc/peer/models/Set2-Case3a/model-info.json
@@ -1,4 +1,10 @@
 {
   "name": "PEER Set2-Case3a",
-  "site-class-vs30": { "BC": 760 }
+  "site-class-vs30": { "BC": 760 },
+  "bounds":  {
+    "min-latitude": -5.0,
+    "min-longitude": -70.0,
+    "max-latitude": 5.0,
+    "max-longitude": -50.0
+  }
 }
diff --git a/etc/peer/models/Set2-Case3b-fast/model-info.json b/etc/peer/models/Set2-Case3b-fast/model-info.json
index bb0c7e5e1656ec429f3a33c96e571e979e697218..d4ae4c5cf05c9f089c906cd7e743115c9c905799 100644
--- a/etc/peer/models/Set2-Case3b-fast/model-info.json
+++ b/etc/peer/models/Set2-Case3b-fast/model-info.json
@@ -1,4 +1,10 @@
 {
   "name": "PEER Set2-Case3b-fast",
-  "site-class-vs30": { "BC": 760 }
+  "site-class-vs30": { "BC": 760 },
+  "bounds":  {
+    "min-latitude": -5.0,
+    "min-longitude": -70.0,
+    "max-latitude": 5.0,
+    "max-longitude": -50.0
+  }
 }
diff --git a/etc/peer/models/Set2-Case3b/model-info.json b/etc/peer/models/Set2-Case3b/model-info.json
index f5443c8314ff7f48069ada7ae4c5276e3e66acdb..df2040827b244b131e9ebb2a1ff1b00f9bda6555 100644
--- a/etc/peer/models/Set2-Case3b/model-info.json
+++ b/etc/peer/models/Set2-Case3b/model-info.json
@@ -1,4 +1,10 @@
 {
   "name": "PEER Set2-Case3b",
-  "site-class-vs30": { "BC": 760 }
+  "site-class-vs30": { "BC": 760 },
+  "bounds":  {
+    "min-latitude": -5.0,
+    "min-longitude": -70.0,
+    "max-latitude": 5.0,
+    "max-longitude": -50.0
+  }
 }
diff --git a/etc/peer/models/Set2-Case3c-fast/model-info.json b/etc/peer/models/Set2-Case3c-fast/model-info.json
index 21c15e1b383847efd844db9c68e2eb947e066eb0..64c155eff5de01189bcfad6d243acf9e69c3d68a 100644
--- a/etc/peer/models/Set2-Case3c-fast/model-info.json
+++ b/etc/peer/models/Set2-Case3c-fast/model-info.json
@@ -1,4 +1,10 @@
 {
   "name": "PEER Set2-Case3c-fast",
-  "site-class-vs30": { "BC": 760 }
+  "site-class-vs30": { "BC": 760 },
+  "bounds":  {
+    "min-latitude": -5.0,
+    "min-longitude": -70.0,
+    "max-latitude": 5.0,
+    "max-longitude": -50.0
+  }
 }
diff --git a/etc/peer/models/Set2-Case3c/model-info.json b/etc/peer/models/Set2-Case3c/model-info.json
index 13a04b53aee8d984dbb0a0960f4430fbd76a00b4..39fa646e2fa6f049a6510b5646d824a8a23f36f6 100644
--- a/etc/peer/models/Set2-Case3c/model-info.json
+++ b/etc/peer/models/Set2-Case3c/model-info.json
@@ -1,4 +1,10 @@
 {
   "name": "PEER Set2-Case3c",
-  "site-class-vs30": { "BC": 760 }
+  "site-class-vs30": { "BC": 760 },
+  "bounds":  {
+    "min-latitude": -5.0,
+    "min-longitude": -70.0,
+    "max-latitude": 5.0,
+    "max-longitude": -50.0
+  }
 }
diff --git a/etc/peer/models/Set2-Case3d-fast/model-info.json b/etc/peer/models/Set2-Case3d-fast/model-info.json
index 0ef2a77cee30d46af9abd639bf8359e9e8e15da1..0df777e6e8743d2fde686db5ba417a0a27784b06 100644
--- a/etc/peer/models/Set2-Case3d-fast/model-info.json
+++ b/etc/peer/models/Set2-Case3d-fast/model-info.json
@@ -1,4 +1,10 @@
 {
   "name": "PEER Set2-Case3d-fast",
-  "site-class-vs30": { "BC": 760 }
+  "site-class-vs30": { "BC": 760 },
+  "bounds":  {
+    "min-latitude": -5.0,
+    "min-longitude": -70.0,
+    "max-latitude": 5.0,
+    "max-longitude": -50.0
+  }
 }
diff --git a/etc/peer/models/Set2-Case3d/model-info.json b/etc/peer/models/Set2-Case3d/model-info.json
index 72e9a02e53a9e22be04fc1ee7bf6bf96b7143f92..70ce5f198a493112e65a4703eff3c9aefa1b57f2 100644
--- a/etc/peer/models/Set2-Case3d/model-info.json
+++ b/etc/peer/models/Set2-Case3d/model-info.json
@@ -1,4 +1,10 @@
 {
   "name": "PEER Set2-Case3d",
-  "site-class-vs30": { "BC": 760 }
+  "site-class-vs30": { "BC": 760 },
+  "bounds":  {
+    "min-latitude": -5.0,
+    "min-longitude": -70.0,
+    "max-latitude": 5.0,
+    "max-longitude": -50.0
+  }
 }
diff --git a/etc/peer/models/Set2-Case4a-fast/model-info.json b/etc/peer/models/Set2-Case4a-fast/model-info.json
index b5430f610d32672ea45f7b77bf0d2ad1a2c8e66c..287e070f0d380440d1fc0a4ade95ec08045e7227 100644
--- a/etc/peer/models/Set2-Case4a-fast/model-info.json
+++ b/etc/peer/models/Set2-Case4a-fast/model-info.json
@@ -1,4 +1,10 @@
 {
   "name": "PEER Set2-Case4a-fast",
-  "site-class-vs30": { "BC": 760 }
+  "site-class-vs30": { "BC": 760 },
+  "bounds":  {
+    "min-latitude": -5.0,
+    "min-longitude": -70.0,
+    "max-latitude": 5.0,
+    "max-longitude": -50.0
+  }
 }
diff --git a/etc/peer/models/Set2-Case4a/model-info.json b/etc/peer/models/Set2-Case4a/model-info.json
index 06323a365edd947ce2fa39e605598b44676e6999..f025d77804101070bfaf5be8cdedbd27d8728344 100644
--- a/etc/peer/models/Set2-Case4a/model-info.json
+++ b/etc/peer/models/Set2-Case4a/model-info.json
@@ -1,4 +1,10 @@
 {
   "name": "PEER Set2-Case4a",
-  "site-class-vs30": { "BC": 760 }
+  "site-class-vs30": { "BC": 760 },
+  "bounds":  {
+    "min-latitude": -5.0,
+    "min-longitude": -70.0,
+    "max-latitude": 5.0,
+    "max-longitude": -50.0
+  }
 }
diff --git a/etc/peer/models/Set2-Case4b-fast/model-info.json b/etc/peer/models/Set2-Case4b-fast/model-info.json
index 76594bccbb5f30831790bedbcb14515cbd7746d1..e2fd9dc5b96c29693de5322c92530cd07b28a065 100644
--- a/etc/peer/models/Set2-Case4b-fast/model-info.json
+++ b/etc/peer/models/Set2-Case4b-fast/model-info.json
@@ -1,4 +1,10 @@
 {
   "name": "PEER Set2-Case4b-fast",
-  "site-class-vs30": { "BC": 760 }
+  "site-class-vs30": { "BC": 760 },
+  "bounds":  {
+    "min-latitude": -5.0,
+    "min-longitude": -70.0,
+    "max-latitude": 5.0,
+    "max-longitude": -50.0
+  }
 }
diff --git a/etc/peer/models/Set2-Case4b/model-info.json b/etc/peer/models/Set2-Case4b/model-info.json
index 67785f30573f4741f608bee6ffb550961047d531..ac2e148bb2e5c1cf6321296e0ec587cad674fa07 100644
--- a/etc/peer/models/Set2-Case4b/model-info.json
+++ b/etc/peer/models/Set2-Case4b/model-info.json
@@ -1,4 +1,10 @@
 {
   "name": "PEER Set2-Case4b",
-  "site-class-vs30": { "BC": 760 }
+  "site-class-vs30": { "BC": 760 },
+  "bounds":  {
+    "min-latitude": -5.0,
+    "min-longitude": -70.0,
+    "max-latitude": 5.0,
+    "max-longitude": -50.0
+  }
 }
diff --git a/etc/peer/models/Set2-Case5a/model-info.json b/etc/peer/models/Set2-Case5a/model-info.json
index b802ee2bdd5ebde3cea1591308347a07dcc95440..014aef2f2dded9fc0c91d681323f216a6f9d2e5c 100644
--- a/etc/peer/models/Set2-Case5a/model-info.json
+++ b/etc/peer/models/Set2-Case5a/model-info.json
@@ -1,4 +1,10 @@
 {
   "name": "PEER Set2-Case5a",
-  "site-class-vs30": { "BC": 760 }
+  "site-class-vs30": { "BC": 760 },
+  "bounds":  {
+    "min-latitude": -5.0,
+    "min-longitude": -70.0,
+    "max-latitude": 5.0,
+    "max-longitude": -50.0
+  }
 }
diff --git a/etc/peer/models/Set2-Case5b/model-info.json b/etc/peer/models/Set2-Case5b/model-info.json
index a452ae4494b6d8fc08d7027dd95ea43ac9c4f550..ebd21d8adeeb365064fe4ba3ce35ab2158f83b2b 100644
--- a/etc/peer/models/Set2-Case5b/model-info.json
+++ b/etc/peer/models/Set2-Case5b/model-info.json
@@ -1,4 +1,10 @@
 {
   "name": "PEER Set2-Case5b",
-  "site-class-vs30": { "BC": 760 }
+  "site-class-vs30": { "BC": 760 },
+  "bounds":  {
+    "min-latitude": -5.0,
+    "min-longitude": -70.0,
+    "max-latitude": 5.0,
+    "max-longitude": -50.0
+  }
 }
diff --git a/gradle.properties b/gradle.properties
index 336a4f52df56f4229dbb3fbfee730f845e55d1b0..3f48835359cf223a3a469c892eb0c628d133a517 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -10,7 +10,7 @@ micronautVersion = 3.2.3
 micronautRxVersion = 2.1.1
 nodePluginVersion = 3.0.1
 nodeVersion = 16.3.0
-nshmpLibVersion = 1.1.5
+nshmpLibVersion = 1.1.8
 nshmpWsUtilsVersion = 0.3.11
 openApiVersion = 4.0.0
 shadowVersion = 7.1.2
diff --git a/package-lock.json b/package-lock.json
index dc72624ba1f9b5786382c12356add218736ccc4b..fd03b78e351745759edce1f4fbeb016a832b3d8b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -5,7 +5,6 @@
   "requires": true,
   "packages": {
     "": {
-      "name": "nshmp-haz",
       "version": "2.0.0",
       "devDependencies": {
         "markdownlint-cli": "^0.31.1",
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/HazardMaps.java b/src/main/java/gov/usgs/earthquake/nshmp/HazardMaps.java
index b4580ef94e478e0733909b9f055b73b29b8a4229..bb5733e2f5a2bface8609b837c11ce001a6d092e 100644
--- a/src/main/java/gov/usgs/earthquake/nshmp/HazardMaps.java
+++ b/src/main/java/gov/usgs/earthquake/nshmp/HazardMaps.java
@@ -15,7 +15,6 @@ import java.util.stream.Collectors;
 import java.util.stream.Stream;
 
 import gov.usgs.earthquake.nshmp.data.Interpolator;
-import gov.usgs.earthquake.nshmp.internal.Parsing;
 
 /**
  * Utility class to create hazard map datasets from a hazard curve results.
@@ -38,7 +37,7 @@ public class HazardMaps {
   private static final String PROGRAM = HazardMaps.class.getSimpleName();
   private static final String VALUE_FMT = "%.8e";
   private static final Function<Double, String> VALUE_FORMATTER =
-      Parsing.formatDoubleFunction(VALUE_FMT);
+      Text.formatDoubleFunction(VALUE_FMT);
 
   private HazardMaps() {}
 
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/ServletUtil.java b/src/main/java/gov/usgs/earthquake/nshmp/www/ServletUtil.java
index 8eaadb3536dc61b8c90d66af93bed58b041eb2ad..134c57ef8377ded5a6dfa889679214b93797a197 100644
--- a/src/main/java/gov/usgs/earthquake/nshmp/www/ServletUtil.java
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/ServletUtil.java
@@ -49,6 +49,7 @@ import jakarta.inject.Singleton;
 @Singleton
 public class ServletUtil {
 
+  @Deprecated
   public static final Gson GSON;
   public static final Gson GSON2;
 
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/services/SwaggerController.java b/src/main/java/gov/usgs/earthquake/nshmp/www/SwaggerController.java
similarity index 66%
rename from src/main/java/gov/usgs/earthquake/nshmp/www/services/SwaggerController.java
rename to src/main/java/gov/usgs/earthquake/nshmp/www/SwaggerController.java
index cae6aed6c15ff666f4baa26ab71459615a6ce4ff..ad2efec941029e4f8fd21b6298cd8897cc9396cd 100644
--- a/src/main/java/gov/usgs/earthquake/nshmp/www/services/SwaggerController.java
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/SwaggerController.java
@@ -1,17 +1,10 @@
-package gov.usgs.earthquake.nshmp.www.services;
+package gov.usgs.earthquake.nshmp.www;
 
 import java.util.List;
-import java.util.Map;
-import java.util.logging.Logger;
-import java.util.stream.Collectors;
 
 import org.slf4j.LoggerFactory;
 
-import gov.usgs.earthquake.nshmp.geo.Location;
 import gov.usgs.earthquake.nshmp.model.HazardModel;
-import gov.usgs.earthquake.nshmp.www.NshmpMicronautServlet;
-import gov.usgs.earthquake.nshmp.www.ServletUtil;
-import gov.usgs.earthquake.nshmp.www.SwaggerUtils;
 
 import io.micronaut.http.HttpRequest;
 import io.micronaut.http.HttpResponse;
@@ -57,13 +50,12 @@ public class SwaggerController {
       HttpRequest<?> request,
       HazardModel model) {
     var openApi = new OpenAPIV3Parser().read("META-INF/swagger/nshmp-haz.yml");
-    var bounds = new Bounds(model.bounds());
+    var bounds = model.bounds();
     SwaggerUtils.addLocationBounds(openApi, bounds.min, bounds.max);
     var components = openApi.getComponents();
     var schemas = components.getSchemas();
     SwaggerUtils.siteClassSchema(schemas, List.copyOf(model.siteClasses().keySet()));
-    SwaggerUtils.imtSchema(schemas,
-        model.config().hazard.imts.stream().collect(Collectors.toList()));
+    SwaggerUtils.imtSchema(schemas, List.copyOf(model.config().hazard.imts));
     openApi.servers(null);
 
     openApi.getInfo().setTitle(model.name() + " Web Services");
@@ -74,18 +66,4 @@ public class SwaggerController {
     return openApi;
   }
 
-  private static class Bounds {
-    final Location min;
-    final Location max;
-
-    Bounds(Map<String, Double> bounds) {
-      var log = Logger.getAnonymousLogger();
-
-      bounds.entrySet().forEach(entry -> {
-        log.info(entry.getKey() + ", " + entry.getValue());
-      });
-      min = Location.create(bounds.get("min-longitude"), bounds.get("min-latitude"));
-      max = Location.create(bounds.get("max-longitude"), bounds.get("max-latitude"));
-    }
-  }
 }
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
index 3f2fdc3f1256ff8c773eb8adaf0370652dea9c2e..ba7c7c07059aa39cc3795150291521130b7d193f 100644
--- a/src/main/java/gov/usgs/earthquake/nshmp/www/hazard/DisaggController.java
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/hazard/DisaggController.java
@@ -35,11 +35,17 @@ import jakarta.inject.Inject;
  * Micronaut web service controller for disaggregation of probabilistic seismic
  * hazard.
  *
+ * <p>See src/main/resources/application.yml nshmp-haz.model-path for installed
+ * model.
+ *
+ * <p>To run the Micronaut jar file with a model: java -jar
+ * path/to/nshmp-haz.jar --model=<path/to/model>
+ *
  * @author U.S. Geological Survey
  */
 @Tag(
-    name = "Disaggregation",
-    description = "USGS NSHMP hazard disaggregation service")
+    name = DisaggService.NAME,
+    description = "USGS NSHM hazard disaggregation service")
 @Controller("/disagg")
 public class DisaggController {
 
@@ -68,9 +74,9 @@ public class DisaggController {
   }
 
   /**
-   * @param longitude Longitude in the range
-   * @param latitude Latitude in the range
-   * @param vs30 Site Vs30 value in the range [150..3000] m/s.
+   * @param longitude Longitude in decimal degrees in the range
+   * @param latitude Latitude in decimal degrees in the range
+   * @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,
@@ -80,7 +86,8 @@ public class DisaggController {
    */
   @Operation(
       summary = "Disaggregate hazard at a specified return period",
-      description = "Returns a hazard disaggregation computed from the installed model")
+      description = "Returns a hazard disaggregation computed from the installed model",
+      operationId = "disagg-calc-rp")
   @ApiResponse(
       description = "Disaggregation",
       responseCode = "200",
@@ -105,10 +112,7 @@ public class DisaggController {
       Set<Imt> imts = HazardService.readImts(http);
       Set<DataType> dataTypes = HazardService.readDataTypes(http);
       DisaggService.RequestRp request = new DisaggService.RequestRp(
-          http,
-          longitude, latitude, vs30, imts,
-          returnPeriod,
-          dataTypes);
+          http, longitude, latitude, vs30, imts, returnPeriod, dataTypes);
       return DisaggService.getDisaggRp(request);
     } catch (Exception e) {
       return ServletUtil.error(
@@ -119,14 +123,16 @@ public class DisaggController {
   }
 
   /**
-   * @param longitude Longitude in the range
-   * @param latitude Latitude in decimal degrees
-   * @param vs30 Site Vs30 value in the range [150..3000] m/s.
+   * @param longitude Longitude in decimal degrees in the range
+   * @param latitude Latitude in decimal degrees in the range
+   * @param vs30 Site Vs30 value in the range [150..3000] m/s
+   * @param imls Mapping of IMTs to disaggregation intensity measure levels
    * @param out The data types to output
    */
   @Operation(
       summary = "Disaggregate hazard at specified IMLs",
-      description = "Returns a hazard disaggregation computed from the installed model")
+      description = "Returns a hazard disaggregation computed from the installed model",
+      operationId = "disagg-calc-iml")
   @ApiResponse(
       description = "Disaggregation",
       responseCode = "200",
@@ -150,10 +156,7 @@ public class DisaggController {
       checkArgument(!imtImlMap.isEmpty(), "No IMLs supplied");
       Set<DataType> dataTypes = HazardService.readDataTypes(http);
       DisaggService.RequestIml request = new DisaggService.RequestIml(
-          http,
-          longitude, latitude, vs30,
-          imtImlMap,
-          dataTypes);
+          http, longitude, latitude, vs30, imtImlMap, dataTypes);
       return DisaggService.getDisaggIml(request);
     } catch (Exception e) {
       return ServletUtil.error(
@@ -163,12 +166,12 @@ public class DisaggController {
     }
   }
 
-  // For Swagger schema
+  // Swagger schema
   private static class DisaggResponseIml extends ResponseBody<RequestIml, Response> {}
 
-  // For Swagger schema
+  // Swagger schema
   private static class DisaggResponseReturnPeriod extends ResponseBody<RequestRp, Response> {}
 
-  // For Swagger schema
+  // Swagger schema
   private static class MetadataResponse extends ResponseBody<String, Metadata> {};
 }
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
index abd46fdc89ac8b523365d3dad427869ed14a5f76..f9b065a6620a1a52eda1c6f7708742c87bff486b 100644
--- a/src/main/java/gov/usgs/earthquake/nshmp/www/hazard/DisaggService.java
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/hazard/DisaggService.java
@@ -33,8 +33,8 @@ import gov.usgs.earthquake.nshmp.www.ResponseBody;
 import gov.usgs.earthquake.nshmp.www.ResponseMetadata;
 import gov.usgs.earthquake.nshmp.www.ServletUtil;
 import gov.usgs.earthquake.nshmp.www.ServletUtil.Server;
-import gov.usgs.earthquake.nshmp.www.hazard.HazardService.HazardRequest;
-import gov.usgs.earthquake.nshmp.www.hazard.HazardService.Metadata;
+import gov.usgs.earthquake.nshmp.www.hazard.HazardService.BaseRequest;
+import gov.usgs.earthquake.nshmp.www.meta.DoubleParameter;
 import gov.usgs.earthquake.nshmp.www.meta.Parameter;
 
 import io.micronaut.http.HttpRequest;
@@ -57,9 +57,10 @@ public final class DisaggService {
    * OR imt=iml pairs
    */
 
-  static final String NAME = "Disaggregation Service";
+  static final String NAME = "Hazard Disaggregation";
   static final Logger LOG = LoggerFactory.getLogger(DisaggService.class);
 
+  // TODO range check return periods and imls
   private static Range<Double> rpRange = Range.closed(1.0, 20000.0);
   private static Range<Double> imlRange = Range.closed(0.0001, 8.0);
 
@@ -70,7 +71,6 @@ public final class DisaggService {
     DISAGG_DATA;
   }
 
-  /** HazardController.doGetMetadata() handler. */
   static HttpResponse<String> getMetadata(HttpRequest<?> request) {
     var url = request.getUri().toString();
     var usage = new Metadata(ServletUtil.model());
@@ -85,7 +85,6 @@ public final class DisaggService {
     return HttpResponse.ok(svcResponse);
   }
 
-  /** HazardController.doGetDisaggIml() handler. */
   static HttpResponse<String> getDisaggIml(RequestIml request)
       throws InterruptedException, ExecutionException {
     var stopwatch = Stopwatch.createStarted();
@@ -106,7 +105,6 @@ public final class DisaggService {
     return HttpResponse.ok(svcResponse);
   }
 
-  /** HazardController.doGetDisaggRp() handler. */
   static HttpResponse<String> getDisaggRp(RequestRp request)
       throws InterruptedException, ExecutionException {
     var stopwatch = Stopwatch.createStarted();
@@ -132,7 +130,6 @@ public final class DisaggService {
    *
    * 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
-   *
    */
 
   private static Disaggregation calcDisaggIml(RequestIml request)
@@ -204,7 +201,7 @@ public final class DisaggService {
     return disagg;
   }
 
-  static final class RequestIml extends HazardRequest {
+  static final class RequestIml extends BaseRequest {
     final Map<Imt, Double> imls;
     final Set<DataType> dataTypes;
 
@@ -230,7 +227,7 @@ public final class DisaggService {
     }
   }
 
-  static final class RequestRp extends HazardRequest {
+  static final class RequestRp extends BaseRequest {
     final double returnPeriod;
     final Set<DataType> dataTypes;
     final Set<Imt> imts;
@@ -266,15 +263,15 @@ public final class DisaggService {
   }
 
   static final class Response {
-    final Response.Metadata metadata;
+    final ResponseMetadata metadata;
     final List<ImtDisagg> disaggs;
 
-    Response(Response.Metadata metadata, List<ImtDisagg> disaggs) {
+    Response(ResponseMetadata metadata, List<ImtDisagg> disaggs) {
       this.metadata = metadata;
       this.disaggs = disaggs;
     }
 
-    public Response.Metadata getMetadata() {
+    public ResponseMetadata getMetadata() {
       return metadata;
     }
 
@@ -282,14 +279,14 @@ public final class DisaggService {
       return disaggs;
     }
 
-    private static final class Metadata {
+    private static final class ResponseMetadata {
       final Server server;
       final String rlabel = "Closest Distance, rRup (km)";
       final String mlabel = "Magnitude (Mw)";
       final String εlabel = "% Contribution to Hazard";
       final Object εbins;
 
-      Metadata(Server server, Object εbins) {
+      ResponseMetadata(Server server, Object εbins) {
         this.server = server;
         this.εbins = εbins;
       }
@@ -364,7 +361,7 @@ public final class DisaggService {
         var server = ServletUtil.serverData(ServletUtil.THREAD_COUNT, timer);
 
         return new Response(
-            new Response.Metadata(server, disagg.εBins()),
+            new ResponseMetadata(server, disagg.εBins()),
             disaggs);
       }
     }
@@ -389,4 +386,33 @@ public final class DisaggService {
       return data;
     }
   }
+
+  private static class Metadata extends HazardService.Metadata {
+    final DoubleParameter iml;
+    final DoubleParameter returnPeriod;
+
+    Metadata(HazardModel model) {
+      super(model);
+
+      iml = new DoubleParameter(
+          "Intensity Measure Level",
+          "",
+          imlRange.lowerEndpoint(),
+          imlRange.upperEndpoint());
+
+      returnPeriod = new DoubleParameter(
+          "Return Period",
+          "yr",
+          rpRange.lowerEndpoint(),
+          rpRange.upperEndpoint());
+    }
+
+    public DoubleParameter getIml() {
+      return iml;
+    }
+
+    public DoubleParameter getReturnPeriod() {
+      return returnPeriod;
+    }
+  }
 }
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
index e6680a3a98992e56e5894dd12e451b31b9c92bcb..261b62b69b173fd23e98c07eaab8fc17050737c7 100644
--- a/src/main/java/gov/usgs/earthquake/nshmp/www/hazard/HazardController.java
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/hazard/HazardController.java
@@ -29,11 +29,17 @@ import jakarta.inject.Inject;
  * Micronaut web service controller for probabilistic seismic hazard
  * calculations.
  *
+ * <p>See src/main/resources/application.yml nshmp-haz.model-path for installed
+ * model.
+ *
+ * <p>To run the Micronaut jar file with a model: java -jar
+ * path/to/nshmp-haz.jar --model=<path/to/model>
+ *
  * @author U.S. Geological Survey
  */
 @Tag(
-    name = "Hazard",
-    description = "USGS NSHMP hazard calculation service")
+    name = HazardService.NAME,
+    description = "USGS NSHM hazard calculation service")
 @Controller("/hazard")
 public class HazardController {
 
@@ -41,7 +47,7 @@ public class HazardController {
   private NshmpMicronautServlet servlet;
 
   @Operation(
-      summary = "Hazard model and service metadata",
+      summary = "Hazard calculation model and service metadata",
       description = "Returns details of the installed model and service request parameters",
       operationId = "hazard-metadata")
   @ApiResponse(
@@ -62,18 +68,19 @@ public class HazardController {
   }
 
   /**
-   * @param longitude Longitude in decimal degrees
-   * @param latitude Latitude in decimal degrees
-   * @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 longitude Longitude in decimal degrees in the range
+   * @param latitude Latitude in decimal degrees in the range
+   * @param vs30 Site Vs30 value in the range [150..3000] m/s
+   * @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. Responses
    *        for numerous IMT's are quite large, on the order of MB.
    */
   @Operation(
       summary = "Compute probabilisitic hazard at a site",
-      description = "Returns hazard curves computed from the installed model")
+      description = "Returns hazard curves computed from the installed model",
+      operationId = "hazard-calc")
   @ApiResponse(
       description = "Hazard curves",
       responseCode = "200",
@@ -97,10 +104,7 @@ public class HazardController {
     try {
       Set<Imt> imts = HazardService.readImts(http);
       HazardService.Request request = new HazardService.Request(
-          http,
-          longitude, latitude, vs30,
-          imts,
-          truncate, maxdir);
+          http, longitude, latitude, vs30, imts, truncate, maxdir);
       return HazardService.getHazard(request);
     } catch (Exception e) {
       return ServletUtil.error(
@@ -110,9 +114,9 @@ public class HazardController {
     }
   }
 
-  // For Swagger schemas
+  // Swagger schema
   private static class HazardResponse extends ResponseBody<Request, Response> {}
 
-  // For Swagger schemas
+  // Swagger schema
   private static class MetadataResponse extends ResponseBody<String, Metadata> {};
 }
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
index 3f06ebd44b15cc80a8d86eb2a1fbcff0c59165a5..691d27f40ee9189e6062c90ba07e941266951776 100644
--- a/src/main/java/gov/usgs/earthquake/nshmp/www/hazard/HazardService.java
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/hazard/HazardService.java
@@ -1,6 +1,5 @@
 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;
@@ -29,7 +28,6 @@ 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;
@@ -41,7 +39,7 @@ import gov.usgs.earthquake.nshmp.www.ServletUtil;
 import gov.usgs.earthquake.nshmp.www.ServletUtil.Server;
 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 gov.usgs.earthquake.nshmp.www.source.SourceService.SourceModel;
 
 import io.micronaut.http.HttpRequest;
 import io.micronaut.http.HttpResponse;
@@ -56,12 +54,11 @@ import jakarta.inject.Singleton;
 @Singleton
 public final class HazardService {
 
-  static final String NAME = "Hazard Service";
+  static final String NAME = "Hazard Curves";
   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());
@@ -76,7 +73,6 @@ public final class HazardService {
     return HttpResponse.ok(json);
   }
 
-  /** HazardController.doGetHazard() handler. */
   public static HttpResponse<String> getHazard(Request request)
       throws InterruptedException, ExecutionException {
     var stopwatch = Stopwatch.createStarted();
@@ -106,7 +102,7 @@ public final class HazardService {
    * apply truncation and scaling on the client.
    */
 
-  public static Hazard calcHazard(Request request)
+  static Hazard calcHazard(Request request)
       throws InterruptedException, ExecutionException {
 
     HazardModel model = ServletUtil.model();
@@ -137,18 +133,17 @@ public final class HazardService {
 
     Metadata(HazardModel model) {
       this.model = new SourceModel(model);
-      // should get min max from model
       longitude = new DoubleParameter(
           "Longitude",
           "°",
-          Coordinates.LON_RANGE.lowerEndpoint(),
-          Coordinates.LON_RANGE.upperEndpoint());
+          model.bounds().min.longitude,
+          model.bounds().max.longitude);
 
       latitude = new DoubleParameter(
           "Latitude",
           "°",
-          Coordinates.LAT_RANGE.lowerEndpoint(),
-          Coordinates.LAT_RANGE.upperEndpoint());
+          model.bounds().min.latitude,
+          model.bounds().max.latitude);
 
       vs30 = new DoubleParameter(
           "Vs30",
@@ -174,13 +169,14 @@ public final class HazardService {
     }
   }
 
-  static class HazardRequest {
+  /* Base request class for both hazard and disagg. */
+  static class BaseRequest {
     final transient HttpRequest<?> http;
     final double longitude;
     final double latitude;
     final double vs30;
 
-    public HazardRequest(
+    public BaseRequest(
         HttpRequest<?> http,
         double longitude,
         double latitude,
@@ -204,7 +200,7 @@ public final class HazardService {
     }
   }
 
-  static final class Request extends HazardRequest {
+  static final class Request extends BaseRequest {
     final boolean truncate;
     final boolean maxdir;
     final Set<Imt> imts;
@@ -296,12 +292,8 @@ public final class HazardService {
       }
 
       Builder hazard(Hazard hazard) {
-        // 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()) {
@@ -322,7 +314,6 @@ public final class HazardService {
             XySequence.addToMap(type, componentMap, typeTotalMap.get(type));
           }
         }
-
         return this;
       }
 
@@ -442,7 +433,7 @@ public final class HazardService {
         .collect(toCollection(() -> EnumSet.noneOf(Imt.class)));
   }
 
-  /* Read the 'imt' query values; can be comma-delimited. */
+  /* Read the 'out'put type query values; can be comma-delimited. */
   static Set<DataType> readDataTypes(HttpRequest<?> http) {
     return http.getParameters().getAll("out").stream()
         .map(s -> s.split(","))
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/services/RateController.java b/src/main/java/gov/usgs/earthquake/nshmp/www/services/RateController.java
deleted file mode 100644
index 48961477728e95ef794d508ba627eac4311b6696..0000000000000000000000000000000000000000
--- a/src/main/java/gov/usgs/earthquake/nshmp/www/services/RateController.java
+++ /dev/null
@@ -1,223 +0,0 @@
-package gov.usgs.earthquake.nshmp.www.services;
-
-import java.util.Optional;
-
-import gov.usgs.earthquake.nshmp.www.NshmpMicronautServlet;
-import gov.usgs.earthquake.nshmp.www.ResponseBody;
-import gov.usgs.earthquake.nshmp.www.services.RateService.ProbabilityParameters;
-import gov.usgs.earthquake.nshmp.www.services.RateService.Query;
-import gov.usgs.earthquake.nshmp.www.services.RateService.RateParameters;
-import gov.usgs.earthquake.nshmp.www.services.RateService.RequestData;
-import gov.usgs.earthquake.nshmp.www.services.RateService.ResponseData;
-import gov.usgs.earthquake.nshmp.www.services.RateService.Service;
-import gov.usgs.earthquake.nshmp.www.services.RateService.Usage;
-
-import io.micronaut.core.annotation.Nullable;
-import io.micronaut.http.HttpRequest;
-import io.micronaut.http.HttpResponse;
-import io.micronaut.http.MediaType;
-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.Content;
-import io.swagger.v3.oas.annotations.media.Schema;
-import io.swagger.v3.oas.annotations.responses.ApiResponse;
-import jakarta.inject.Inject;
-
-/**
- * Micronaut controller for rate and probability services.
- *
- * @see RateService
- *
- * @author U.S. Geological Survey
- */
-@Controller("/")
-public class RateController {
-
-  @Inject
-  private NshmpMicronautServlet servlet;
-
-  /**
-   * GET method to compute annual-rate, query based.
-   *
-   * @param request The HTTP request
-   * @param longitude Longitude (in decimal degrees)
-   * @param latitude Latitude (in decimal degrees)
-   * @param distance Cutoff distance (in km) ([0.01, 1000])
-   */
-  @Operation(
-      summary = "Compute earthquake annual-rates",
-      description = "Compute incremental earthquake annual-rates at a location",
-      operationId = "rate_doGetRate",
-      tags = { "Rate Service" })
-  @ApiResponse(
-      description = "Earthquake annual-rates service metadata",
-      responseCode = "20x",
-      content = {
-          @Content(
-              schema = @Schema(implementation = RateMetadataResponse.class))
-      })
-  @ApiResponse(
-      description = "Earthquake annual-rates calculation response",
-      responseCode = "200",
-      content = {
-          @Content(
-              schema = @Schema(implementation = CalcResponse.class))
-      })
-  @Get(
-      uri = "/rate{?longitude,latitude,distance}",
-      produces = MediaType.APPLICATION_JSON)
-  public HttpResponse<String> doGetRate(
-      HttpRequest<?> request,
-      @Schema(
-          required = true) @QueryValue @Nullable Double longitude,
-      @Schema(
-          required = true) @QueryValue @Nullable Double latitude,
-      @Schema(
-          required = true,
-          minimum = "0.01",
-          maximum = "1000") @QueryValue @Nullable Double distance) {
-    var service = Service.RATE;
-    var query = new Query(service, longitude, latitude, distance, Optional.empty());
-    return RateService.handleDoGetCalc(request, query);
-  }
-
-  /**
-   * GET method to compute annual-rate, slash based
-   *
-   * @param request The HTTP request
-   * @param longitude Longitude (in decimal degrees)
-   * @param latitude Latitude (in decimal degrees)
-   * @param distance Cutoff distance (in km) ([0.01, 1000])
-   */
-  @Operation(
-      summary = "Compute earthquake annual-rates",
-      description = "Compute incremental earthquake annual-rates at a location",
-      operationId = "rate_doGetRateSlash",
-      tags = { "Rate Service" })
-  @ApiResponse(
-      description = "Earthquake annual-rates calculation response",
-      responseCode = "200",
-      content = @Content(
-          schema = @Schema(implementation = CalcResponse.class)))
-  @Get(
-      uri = "/rate{/longitude}{/latitude}{/distance}",
-      produces = MediaType.APPLICATION_JSON)
-  public HttpResponse<String> doGetRateSlash(
-      HttpRequest<?> request,
-      @Schema(
-          required = true) @PathVariable @Nullable Double longitude,
-      @Schema(
-          required = true) @PathVariable @Nullable Double latitude,
-      @Schema(
-          required = true,
-          minimum = "0.01",
-          maximum = "1000") @PathVariable @Nullable Double distance) {
-    var service = Service.RATE;
-    var query = new Query(service, longitude, latitude, distance, Optional.empty());
-    return RateService.handleDoGetCalc(request, query);
-  }
-
-  /**
-   * GET method to compute probability, query based.
-   *
-   * @param request The HTTP request
-   * @param longitude Longitude (in decimal degrees)
-   * @param latitude Latitude (in decimal degrees)
-   * @param distance Cutoff distance (in km) ([0.01, 1000])
-   * @param timespan Forcast time span (in years) ([1, 10000])
-   */
-  @Operation(
-      summary = "Compute earthquake probabilities",
-      description = "Compute cumulative earthquake probabilities P(M ≥ x) at a location",
-      operationId = "probability_doGetProbability",
-      tags = { "Probability Service" })
-  @ApiResponse(
-      description = "Earthquake probabilities service metadata",
-      responseCode = "20x",
-      content = {
-          @Content(
-              schema = @Schema(implementation = ProbMetadataResponse.class))
-      })
-  @ApiResponse(
-      description = "Earthquake probabilities calculation response",
-      responseCode = "200",
-      content = {
-          @Content(
-              schema = @Schema(
-                  implementation = CalcResponse.class))
-      })
-  @Get(
-      uri = "/probability{?longitude,latitude,distance,timespan}",
-      produces = MediaType.APPLICATION_JSON)
-  public HttpResponse<String> doGetProbability(
-      HttpRequest<?> request,
-      @Schema(
-          required = true) @QueryValue @Nullable Double longitude,
-      @Schema(
-          required = true) @QueryValue @Nullable Double latitude,
-      @Schema(
-          required = true,
-          minimum = "0.01",
-          maximum = "1000") @QueryValue @Nullable Double distance,
-      @Schema(
-          required = true,
-          minimum = "1",
-          maximum = "10000") @QueryValue @Nullable Double timespan) {
-    var service = Service.PROBABILITY;
-    var query = new Query(service, longitude, latitude, distance, Optional.ofNullable(timespan));
-    return RateService.handleDoGetCalc(request, query);
-  }
-
-  /**
-   * GET method to compute probability, slash based
-   *
-   * @param request The HTTP request
-   * @param longitude Longitude (in decimal degrees)
-   * @param latitude Latitude (in decimal degrees)
-   * @param distance Cutoff distance (in km) ([0.01, 1000])
-   * @param timespan Forecast time span (in years) ([1, 10000])
-   */
-  @Operation(
-      summary = "Compute earthquake probabilities",
-      description = "Compute cumulative earthquake probabilities P(M ≥ x) at a location",
-      operationId = "probability_doGetProbabilitySlash",
-      tags = { "Probability Service" })
-  @ApiResponse(
-      description = "Earthquake probabilities calculation response",
-      responseCode = "200",
-      content = @Content(
-          schema = @Schema(
-              implementation = CalcResponse.class)))
-  @Get(
-      uri = "/probability{/longitude}{/latitude}{/distance}{/timespan}",
-      produces = MediaType.APPLICATION_JSON)
-  public HttpResponse<String> doGetProbabilitySlash(
-      HttpRequest<?> request,
-      @Schema(
-          required = true) @PathVariable @Nullable Double longitude,
-      @Schema(
-          required = true) @PathVariable @Nullable Double latitude,
-      @Schema(
-          required = true,
-          minimum = "0.01",
-          maximum = "1000") @PathVariable @Nullable Double distance,
-      @Schema(
-          required = true,
-          minimum = "1",
-          maximum = "10000") @PathVariable @Nullable Double timespan) {
-    var service = Service.PROBABILITY;
-    var query = new Query(service, longitude, latitude, distance, Optional.ofNullable(timespan));
-    return RateService.handleDoGetCalc(request, query);
-  }
-
-  // Swagger schemas
-  private static class CalcResponse extends ResponseBody<RequestData, ResponseData> {}
-
-  private static class RateMetadataResponse extends ResponseBody<String, Usage<RateParameters>> {};
-
-  private static class ProbMetadataResponse extends
-      ResponseBody<String, Usage<ProbabilityParameters>> {};
-}
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
deleted file mode 100644
index a084a6a0aa74963840b7f4697b54fd34a6f8b64b..0000000000000000000000000000000000000000
--- a/src/main/java/gov/usgs/earthquake/nshmp/www/services/RateService.java
+++ /dev/null
@@ -1,433 +0,0 @@
-package gov.usgs.earthquake.nshmp.www.services;
-
-import java.util.ArrayList;
-import java.util.List;
-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;
-
-import gov.usgs.earthquake.nshmp.Maths;
-import gov.usgs.earthquake.nshmp.calc.CalcConfig;
-import gov.usgs.earthquake.nshmp.calc.EqRate;
-import gov.usgs.earthquake.nshmp.calc.Site;
-import gov.usgs.earthquake.nshmp.geo.Location;
-import gov.usgs.earthquake.nshmp.model.HazardModel;
-import gov.usgs.earthquake.nshmp.www.HazVersion;
-import gov.usgs.earthquake.nshmp.www.ResponseBody;
-import gov.usgs.earthquake.nshmp.www.ResponseMetadata;
-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.ServletUtil.Server;
-import gov.usgs.earthquake.nshmp.www.WsUtils;
-import gov.usgs.earthquake.nshmp.www.meta.DoubleParameter;
-import gov.usgs.earthquake.nshmp.www.meta.Metadata.DefaultParameters;
-
-import io.micronaut.http.HttpRequest;
-import io.micronaut.http.HttpResponse;
-import jakarta.inject.Singleton;
-
-/**
- * Earthquake probability and rate calculation handler service for
- * {@link RateController}.
- *
- * @author U.S. Geological Survey
- */
-@Singleton
-public final class RateService {
-
-  static final Logger LOG = LoggerFactory.getLogger(RateService.class);
-
-  /*
-   * Developer notes:
-   *
-   * The RateService is currently single-threaded and does not submit jobs to a
-   * request queue; see HazardService. However, jobs are placed on a thread in
-   * the CALC_EXECUTOR thread pool to handle parallel calculation of CEUS and
-   * WUS models.
-   */
-
-  private static final String TOTAL_KEY = "Total";
-
-  /**
-   * Handler for {@link RateController#doGetProbability},
-   * {@link RateController#doGetProbabilitySlash},
-   * {@link RateController#doGetRate}, and {@link RateController#doGetRateSlash}
-   *
-   * @param service The service
-   * @param query The query
-   * @param urlHelper The url helper
-   * @return
-   */
-  static HttpResponse<String> handleDoGetCalc(HttpRequest<?> request, Query query) {
-    var service = query.service;
-
-    try {
-
-      if (query.isNull()) {
-        return handleDoGetUsage(request, service);
-      }
-
-      query.checkValues();
-      var requestData = new RequestData(query);
-      var response = processRequest(request, service, requestData);
-      var svcResponse = ServletUtil.GSON.toJson(response);
-      return HttpResponse.ok(svcResponse);
-    } catch (Exception e) {
-      return ServletUtil.error(LOG, e, service.name, request.getUri().getPath());
-    }
-  }
-
-  static ResponseBody<String, Usage<DefaultParameters>> metadata(HttpRequest<?> request,
-      Service service) {
-    var parameters = service == Service.RATE ? new RateParameters() : new ProbabilityParameters();
-    var usage = new Usage<DefaultParameters>(service, parameters);
-    var url = request.getUri().getPath();
-    return ResponseBody.<String, Usage<DefaultParameters>> usage()
-        .name(service.name)
-        .url(url)
-        .metadata(new ResponseMetadata(HazVersion.appVersions()))
-        .request(url)
-        .response(usage)
-        .build();
-  }
-
-  static ResponseBody<RequestData, ResponseData> processRequest(
-      HttpRequest<?> request,
-      Service service,
-      RequestData data) throws InterruptedException, ExecutionException {
-    var timer = Stopwatch.createStarted();
-    var rates = calc(service, data);
-    var responseData = new ResponseData(new ServiceResponseMetadata(service, data), rates, timer);
-    return ResponseBody.<RequestData, ResponseData> success()
-        .name(service.name)
-        .request(data)
-        .metadata(new ResponseMetadata(HazVersion.appVersions()))
-        .response(responseData)
-        .url(request.getUri().getPath())
-        .build();
-  }
-
-  private static EqRate calc(Service service, RequestData data)
-      throws InterruptedException, ExecutionException {
-    var location = Location.create(data.longitude, data.latitude);
-    var site = Site.builder().location(location).build();
-    var futureRates = new ArrayList<ListenableFuture<EqRate>>();
-
-    /*
-     * Because we need to combine model results, intially calculate incremental
-     * annual rates and only convert to cumulative probabilities at the end if
-     * probability service has been called.
-     */
-
-    var model = ServletUtil.model();
-    var rate = process(service, model, site, data.distance, data.timespan);
-    futureRates.add(rate);
-
-    var rates = futureRates.stream()
-        .map((future) -> {
-          try {
-            return future.get();
-          } catch (InterruptedException | ExecutionException e) {
-            throw new RuntimeException(e);
-          }
-        }).collect(Collectors.toList())
-        .toArray(new EqRate[] {});
-
-    var ratesCombined = EqRate.combine(rates);
-
-    if (service == Service.PROBABILITY) {
-      ratesCombined = EqRate.toCumulative(ratesCombined);
-      ratesCombined = EqRate.toPoissonProbability(ratesCombined, data.timespan.get());
-    }
-
-    return ratesCombined;
-  }
-
-  private static HttpResponse<String> handleDoGetUsage(HttpRequest<?> request, Service service) {
-    var response = metadata(request, service);
-    var json = ServletUtil.GSON.toJson(response);
-    return HttpResponse.ok(json);
-  }
-
-  private static ListenableFuture<EqRate> process(
-      Service service,
-      HazardModel model,
-      Site site,
-      double distance,
-      Optional<Double> timespan) {
-    var configBuilder = CalcConfig.copyOf(model.config()).distance(distance);
-    if (service == Service.PROBABILITY) {
-      /* Also sets value format to Poisson probability. */
-      configBuilder.timespan(timespan.get());
-    }
-    var config = configBuilder.build();
-    var task = EqRate.callable(model, config, site);
-    return ServletUtil.CALC_EXECUTOR.submit(task);
-  }
-
-  public static enum Service {
-    RATE(
-        "Earthquake Rate Service",
-        "Compute incremental earthquake annual-rates at a location",
-        List.of(
-            "%srate/{longitude}/{latitude}/{distance}",
-            "%srate?longitude={longitude}&latitude={latitude}&distance={distance}")),
-
-    PROBABILITY(
-        "Earthquake Probability Service",
-        "Compute cumulative earthquake probabilities P(M ≥ x) at a location",
-        List.of(
-            "%sprobability/{longitude}/{latitude}/{distance}/{timespan}",
-            "%sprobability?longitude=<double>&latitude=<double>&distance=<double>&timespan=<double>"));
-
-    private final String name;
-    private final String description;
-    private final List<String> syntax;
-
-    private Service(String name, String description, List<String> syntax) {
-      this.name = name;
-      this.description = description;
-      this.syntax = syntax;
-    }
-  }
-
-  public static class Query extends ServiceQueryData {
-    final Double distance;
-    final Optional<Double> timespan;
-    final Service service;
-
-    public Query(
-        Service service,
-        Double longitude,
-        Double latitude,
-        Double distance,
-        Optional<Double> timespan) {
-      super(longitude, latitude);
-      this.service = service;
-      this.distance = distance;
-      this.timespan = timespan;
-    }
-
-    @Override
-    public boolean isNull() {
-      return super.isNull() && distance == null &&
-          ((service == Service.PROBABILITY && timespan.isEmpty()) || service == Service.RATE);
-    }
-
-    @Override
-    public void checkValues() {
-      super.checkValues();
-      WsUtils.checkValue(Key.DISTANCE, distance);
-      if (service == Service.PROBABILITY) {
-        WsUtils.checkValue(Key.TIMESPAN, timespan.get());
-      }
-    }
-  }
-
-  static final class RequestData extends ServiceRequestData {
-    final double distance;
-    final Optional<Double> timespan;
-
-    RequestData(Query query) {
-      super(query);
-      this.distance = query.distance;
-      this.timespan = query.timespan;
-    }
-
-    public double getDistance() {
-      return distance;
-    }
-
-    public Optional<Double> getTimespan() {
-      return timespan;
-    }
-  }
-
-  private static final class ServiceResponseMetadata {
-    final double latitude;
-    final double longitude;
-    final double distance;
-    final Double timespan;
-
-    final String xlabel = "Magnitude (Mw)";
-    final String ylabel;
-
-    ServiceResponseMetadata(Service service, RequestData request) {
-      var isProbability = service == Service.PROBABILITY;
-      this.longitude = request.longitude;
-      this.latitude = request.latitude;
-      this.distance = request.distance;
-      this.ylabel = isProbability ? "Probability" : "Annual Rate (yr⁻¹)";
-      this.timespan = request.timespan.orElse(null);
-    }
-
-    public double getLatitude() {
-      return latitude;
-    }
-
-    public double getLongitude() {
-      return longitude;
-    }
-
-    public double getDistance() {
-      return distance;
-    }
-
-    public Double getTimespan() {
-      return timespan;
-    }
-
-    public String getXlabel() {
-      return xlabel;
-    }
-
-    public String getYLabel() {
-      return ylabel;
-    }
-  }
-
-  static final class ResponseData {
-    final Server server;
-    final ServiceResponseMetadata metadata;
-    final List<Sequence> data;
-
-    ResponseData(ServiceResponseMetadata metadata, EqRate rates, Stopwatch timer) {
-      server = ServletUtil.serverData(ServletUtil.THREAD_COUNT, timer);
-      this.metadata = metadata;
-      this.data = buildSequence(rates);
-    }
-
-    public Server getServer() {
-      return server;
-    }
-
-    public ServiceResponseMetadata getMetadata() {
-      return metadata;
-    }
-
-    public List<Sequence> getData() {
-      return data;
-    }
-
-    List<Sequence> buildSequence(EqRate rates) {
-      var sequences = new ArrayList<Sequence>();
-
-      /* Total mfd. */
-      var total = (!rates.totalMfd.isClear()) ? rates.totalMfd.trim() : rates.totalMfd;
-      var totalOut = new Sequence(
-          TOTAL_KEY,
-          total.xValues().boxed().collect(Collectors.toList()),
-          total.yValues().boxed().collect(Collectors.toList()));
-      sequences.add(totalOut);
-
-      /* Source type mfds. */
-      for (var entry : rates.typeMfds.entrySet()) {
-        var type = entry.getValue();
-        if (type.isClear()) {
-          continue;
-        }
-        type = type.trim();
-        var typeOut = new Sequence(
-            entry.getKey().toString(),
-            type.xValues().boxed().collect(Collectors.toList()),
-            type.yValues().boxed().collect(Collectors.toList()));
-        sequences.add(typeOut);
-      }
-
-      return List.copyOf(sequences);
-    }
-  }
-
-  /*
-   * Would rather use this a general container for mfds and hazard curves. See
-   * HazardService.Curve
-   */
-  private static class Sequence {
-    final String component;
-    final List<Double> xvalues;
-    final List<Double> yvalues;
-
-    Sequence(String component, List<Double> xvalues, List<Double> yvalues) {
-      this.component = component;
-      this.xvalues = xvalues;
-      this.yvalues = yvalues;
-    }
-
-    public String getComponent() {
-      return component;
-    }
-
-    public List<Double> getXvalues() {
-      return xvalues;
-    }
-
-    public List<Double> getYvalues() {
-      return yvalues;
-    }
-  }
-
-  static class Usage<T extends DefaultParameters> {
-    final String description;
-    final List<String> syntax;
-    final T parameters;
-
-    private Usage(Service service, T parameters) {
-      description = service.description;
-      this.syntax = service.syntax;
-      this.parameters = parameters;
-    }
-
-    public String getDescription() {
-      return description;
-    }
-
-    public List<String> getSyntax() {
-      return syntax;
-    }
-
-    public T getParameters() {
-      return parameters;
-    }
-  }
-
-  static class RateParameters extends DefaultParameters {
-    final DoubleParameter distance;
-
-    RateParameters() {
-      super();
-      distance = new DoubleParameter(
-          "Cutoff distance",
-          "km",
-          0.01,
-          1000.0);
-    }
-
-    public DoubleParameter getDistance() {
-      return distance;
-    }
-  }
-
-  static class ProbabilityParameters extends RateParameters {
-    final DoubleParameter timespan;
-
-    ProbabilityParameters() {
-      timespan = new DoubleParameter(
-          "Forecast time span",
-          "years",
-          Maths.TIMESPAN_RANGE.lowerEndpoint(),
-          Maths.TIMESPAN_RANGE.upperEndpoint());
-    }
-
-    public DoubleParameter getTimespan() {
-      return timespan;
-    }
-  }
-}
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/services/SourceLogicTreesController.java b/src/main/java/gov/usgs/earthquake/nshmp/www/services/SourceLogicTreesController.java
deleted file mode 100644
index 253647039a1e2df82441b24e5b4e5ffa05c2d150..0000000000000000000000000000000000000000
--- a/src/main/java/gov/usgs/earthquake/nshmp/www/services/SourceLogicTreesController.java
+++ /dev/null
@@ -1,72 +0,0 @@
-package gov.usgs.earthquake.nshmp.www.services;
-
-import gov.usgs.earthquake.nshmp.www.NshmpMicronautServlet;
-import gov.usgs.earthquake.nshmp.www.ResponseBody;
-import gov.usgs.earthquake.nshmp.www.services.SourceLogicTreesService.Metadata;
-import gov.usgs.earthquake.nshmp.www.services.SourceLogicTreesService.RequestData;
-
-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.swagger.v3.oas.annotations.Operation;
-import io.swagger.v3.oas.annotations.media.Content;
-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 NSHM source logic trees.
- *
- * @see SourceLogicTreesService
- * @author U.S. Geological Survey
- */
-@Tag(
-    name = "Source Logic Trees",
-    description = "NSHM source logic trees service")
-@Controller("/trees")
-public class SourceLogicTreesController {
-
-  @Inject
-  private NshmpMicronautServlet servlet;
-
-  @Operation(
-      description = "Returns the tectonic setting to source logic trees in the NSHM",
-      operationId = "trees_doGetMetadata",
-      summary = "Hazard model source logic trees")
-  @ApiResponse(
-      description = "Source logic trees metadata",
-      responseCode = "200",
-      content = @Content(
-          schema = @Schema(
-              implementation = MetadataResponse.class)))
-  @Get
-  public HttpResponse<String> doGetMetadata(HttpRequest<?> request) {
-    return SourceLogicTreesService.handleDoGetMetadata(request);
-  }
-
-  /**
-   * @param id Source tree id
-   */
-  @Operation(
-      description = "Returns the source logic tree for an id",
-      operationId = "trees_goGetTrees",
-      summary = "Get NSHM source logic tree")
-  @ApiResponse(
-      description = "NSHM source logic tree",
-      responseCode = "200",
-      content = @Content(
-          schema = @Schema(implementation = TreeResponse.class)))
-  @Get(uri = "/{id}")
-  public HttpResponse<String> doGetTrees(HttpRequest<?> request, @PathVariable int id) {
-    return SourceLogicTreesService.handleDoGetTrees(request, id);
-  }
-
-  // For Swagger schemas
-  private static class MetadataResponse extends ResponseBody<String, Metadata> {}
-
-  // For Swagger schemas
-  private static class TreeResponse extends ResponseBody<RequestData, Object> {}
-}
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
deleted file mode 100644
index 97de1486d9c158d1c1a7a44dc7af62006ba54d31..0000000000000000000000000000000000000000
--- a/src/main/java/gov/usgs/earthquake/nshmp/www/services/SourceLogicTreesService.java
+++ /dev/null
@@ -1,98 +0,0 @@
-package gov.usgs.earthquake.nshmp.www.services;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import gov.usgs.earthquake.nshmp.model.HazardModel;
-import gov.usgs.earthquake.nshmp.model.Models;
-import gov.usgs.earthquake.nshmp.www.HazVersion;
-import gov.usgs.earthquake.nshmp.www.ResponseBody;
-import gov.usgs.earthquake.nshmp.www.ResponseMetadata;
-import gov.usgs.earthquake.nshmp.www.ServletUtil;
-import gov.usgs.earthquake.nshmp.www.services.SourceServices.SourceModel;
-
-import io.micronaut.http.HttpRequest;
-import io.micronaut.http.HttpResponse;
-import jakarta.inject.Singleton;
-
-/**
- * Source model tree handler for {@link SourceLogicTreesController}
- *
- * @author U.S. Geological Survey
- */
-@Singleton
-public class SourceLogicTreesService {
-
-  static final Logger LOG = LoggerFactory.getLogger(SourceLogicTreesService.class);
-
-  private static final String NAME = "Source Logic Trees";
-
-  /** SourceLogicTreesController.doGetMetadata() handler */
-  public static HttpResponse<String> handleDoGetMetadata(HttpRequest<?> request) {
-    var url = request.getUri().getPath();
-
-    try {
-      var metadata = new Metadata(ServletUtil.model());
-      var response = ResponseBody.usage()
-          .name(NAME)
-          .url(url)
-          .metadata(new ResponseMetadata(HazVersion.appVersions()))
-          .request(url)
-          .response(metadata)
-          .build();
-      return HttpResponse.ok(ServletUtil.GSON.toJson(response));
-    } catch (Exception e) {
-      return ServletUtil.error(LOG, e, NAME, url);
-    }
-  }
-
-  /** SourceLogicTreesController.doGetTrees() handler */
-  public static HttpResponse<String> handleDoGetTrees(HttpRequest<?> request, Integer id) {
-    var url = request.getUri().getPath();
-
-    try {
-      var tree = Models.tree(ServletUtil.model(), id);
-      var requestData = new RequestData(id);
-      var response = ResponseBody.success()
-          .name(NAME)
-          .url(url)
-          .metadata(new ResponseMetadata(HazVersion.appVersions()))
-          .request(requestData)
-          .response(tree)
-          .build();
-      return HttpResponse.ok(ServletUtil.GSON.toJson(response));
-    } catch (Exception e) {
-      return ServletUtil.error(LOG, e, NAME, url);
-    }
-  }
-
-  static class RequestData {
-    final int id;
-
-    RequestData(int id) {
-      this.id = id;
-    }
-
-    public int getId() {
-      return id;
-    }
-  }
-
-  static class Metadata {
-    final SourceModel model;
-    final Object trees;
-
-    Metadata(HazardModel model) {
-      this.model = new SourceModel(model);
-      trees = Models.trees(model);
-    }
-
-    public SourceModel getModel() {
-      return model;
-    }
-
-    public Object getTrees() {
-      return trees;
-    }
-  }
-}
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/source/ProbabilityController.java b/src/main/java/gov/usgs/earthquake/nshmp/www/source/ProbabilityController.java
new file mode 100644
index 0000000000000000000000000000000000000000..33dbd1b172b7d58bbcaab09b6675a442fb536cd1
--- /dev/null
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/source/ProbabilityController.java
@@ -0,0 +1,110 @@
+package gov.usgs.earthquake.nshmp.www.source;
+
+import gov.usgs.earthquake.nshmp.www.NshmpMicronautServlet;
+import gov.usgs.earthquake.nshmp.www.ResponseBody;
+import gov.usgs.earthquake.nshmp.www.ServletUtil;
+import gov.usgs.earthquake.nshmp.www.source.RateService.ProbMetadata;
+import gov.usgs.earthquake.nshmp.www.source.RateService.Request;
+import gov.usgs.earthquake.nshmp.www.source.RateService.Response;
+
+import io.micronaut.http.HttpRequest;
+import io.micronaut.http.HttpResponse;
+import io.micronaut.http.MediaType;
+import io.micronaut.http.annotation.Controller;
+import io.micronaut.http.annotation.Get;
+import io.micronaut.http.annotation.PathVariable;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.media.Content;
+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 probability calcuations.
+ *
+ * <p>See src/main/resources/application.yml nshmp-haz.model-path for installed
+ * model.
+ *
+ * <p>To run the Micronaut jar file with a model: java -jar
+ * path/to/nshmp-haz.jar --model=<path/to/model>
+ *
+ * @author U.S. Geological Survey
+ */
+@Tag(
+    name = RateService.NAME_PROBABILITY,
+    description = "USGS NSHM earthquake probability calculation service")
+@Controller("/probability")
+public class ProbabilityController {
+
+  @Inject
+  private NshmpMicronautServlet servlet;
+
+  @Operation(
+      summary = "Earthquake probability calculation model and service metadata",
+      description = "Returns details of the installed model and service request parameters",
+      operationId = "probability-metadata")
+  @ApiResponse(
+      description = "Probability service metadata",
+      responseCode = "200",
+      content = @Content(
+          schema = @Schema(implementation = ProbabilityMetadata.class)))
+  @Get(produces = MediaType.APPLICATION_JSON)
+  public HttpResponse<String> doGetProbabilityMetadata(HttpRequest<?> http) {
+    try {
+      return RateService.getProbMetadata(http);
+    } catch (Exception e) {
+      return ServletUtil.error(
+          RateService.LOG, e,
+          RateService.NAME_PROBABILITY,
+          http.getUri().toString());
+    }
+  }
+
+  /**
+   * @param longitude Longitude in decimal degrees in the range
+   * @param latitude Latitude in decimal degrees in the range
+   * @param distance Cutoff distance in the range [0.01..1000] km.
+   * @param timespan Forecast time span in the range [1..10000] years.
+   */
+  @Operation(
+      summary = "Compute earthquake probabilities",
+      description = "Compute cumulative earthquake probabilities P(M ≥ x) at a location",
+      operationId = "probability-calc")
+  @ApiResponse(
+      description = "Earthquake probability calculation response",
+      responseCode = "200",
+      content = @Content(
+          schema = @Schema(
+              implementation = ProbabilityResponse.class)))
+  @Get(
+      uri = "/{longitude}/{latitude}/{distance}/{timespan}",
+      produces = MediaType.APPLICATION_JSON)
+  public HttpResponse<String> doGetProbability(
+      HttpRequest<?> http,
+      @PathVariable double longitude,
+      @PathVariable double latitude,
+      @Schema(
+          minimum = "0.01",
+          maximum = "1000") @PathVariable double distance,
+      @Schema(
+          minimum = "1",
+          maximum = "10000") @PathVariable double timespan) {
+    try {
+      RateService.ProbRequest request = new RateService.ProbRequest(
+          http, longitude, latitude, distance, timespan);
+      return RateService.getProbability(request);
+    } catch (Exception e) {
+      return ServletUtil.error(
+          RateService.LOG, e,
+          RateService.NAME_PROBABILITY,
+          http.getUri().toString());
+    }
+  }
+
+  // Swagger schema
+  private static class ProbabilityResponse extends ResponseBody<Request, Response> {}
+
+  // Swagger schema
+  private static class ProbabilityMetadata extends ResponseBody<String, ProbMetadata> {};
+}
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/source/RateController.java b/src/main/java/gov/usgs/earthquake/nshmp/www/source/RateController.java
new file mode 100644
index 0000000000000000000000000000000000000000..8dffb0c1fd894303271ee5ac796ea16a9c2ea78d
--- /dev/null
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/source/RateController.java
@@ -0,0 +1,105 @@
+package gov.usgs.earthquake.nshmp.www.source;
+
+import gov.usgs.earthquake.nshmp.www.NshmpMicronautServlet;
+import gov.usgs.earthquake.nshmp.www.ResponseBody;
+import gov.usgs.earthquake.nshmp.www.ServletUtil;
+import gov.usgs.earthquake.nshmp.www.source.RateService.Metadata;
+import gov.usgs.earthquake.nshmp.www.source.RateService.Request;
+import gov.usgs.earthquake.nshmp.www.source.RateService.Response;
+
+import io.micronaut.http.HttpRequest;
+import io.micronaut.http.HttpResponse;
+import io.micronaut.http.MediaType;
+import io.micronaut.http.annotation.Controller;
+import io.micronaut.http.annotation.Get;
+import io.micronaut.http.annotation.PathVariable;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.media.Content;
+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 rate calcuations.
+ *
+ * <p>See src/main/resources/application.yml nshmp-haz.model-path for installed
+ * model.
+ *
+ * <p>To run the Micronaut jar file with a model: java -jar
+ * path/to/nshmp-haz.jar --model=<path/to/model>
+ *
+ * @author U.S. Geological Survey
+ */
+@Tag(
+    name = RateService.NAME_RATE,
+    description = "USGS NSHM earthquake rate calculation service")
+@Controller("/rate")
+public class RateController {
+
+  @Inject
+  private NshmpMicronautServlet servlet;
+
+  @Operation(
+      summary = "Earthquake rate calculation model and service metadata",
+      description = "Returns details of the installed model and service request parameters",
+      operationId = "rate-metadata")
+  @ApiResponse(
+      description = "Rate service metadata",
+      responseCode = "200",
+      content = @Content(
+          schema = @Schema(implementation = RateMetadata.class)))
+  @Get(produces = MediaType.APPLICATION_JSON)
+  public HttpResponse<String> doGetRateMetadata(HttpRequest<?> http) {
+    try {
+      return RateService.getRateMetadata(http);
+    } catch (Exception e) {
+      return ServletUtil.error(
+          RateService.LOG, e,
+          RateService.NAME_RATE,
+          http.getUri().toString());
+    }
+  }
+
+  /**
+   * @param longitude Longitude in decimal degrees in the range
+   * @param latitude Latitude in decimal degrees in the range
+   * @param distance Cutoff distance in the range [0.01, 1000] km.
+   */
+  @Operation(
+      summary = "Compute annual earthquake rates",
+      description = "Compute incremental annual earthquake rates at a location",
+      operationId = "rate-calc")
+  @ApiResponse(
+      description = "Earthquake annual rate calculation response",
+      responseCode = "200",
+      content = @Content(
+          schema = @Schema(implementation = RateResponse.class)))
+  @Get(
+      uri = "/{longitude}/{latitude}/{distance}",
+      produces = MediaType.APPLICATION_JSON)
+  public HttpResponse<String> doGetRate(
+      HttpRequest<?> http,
+      @PathVariable double longitude,
+      @PathVariable double latitude,
+      @Schema(
+          minimum = "0.01",
+          maximum = "1000") @PathVariable double distance) {
+    try {
+      RateService.Request request = new RateService.Request(
+          http, longitude, latitude, distance);
+      return RateService.getRate(request);
+    } catch (Exception e) {
+      return ServletUtil.error(
+          RateService.LOG, e,
+          RateService.NAME_RATE,
+          http.getUri().toString());
+    }
+  }
+
+  // Swagger schema
+  private static class RateResponse extends ResponseBody<Request, Response> {}
+
+  // Swagger schema
+  private static class RateMetadata extends ResponseBody<String, Metadata> {};
+}
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/source/RateService.java b/src/main/java/gov/usgs/earthquake/nshmp/www/source/RateService.java
new file mode 100644
index 0000000000000000000000000000000000000000..b610a993541d31d71cee1cac50d9311e20b1c95e
--- /dev/null
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/source/RateService.java
@@ -0,0 +1,425 @@
+package gov.usgs.earthquake.nshmp.www.source;
+
+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 java.util.ArrayList;
+import java.util.List;
+import java.util.OptionalDouble;
+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.collect.Range;
+import com.google.common.util.concurrent.ListenableFuture;
+
+import gov.usgs.earthquake.nshmp.calc.CalcConfig;
+import gov.usgs.earthquake.nshmp.calc.EqRate;
+import gov.usgs.earthquake.nshmp.calc.Site;
+import gov.usgs.earthquake.nshmp.geo.Location;
+import gov.usgs.earthquake.nshmp.model.HazardModel;
+import gov.usgs.earthquake.nshmp.www.HazVersion;
+import gov.usgs.earthquake.nshmp.www.ResponseBody;
+import gov.usgs.earthquake.nshmp.www.ResponseMetadata;
+import gov.usgs.earthquake.nshmp.www.ServletUtil;
+import gov.usgs.earthquake.nshmp.www.ServletUtil.Server;
+import gov.usgs.earthquake.nshmp.www.meta.DoubleParameter;
+import gov.usgs.earthquake.nshmp.www.source.SourceService.SourceModel;
+
+import io.micronaut.http.HttpRequest;
+import io.micronaut.http.HttpResponse;
+import jakarta.inject.Singleton;
+
+/**
+ * Earthquake probability and rate calculation handler service for
+ * {@link RateController}.
+ *
+ * @author U.S. Geological Survey
+ */
+@Singleton
+public final class RateService {
+
+  static final String NAME_RATE = "Earthquake Rates";
+  static final String NAME_PROBABILITY = "Earthquake Probabilities";
+  static final Logger LOG = LoggerFactory.getLogger(RateService.class);
+
+  /*
+   * Developer notes:
+   *
+   * The RateService is currently single-threaded and does not submit jobs to a
+   * request queue; see HazardService. However, jobs are placed on a thread in
+   * the CALC_EXECUTOR thread pool to handle parallel calculation of CEUS and
+   * WUS models.
+   */
+
+  private static final String TOTAL_KEY = "Total";
+
+  public static HttpResponse<String> getRate(Request request) {
+    var stopwatch = Stopwatch.createStarted();
+    var rates = calcRate(request);
+    var response = new Response.Builder()
+        .timer(stopwatch)
+        .request(request)
+        .rates(rates)
+        .build();
+    var body = ResponseBody.success()
+        .name(NAME_RATE)
+        .url(request.http.getUri().toString())
+        .metadata(new ResponseMetadata(HazVersion.appVersions()))
+        .request(request)
+        .response(response)
+        .build();
+    String json = ServletUtil.GSON2.toJson(body);
+    return HttpResponse.ok(json);
+  }
+
+  public static HttpResponse<String> getProbability(ProbRequest request) {
+    var stopwatch = Stopwatch.createStarted();
+    var rates = calcProbability(request);
+    var response = new Response.Builder()
+        .timer(stopwatch)
+        .request(request)
+        .rates(rates)
+        .build();
+    var body = ResponseBody.success()
+        .name(NAME_PROBABILITY)
+        .url(request.http.getUri().toString())
+        .metadata(new ResponseMetadata(HazVersion.appVersions()))
+        .request(request)
+        .response(response)
+        .build();
+    String json = ServletUtil.GSON2.toJson(body);
+    return HttpResponse.ok(json);
+  }
+
+  private static EqRate calcRate(Request request) {
+    return calc(request, OptionalDouble.empty());
+  }
+
+  private static EqRate calcProbability(ProbRequest request) {
+    return calc(request, OptionalDouble.of(request.timespan));
+  }
+
+  private static EqRate calc(Request request, OptionalDouble timespan) {
+    var location = Location.create(request.longitude, request.latitude);
+    var site = Site.builder().location(location).build();
+    var futureRates = new ArrayList<ListenableFuture<EqRate>>();
+
+    /*
+     * Because we need to combine model results, intially calculate incremental
+     * annual rates and only convert to cumulative probabilities at the end if
+     * probability service has been called.
+     */
+
+    var model = ServletUtil.model();
+    var rate = process(model, site, request.distance, timespan);
+    futureRates.add(rate);
+
+    var rates = futureRates.stream()
+        .map((future) -> {
+          try {
+            return future.get();
+          } catch (InterruptedException | ExecutionException e) {
+            throw new RuntimeException(e);
+          }
+        }).collect(Collectors.toList())
+        .toArray(new EqRate[] {});
+
+    var ratesCombined = EqRate.combine(rates);
+
+    if (timespan.isPresent()) {
+      ratesCombined = EqRate.toCumulative(ratesCombined);
+      ratesCombined = EqRate.toPoissonProbability(ratesCombined, timespan.orElseThrow());
+    }
+
+    return ratesCombined;
+  }
+
+  private static ListenableFuture<EqRate> process(
+      HazardModel model,
+      Site site,
+      double distance,
+      OptionalDouble timespan) {
+    var configBuilder = CalcConfig.copyOf(model.config()).distance(distance);
+    if (timespan.isPresent()) {
+      /* Also sets value format to Poisson probability. */
+      configBuilder.timespan(timespan.getAsDouble());
+    }
+    var config = configBuilder.build();
+    var task = EqRate.callable(model, config, site);
+    return ServletUtil.CALC_EXECUTOR.submit(task);
+  }
+
+  static class Response {
+
+    final Metadata metadata;
+    final List<Sequence> data;
+
+    Response(Metadata metadata, List<Sequence> data) {
+      this.metadata = metadata;
+      this.data = data;
+    }
+
+    public Metadata getMetadata() {
+      return metadata;
+    }
+
+    public List<Sequence> getData() {
+      return data;
+    }
+
+    private static final class Metadata {
+      final Server server;
+      final String xlabel = "Ground Motion (g)";
+      final String ylabel = "Annual Frequency of Exceedance";
+
+      Metadata(Server server) {
+        this.server = server;
+      }
+
+      public Server getServer() {
+        return server;
+      }
+
+      public String getXLabel() {
+        return xlabel;
+      }
+
+      public String getYLabel() {
+        return ylabel;
+      }
+    }
+
+    private static final class Builder {
+
+      Stopwatch timer;
+      Request request;
+      List<Sequence> data;
+
+      Builder timer(Stopwatch timer) {
+        this.timer = timer;
+        return this;
+      }
+
+      Builder request(Request request) {
+        this.request = request;
+        return this;
+      }
+
+      Builder rates(EqRate rates) {
+        this.data = buildSequences(rates);
+        return this;
+      }
+
+      private List<Sequence> buildSequences(EqRate rates) {
+        var sequences = new ArrayList<Sequence>();
+
+        /* Total mfd. */
+        var total = (!rates.totalMfd.isClear()) ? rates.totalMfd.trim() : rates.totalMfd;
+        var totalOut = new Sequence(
+            TOTAL_KEY,
+            total.xValues().boxed().collect(Collectors.toList()),
+            total.yValues().boxed().collect(Collectors.toList()));
+        sequences.add(totalOut);
+
+        /* Source type mfds. */
+        for (var entry : rates.typeMfds.entrySet()) {
+          var type = entry.getValue();
+          if (type.isClear()) {
+            continue;
+          }
+          type = type.trim();
+          var typeOut = new Sequence(
+              entry.getKey().toString(),
+              type.xValues().boxed().collect(Collectors.toList()),
+              type.yValues().boxed().collect(Collectors.toList()));
+          sequences.add(typeOut);
+        }
+
+        return List.copyOf(sequences);
+      }
+
+      Response build() {
+        Server server = ServletUtil.serverData(ServletUtil.THREAD_COUNT, timer);
+        return new Response(new Response.Metadata(server), data);
+      }
+    }
+  }
+
+  /*
+   * Would rather use this a general container for mfds and hazard curves. See
+   * HazardService.Curve
+   */
+  private static class Sequence {
+    final String component;
+    final List<Double> xvalues;
+    final List<Double> yvalues;
+
+    Sequence(String component, List<Double> xvalues, List<Double> yvalues) {
+      this.component = component;
+      this.xvalues = xvalues;
+      this.yvalues = yvalues;
+    }
+
+    public String getComponent() {
+      return component;
+    }
+
+    public List<Double> getXvalues() {
+      return xvalues;
+    }
+
+    public List<Double> getYvalues() {
+      return yvalues;
+    }
+  }
+
+  private static final Range<Double> DISTANCE_RANGE = Range.closed(0.01, 1000.0);
+  private static final Range<Double> TIMESPAN_RANGE = Range.closed(1.0, 10000.0);
+
+  static class Request {
+
+    final transient HttpRequest<?> http;
+    final double longitude;
+    final double latitude;
+    final double distance;
+
+    public Request(
+        HttpRequest<?> http,
+        double longitude,
+        double latitude,
+        double distance) {
+      this.http = http;
+      this.longitude = checkLongitude(longitude);
+      this.latitude = checkLatitude(latitude);
+      this.distance = checkInRange(DISTANCE_RANGE, "Distance cutoff", distance);
+    }
+
+    public double getLongitude() {
+      return longitude;
+    }
+
+    public double getLatitude() {
+      return latitude;
+    }
+
+    public double getDistance() {
+      return distance;
+    }
+  }
+
+  static class ProbRequest extends Request {
+
+    final double timespan;
+
+    public ProbRequest(
+        HttpRequest<?> http,
+        double longitude,
+        double latitude,
+        double distance,
+        double timespan) {
+      super(http, longitude, latitude, distance);
+      this.timespan = checkInRange(TIMESPAN_RANGE, "Forecast timespan", timespan);
+    }
+
+    public double getTimespan() {
+      return timespan;
+    }
+  }
+
+  public static HttpResponse<String> getRateMetadata(HttpRequest<?> request) {
+    var url = request.getUri().toString();
+    var usage = new Metadata(ServletUtil.model());
+    var body = ResponseBody.usage()
+        .name(NAME_RATE)
+        .url(url)
+        .metadata(new ResponseMetadata(HazVersion.appVersions()))
+        .request(url)
+        .response(usage)
+        .build();
+    var json = ServletUtil.GSON2.toJson(body);
+    return HttpResponse.ok(json);
+  }
+
+  public static HttpResponse<String> getProbMetadata(HttpRequest<?> request) {
+    var url = request.getUri().toString();
+    var usage = new ProbMetadata(ServletUtil.model());
+    var body = ResponseBody.usage()
+        .name(NAME_PROBABILITY)
+        .url(url)
+        .metadata(new ResponseMetadata(HazVersion.appVersions()))
+        .request(url)
+        .response(usage)
+        .build();
+    var json = ServletUtil.GSON2.toJson(body);
+    return HttpResponse.ok(json);
+  }
+
+  static class Metadata {
+
+    final SourceModel model;
+    final DoubleParameter longitude;
+    final DoubleParameter latitude;
+    final DoubleParameter distance;
+
+    Metadata(HazardModel model) {
+      this.model = new SourceModel(model);
+      longitude = new DoubleParameter(
+          "Longitude",
+          "°",
+          model.bounds().min.longitude,
+          model.bounds().max.longitude);
+
+      latitude = new DoubleParameter(
+          "Latitude",
+          "°",
+          model.bounds().min.latitude,
+          model.bounds().max.latitude);
+
+      distance = new DoubleParameter(
+          "Cutoff distance",
+          "km",
+          0.01,
+          1000.0);
+    }
+
+    public SourceModel getModel() {
+      return model;
+    }
+
+    public DoubleParameter getLongitude() {
+      return longitude;
+    }
+
+    public DoubleParameter getLatitude() {
+      return latitude;
+    }
+
+    public DoubleParameter getDistance() {
+      return distance;
+    }
+  }
+
+  static class ProbMetadata extends Metadata {
+
+    final DoubleParameter timespan;
+
+    ProbMetadata(HazardModel model) {
+      super(model);
+      timespan = new DoubleParameter(
+          "Forecast timespan",
+          "years",
+          1.0,
+          10000.0);
+    }
+
+    public DoubleParameter getTimespan() {
+      return timespan;
+    }
+  }
+
+}
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/services/SourceController.java b/src/main/java/gov/usgs/earthquake/nshmp/www/source/SourceController.java
similarity index 54%
rename from src/main/java/gov/usgs/earthquake/nshmp/www/services/SourceController.java
rename to src/main/java/gov/usgs/earthquake/nshmp/www/source/SourceController.java
index b6fe870b5d8f6ddc6359971fcbda57f0b4a774c4..2bd63ae10a410fcd0de8e2ee2bc7b482f935322a 100644
--- a/src/main/java/gov/usgs/earthquake/nshmp/www/services/SourceController.java
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/source/SourceController.java
@@ -1,8 +1,9 @@
-package gov.usgs.earthquake.nshmp.www.services;
+package gov.usgs.earthquake.nshmp.www.source;
 
 import gov.usgs.earthquake.nshmp.www.NshmpMicronautServlet;
 import gov.usgs.earthquake.nshmp.www.ResponseBody;
-import gov.usgs.earthquake.nshmp.www.services.SourceServices.ResponseData;
+import gov.usgs.earthquake.nshmp.www.ServletUtil;
+import gov.usgs.earthquake.nshmp.www.source.SourceService.ResponseData;
 
 import io.micronaut.http.HttpRequest;
 import io.micronaut.http.HttpResponse;
@@ -17,34 +18,36 @@ import io.swagger.v3.oas.annotations.tags.Tag;
 import jakarta.inject.Inject;
 
 /**
- * Source model service to return the current installed model.
+ * Micronaut web service controller to return metadata for the current installed
+ * model.
  *
- * <p> See src/main/resources/applicaiton.yml nshmp-haz.installed-model for
- * default model used
- *
- * <p> To run the Micronaut jar file with a model: java -jar
- * path/to/nshmp-haz.jar -model=<{@code Model}>
+ * <p>See src/main/resources/application.yml nshmp-haz.model-path for installed
+ * model.
  *
+ * <p>To run the Micronaut jar file with a model: java -jar
+ * path/to/nshmp-haz.jar --model=<path/to/model>
  *
  * @author U.S. Geological Survey
  */
-@Tag(name = "Source Model")
+@Tag(
+    name = SourceService.NAME,
+    description = "USGS NSHM source model metadata service")
 @Controller("/source")
 public class SourceController {
 
+  // TODO consider renaming to /model
+
   @Inject
   private NshmpMicronautServlet servlet;
 
   /**
-   * GET method to return the source model usage
-   *
    * @param request The HTTP request
    */
   @Operation(
-      summary = "Returns the metadata about the current installed NSHM",
+      summary = "Returns metadata about the current installed NSHM",
       description = "Returns the install National Hazard Model with supported:\n" +
-          "* Intensity measure types (IMT)\n * VS30\n * Region bounds\n * Return period",
-      operationId = "source_doGetUsage")
+          "* Intensity measure types (IMT)\n * Vs30\n * Region bounds\n * Return period",
+      operationId = "source-model-metadata")
   @ApiResponse(
       description = "Installed source model",
       responseCode = "200",
@@ -52,10 +55,17 @@ public class SourceController {
           schema = @Schema(
               implementation = MetadataResponse.class)))
   @Get(produces = MediaType.APPLICATION_JSON)
-  public HttpResponse<String> doGetUsage(HttpRequest<?> request) {
-    return SourceServices.handleDoGetUsage(request);
+  public HttpResponse<String> doGetMetadata(HttpRequest<?> http) {
+    try {
+      return SourceService.getMetadata(http);
+    } catch (Exception e) {
+      return ServletUtil.error(
+          SourceService.LOG, e,
+          SourceService.NAME,
+          http.getUri().toString());
+    }
   }
 
-  // For Swagger schemas
+  // Swagger schema
   private static class MetadataResponse extends ResponseBody<String, ResponseData> {}
 }
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/source/SourceLogicTreesController.java b/src/main/java/gov/usgs/earthquake/nshmp/www/source/SourceLogicTreesController.java
new file mode 100644
index 0000000000000000000000000000000000000000..971c66e80bbc529918c3c7b3427bbf83235870ce
--- /dev/null
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/source/SourceLogicTreesController.java
@@ -0,0 +1,93 @@
+package gov.usgs.earthquake.nshmp.www.source;
+
+import gov.usgs.earthquake.nshmp.www.NshmpMicronautServlet;
+import gov.usgs.earthquake.nshmp.www.ResponseBody;
+import gov.usgs.earthquake.nshmp.www.ServletUtil;
+import gov.usgs.earthquake.nshmp.www.source.SourceLogicTreesService.Metadata;
+import gov.usgs.earthquake.nshmp.www.source.SourceLogicTreesService.RequestData;
+
+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.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.media.Content;
+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 the source logic trees in the current
+ * installed model.
+ *
+ * <p>See src/main/resources/application.yml nshmp-haz.model-path for installed
+ * model.
+ *
+ * <p>To run the Micronaut jar file with a model: java -jar
+ * path/to/nshmp-haz.jar --model=<path/to/model>
+ *
+ * @author U.S. Geological Survey
+ */
+@Tag(
+    name = SourceLogicTreesService.NAME,
+    description = "USGS NSHM source model logic tree service")
+@Controller("/trees")
+public class SourceLogicTreesController {
+
+  @Inject
+  private NshmpMicronautServlet servlet;
+
+  @Operation(
+      summary = "Source model logic tree listing",
+      description = "Returns the ID's of logic trees in the model",
+      operationId = "source-tree-metadata")
+  @ApiResponse(
+      description = "Source logic tree metadata",
+      responseCode = "200",
+      content = @Content(
+          schema = @Schema(
+              implementation = MetadataResponse.class)))
+  @Get
+  public HttpResponse<String> doGetMetadata(HttpRequest<?> http) {
+    try {
+      return SourceLogicTreesService.getMetadata(http);
+    } catch (Exception e) {
+      return ServletUtil.error(
+          SourceLogicTreesService.LOG, e,
+          SourceLogicTreesService.NAME,
+          http.getUri().toString());
+    }
+  }
+
+  /**
+   * @param id Source tree id
+   */
+  @Operation(
+      summary = "Get a source model MFD logic tree",
+      description = "Returns the logic tree of MFDs for the supplied ID",
+      operationId = "source-tree-mfds")
+  @ApiResponse(
+      description = "NSHM source logic tree",
+      responseCode = "200",
+      content = @Content(
+          schema = @Schema(implementation = TreeResponse.class)))
+  @Get(uri = "/{id}")
+  public HttpResponse<String> doGetTree(HttpRequest<?> http, @PathVariable int id) {
+    try {
+      return SourceLogicTreesService.getTree(http, id);
+    } catch (Exception e) {
+      return ServletUtil.error(
+          SourceLogicTreesService.LOG, e,
+          SourceLogicTreesService.NAME,
+          http.getUri().toString());
+    }
+  }
+
+  // Swagger schema
+  private static class MetadataResponse extends ResponseBody<String, Metadata> {}
+
+  // Swagger schema
+  private static class TreeResponse extends ResponseBody<RequestData, Object> {}
+}
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/source/SourceLogicTreesService.java b/src/main/java/gov/usgs/earthquake/nshmp/www/source/SourceLogicTreesService.java
new file mode 100644
index 0000000000000000000000000000000000000000..3f0fe6541bf47166bda144fe12484924935ffe6c
--- /dev/null
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/source/SourceLogicTreesService.java
@@ -0,0 +1,85 @@
+package gov.usgs.earthquake.nshmp.www.source;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import gov.usgs.earthquake.nshmp.model.HazardModel;
+import gov.usgs.earthquake.nshmp.model.Models;
+import gov.usgs.earthquake.nshmp.www.HazVersion;
+import gov.usgs.earthquake.nshmp.www.ResponseBody;
+import gov.usgs.earthquake.nshmp.www.ResponseMetadata;
+import gov.usgs.earthquake.nshmp.www.ServletUtil;
+import gov.usgs.earthquake.nshmp.www.source.SourceService.SourceModel;
+
+import io.micronaut.http.HttpRequest;
+import io.micronaut.http.HttpResponse;
+import jakarta.inject.Singleton;
+
+/**
+ * Source model tree handler for {@link SourceLogicTreesController}
+ *
+ * @author U.S. Geological Survey
+ */
+@Singleton
+public class SourceLogicTreesService {
+
+  static final String NAME = "Model Logic Trees";
+  static final Logger LOG = LoggerFactory.getLogger(SourceLogicTreesService.class);
+
+  public static HttpResponse<String> getMetadata(HttpRequest<?> request) {
+    var url = request.getUri().toString();
+    var metadata = new Metadata(ServletUtil.model());
+    var response = ResponseBody.usage()
+        .name(NAME)
+        .url(url)
+        .metadata(new ResponseMetadata(HazVersion.appVersions()))
+        .request(url)
+        .response(metadata)
+        .build();
+    return HttpResponse.ok(ServletUtil.GSON2.toJson(response));
+  }
+
+  public static HttpResponse<String> getTree(HttpRequest<?> request, Integer id) {
+    var url = request.getUri().toString();
+    var tree = Models.tree(ServletUtil.model(), id);
+    var requestData = new RequestData(id);
+    var response = ResponseBody.success()
+        .name(NAME)
+        .url(url)
+        .metadata(new ResponseMetadata(HazVersion.appVersions()))
+        .request(requestData)
+        .response(tree)
+        .build();
+    return HttpResponse.ok(ServletUtil.GSON2.toJson(response));
+  }
+
+  static class RequestData {
+    final int id;
+
+    RequestData(int id) {
+      this.id = id;
+    }
+
+    public int getId() {
+      return id;
+    }
+  }
+
+  static class Metadata {
+    final SourceModel model;
+    final Object trees;
+
+    Metadata(HazardModel model) {
+      this.model = new SourceModel(model);
+      trees = Models.trees(model);
+    }
+
+    public SourceModel getModel() {
+      return model;
+    }
+
+    public Object getTrees() {
+      return trees;
+    }
+  }
+}
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/services/SourceServices.java b/src/main/java/gov/usgs/earthquake/nshmp/www/source/SourceService.java
similarity index 53%
rename from src/main/java/gov/usgs/earthquake/nshmp/www/services/SourceServices.java
rename to src/main/java/gov/usgs/earthquake/nshmp/www/source/SourceService.java
index 525ed76b346e7485281ebdfe972049f4627a4d87..6cedce847c694e6243773f74bde87e91ba7be2f5 100644
--- a/src/main/java/gov/usgs/earthquake/nshmp/www/services/SourceServices.java
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/source/SourceService.java
@@ -1,26 +1,22 @@
-package gov.usgs.earthquake.nshmp.www.services;
+package gov.usgs.earthquake.nshmp.www.source;
 
 import static java.util.stream.Collectors.toList;
 
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.stream.DoubleStream;
 
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import com.google.gson.Gson;
-import com.google.gson.GsonBuilder;
-
 import gov.usgs.earthquake.nshmp.gmm.Gmm;
-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.HazVersion;
 import gov.usgs.earthquake.nshmp.www.ResponseBody;
 import gov.usgs.earthquake.nshmp.www.ResponseMetadata;
 import gov.usgs.earthquake.nshmp.www.ServletUtil;
-import gov.usgs.earthquake.nshmp.www.WsUtils;
 import gov.usgs.earthquake.nshmp.www.meta.Parameter;
 
 import io.micronaut.http.HttpRequest;
@@ -28,44 +24,29 @@ import io.micronaut.http.HttpResponse;
 import jakarta.inject.Singleton;
 
 /**
- * Entry point for services related to source models. Current services:
- * <ul><li>/source/</li></ul>
+ * Source model service.
  *
  * @author U.S. Geological Survey
  */
 @Singleton
-public class SourceServices {
-
-  private static final String NAME = "Source Model";
-
-  static final Logger LOG = LoggerFactory.getLogger(RateService.class);
-
-  public static final Gson GSON;
-
-  static {
-    GSON = new GsonBuilder()
-        .registerTypeAdapter(Imt.class, new WsUtils.EnumSerializer<Imt>())
-        .disableHtmlEscaping()
-        .serializeNulls()
-        .setPrettyPrinting()
-        .create();
-  }
-
-  static HttpResponse<String> handleDoGetUsage(HttpRequest<?> request) {
-    var url = request.getUri().getPath();
-    try {
-      var response = ResponseBody.usage()
-          .name(NAME)
-          .url(url)
-          .metadata(new ResponseMetadata(HazVersion.appVersions()))
-          .request(url)
-          .response(new ResponseData())
-          .build();
-      var json = GSON.toJson(response);
-      return HttpResponse.ok(json);
-    } catch (Exception e) {
-      return ServletUtil.error(LOG, e, NAME, url);
-    }
+public class SourceService {
+
+  static final String NAME = "Model Contents";
+  static final Logger LOG = LoggerFactory.getLogger(SourceService.class);
+
+  static HttpResponse<String> getMetadata(HttpRequest<?> request) {
+    var url = request.getUri().toString();
+    var response = ResponseBody.usage()
+        .name(NAME)
+        .url(url)
+        .metadata(new ResponseMetadata(HazVersion.appVersions()))
+        .request(url)
+        .response(new ResponseData())
+        .build();
+    // TODO check other services for url) and
+    // request() passing in the same url obj
+    var json = ServletUtil.GSON2.toJson(response);
+    return HttpResponse.ok(json);
   }
 
   static class ResponseData {
@@ -91,6 +72,7 @@ public class SourceServices {
     final Set<Gmm> gmms;
     final Map<NehrpSiteClass, Double> siteClasses;
     final List<Parameter> imts;
+    final List<Double> bounds;
 
     public SourceModel(HazardModel model) {
       name = model.name();
@@ -103,6 +85,7 @@ public class SourceServices {
           .sorted()
           .map(imt -> new Parameter(ServletUtil.imtShortLabel(imt), imt.name()))
           .collect(toList());
+      bounds = DoubleStream.of(model.bounds().toArray()).boxed().collect(toList());
     }
 
     public String getName() {
@@ -120,36 +103,9 @@ public class SourceServices {
     public List<Parameter> getImts() {
       return imts;
     }
-  }
-
-  enum Attributes {
-    /* Source model service */
-    MODEL,
-
-    /* Serializing */
-    ID,
-    VALUE,
-    DISPLAY,
-    DISPLAYORDER,
-    YEAR,
-    PATH,
-    REGION,
-    IMT,
-    VS30,
-    SUPPORTS,
-    MINLATITUDE,
-    MINLONGITUDE,
-    MAXLATITUDE,
-    MAXLONGITUDE;
-
-    /** Return upper case string */
-    String toUpperCase() {
-      return name().toUpperCase();
-    }
 
-    /** Return lower case string */
-    String toLowerCase() {
-      return name().toLowerCase();
+    public List<Double> getBounds() {
+      return bounds;
     }
   }
 }
diff --git a/src/test/java/gov/usgs/earthquake/nshmp/model/peer/PeerTest.java b/src/test/java/gov/usgs/earthquake/nshmp/model/peer/PeerTest.java
index b18913f725d0367f5b9354842db1b0730cec99b1..d4fea07779a619d6836a05974fd9625003c0aa4e 100644
--- a/src/test/java/gov/usgs/earthquake/nshmp/model/peer/PeerTest.java
+++ b/src/test/java/gov/usgs/earthquake/nshmp/model/peer/PeerTest.java
@@ -2,7 +2,7 @@ package gov.usgs.earthquake.nshmp.model.peer;
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
-import static gov.usgs.earthquake.nshmp.internal.Parsing.Delimiter.COMMA;
+import static gov.usgs.earthquake.nshmp.Text.Delimiter.COMMA;
 import static java.lang.Math.abs;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.junit.jupiter.api.Assertions.assertArrayEquals;
@@ -31,13 +31,13 @@ import com.google.gson.Gson;
 import com.google.gson.GsonBuilder;
 
 import gov.usgs.earthquake.nshmp.Maths;
+import gov.usgs.earthquake.nshmp.Text;
 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.calc.Sites;
 import gov.usgs.earthquake.nshmp.gmm.Imt;
-import gov.usgs.earthquake.nshmp.internal.Parsing;
 import gov.usgs.earthquake.nshmp.model.HazardModel;
 
 class PeerTest {
@@ -164,7 +164,7 @@ class PeerTest {
     for (String line : Iterables.skip(lines, 1)) {
       String[] splitLine = line.split(",", 4);
       String siteName = splitLine[0];
-      List<Double> values = Parsing.splitToDoubleList(splitLine[3], COMMA);
+      List<Double> values = Text.splitToDoubleList(splitLine[3], COMMA);
       siteValuesMap.put(siteName, Doubles.toArray(values));
     }
     return siteValuesMap;