Skip to content

Commit 0412135

Browse files
committed
Add NCM profile fetching and loading functionality
- Implemented `fetch_ncm_profile` to retrieve geophysical profiles from the USGS National Crustal Model and save them as JSON (gzip-compressed option available). - Added `load_ncm_profile` to load and parse the NCM JSON files into a pandas DataFrame, with optional simplification of adjacent layers. - Introduced `_simplify_profile` to merge layers with similar shear-wave velocities. - Enhanced `Layer` class with a method to compute layer-adjusted damping based on strain. - Updated `adjusted_damping_curves` property to reflect layer-specific minimum damping. - Added mean effective stress calculation in `calc_mean_eff_stress` to support new profile features.
1 parent fee85e0 commit 0412135

File tree

7 files changed

+681
-20
lines changed

7 files changed

+681
-20
lines changed

.pre-commit-config.yaml

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
repos:
22
- repo: https://github.com/astral-sh/ruff-pre-commit
33
# Ruff version.
4-
rev: v0.14.14
4+
rev: v0.15.4
55
hooks:
66
# Run the linter.
77
- id: ruff
@@ -14,14 +14,10 @@ repos:
1414
hooks:
1515
- id: pyupgrade
1616
args: [--py3-plus, --py310-plus]
17-
- repo: https://github.com/datarootsio/databooks
18-
rev: 1.3.10
19-
hooks:
20-
- id: databooks-meta
21-
# - repo: https://github.com/pycqa/pydocstyle
22-
# rev: 6.1.1 # pick a git hash / tag to point to
23-
# hooks:
24-
# - id: pydocstyle
17+
# - repo: https://github.com/pycqa/pydocstyle
18+
# rev: 6.1.1 # pick a git hash / tag to point to
19+
# hooks:
20+
# - id: pydocstyle
2521
- repo: https://github.com/pre-commit/pre-commit-hooks
2622
rev: v6.0.0
2723
hooks:

examples/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ The examples included here are used to demonstrate the capabilities of
2222
14. Example with multiple input motions and simulated soil profiles.
2323
15. List of provided nonlinear curves.
2424
16. Example of using the logic tree.
25+
17. Building a site profile from the USGS National Crustal Model with
26+
Darendeli nonlinear curves and κ₀-adjusted damping.
2527

2628
To be added:
2729

examples/data/ncm_example.json.gz

68.3 KB
Binary file not shown.

examples/example-17.ipynb

Lines changed: 385 additions & 0 deletions
Large diffs are not rendered by default.

src/pystrata/generic.py

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
"""Generic velocity profiles."""
22

3+
import gzip as _gzip
4+
import json
5+
import os
6+
import urllib.request
37
import warnings
48

59
import numpy as np
610
import pandas as pd
711

12+
from .tools import calc_mean_eff_stress
13+
814

915
def kea16_profile(depth: float, vs30: float, region: str) -> pd.DataFrame:
1016
"""
@@ -95,3 +101,191 @@ def kea16_profile(depth: float, vs30: float, region: str) -> pd.DataFrame:
95101
)
96102

97103
return df
104+
105+
106+
NCM_BASE_URL = "https://earthquake.usgs.gov/ws/nshmp/ncm/geophysical"
107+
108+
109+
def fetch_ncm_profile(
110+
latitude: float,
111+
longitude: float,
112+
fpath: str | os.PathLike,
113+
depth_start: float = 0,
114+
depth_step: float = 5,
115+
depth_end: float = 1e4,
116+
gzip_output: bool = True,
117+
) -> None:
118+
"""Fetch a geophysical profile from the USGS National Crustal Model.
119+
120+
Queries the NCM geophysical endpoint for a single location and saves the
121+
JSON response to *fpath*.
122+
123+
Parameters
124+
----------
125+
latitude : float
126+
Latitude of the site [degrees].
127+
longitude : float
128+
Longitude of the site [degrees].
129+
fpath : str or path-like
130+
Output file path. If *gzip_output* is True the file is gzip-compressed.
131+
depth_start : float, optional
132+
Minimum depth [m] (default 0).
133+
depth_step : float, optional
134+
Depth increment [m] (default 5).
135+
depth_end : float, optional
136+
Maximum depth [m] (default 10 000).
137+
gzip_output : bool, optional
138+
If True (default), write a gzip-compressed JSON file.
139+
140+
Raises
141+
------
142+
urllib.error.HTTPError
143+
If the request to the NCM service fails.
144+
RuntimeError
145+
If the response status is not ``"sucess"`` (sic).
146+
"""
147+
url = (
148+
f"{NCM_BASE_URL}"
149+
f"?location={latitude},{longitude}"
150+
f"&depths={depth_start},{depth_step},{depth_end}"
151+
)
152+
with urllib.request.urlopen(url) as resp:
153+
data = json.loads(resp.read().decode())
154+
155+
if data.get("status") not in ("sucess", "success"):
156+
raise RuntimeError(f"NCM request failed with status: {data.get('status')}")
157+
158+
fpath = os.fspath(fpath)
159+
if gzip_output:
160+
with _gzip.open(fpath, "wt") as f:
161+
json.dump(data, f)
162+
else:
163+
with open(fpath, "w") as f:
164+
json.dump(data, f)
165+
166+
167+
def load_ncm_profile(
168+
fpath: str | os.PathLike,
169+
simplify: bool = False,
170+
simplify_tol: float = 0.02,
171+
) -> tuple[pd.DataFrame, float]:
172+
"""Load an NCM geophysical profile and return a DataFrame.
173+
174+
Parameters
175+
----------
176+
fpath : str or path-like
177+
Path to the NCM JSON file (plain or gzip-compressed).
178+
simplify : bool, optional
179+
If True, merge adjacent layers whose shear-wave velocities differ by
180+
less than *simplify_tol* (default False).
181+
simplify_tol : float, optional
182+
Relative tolerance on ``vel_shear`` for merging adjacent layers
183+
(default 0.02, i.e. 2 %).
184+
185+
Returns
186+
-------
187+
df : pandas.DataFrame
188+
DataFrame with columns ``depth`` [m], ``vel_shear`` [m/s], and
189+
``density`` [kg/m³].
190+
water_table_depth : float
191+
Depth to the water table [m].
192+
193+
Raises
194+
------
195+
ValueError
196+
If the file cannot be parsed or contains no results.
197+
"""
198+
fpath = os.fspath(fpath)
199+
200+
# Try gzip first, fall back to plain JSON
201+
try:
202+
with _gzip.open(fpath, "rt") as f:
203+
data = json.load(f)
204+
except _gzip.BadGzipFile:
205+
with open(fpath) as f:
206+
data = json.load(f)
207+
208+
results = data["response"]["results"]
209+
if not results:
210+
raise ValueError("NCM response contains no results")
211+
212+
result = results[0]
213+
water_table_depth = result["location"]["water_table_depth"]
214+
215+
depths = np.array(data["request"]["depths"]["depth_vector"], dtype=float)
216+
vs = np.array(result["profile"]["vs"], dtype=float)
217+
density = np.array(result["profile"]["density"], dtype=float)
218+
unit_wt = density * 9.81 / 1000
219+
220+
df = pd.DataFrame({"depth": depths, "vel_shear": vs, "unit_wt": unit_wt})
221+
222+
if simplify:
223+
df = _simplify_profile(df, simplify_tol)
224+
225+
# Compute mean effective stress
226+
df["mean_eff_stress"] = calc_mean_eff_stress(
227+
df["depth"].values, df["unit_wt"].values, water_table_depth
228+
)
229+
df["thickness"] = np.diff(df["depth"], append=df["depth"].iloc[-1])
230+
231+
return df, water_table_depth
232+
233+
234+
def _simplify_profile(
235+
df: pd.DataFrame,
236+
tol: float,
237+
) -> pd.DataFrame:
238+
"""Merge adjacent layers with similar shear-wave velocity.
239+
240+
Within each group of consecutive layers whose ``vel_shear`` values are
241+
within *tol* of the first layer in the group, the time-averaged shear-wave
242+
velocity and the thickness-weighted average unit weight are computed.
243+
244+
Parameters
245+
----------
246+
df : pandas.DataFrame
247+
Must contain ``depth``, ``vel_shear``, and ``unit_wt`` columns.
248+
tol : float
249+
Relative tolerance for grouping (e.g. 0.02 = 2 %).
250+
251+
Returns
252+
-------
253+
pandas.DataFrame
254+
Simplified profile with the same columns.
255+
"""
256+
depths = df["depth"].values
257+
vs = df["vel_shear"].values
258+
unit_wt = df["unit_wt"].values
259+
260+
# Compute layer thicknesses (last layer gets 0)
261+
thicknesses = np.diff(depths, append=depths[-1])
262+
263+
groups: list[list[int]] = []
264+
current_group: list[int] = [0]
265+
266+
for i in range(1, len(vs)):
267+
ref_vs = vs[current_group[0]]
268+
if abs(vs[i] - ref_vs) / ref_vs <= tol:
269+
current_group.append(i)
270+
else:
271+
groups.append(current_group)
272+
current_group = [i]
273+
groups.append(current_group)
274+
275+
rows = []
276+
for group in groups:
277+
depth_top = depths[group[0]]
278+
thick = thicknesses[group]
279+
total_thick = thick.sum()
280+
281+
if total_thick > 0:
282+
# Time-average velocity: Vs_avg = total_thickness / sum(thickness_i / Vs_i)
283+
vs_avg = total_thick / np.sum(thick / vs[group])
284+
else:
285+
# Halfspace (last layer)
286+
vs_avg = vs[group[0]]
287+
288+
unit_wt_avg = np.average(unit_wt[group], weights=np.maximum(thick, 1e-10))
289+
rows.append({"depth": depth_top, "vel_shear": vs_avg, "unit_wt": unit_wt_avg})
290+
291+
return pd.DataFrame(rows)

src/pystrata/site.py

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1661,6 +1661,20 @@ def strain(self):
16611661

16621662
return value
16631663

1664+
def _compute_damping(self, strain) -> float:
1665+
"""Compute layer-adjusted damping at the given strain.
1666+
1667+
The soil type's minimum damping is replaced with the layer-specific
1668+
minimum damping.
1669+
"""
1670+
try:
1671+
damping = self.soil_type.damping(strain)
1672+
damping -= self.soil_type.damping_min
1673+
except TypeError:
1674+
damping = 0.0
1675+
1676+
return damping + self.damping_min
1677+
16641678
@strain.setter
16651679
def strain(self, strain):
16661680
if self.soil_type.is_nonlinear:
@@ -1676,19 +1690,22 @@ def strain(self, strain):
16761690

16771691
self._shear_mod.value = self.initial_shear_mod * mod_reduc
16781692

1679-
try:
1680-
# Interpolate the damping at the strain, and then reduce by the
1681-
# minimum damping
1682-
damping = self.soil_type.damping(strain)
1683-
damping -= self.soil_type.damping_min
1684-
except TypeError:
1685-
# No iteration provided by damping
1686-
damping = 0
1693+
# Update the damping value
1694+
self._damping.value = self._compute_damping(strain)
16871695

1688-
# Add the layer-specific minimum damping
1689-
damping += self.damping_min
1696+
@property
1697+
def adjusted_damping_curves(self) -> np.recarray:
1698+
"""Return the damping curve adjusted by the layer-specific minimum damping."""
1699+
1700+
if isinstance(self.soil_type.damping, (float, int)):
1701+
# No iteration provided by damping
1702+
strains = np.asarray([np.nan])
1703+
values = np.asarray([self.damping_min])
1704+
else:
1705+
strains = np.asarray(self.soil_type.damping.strains)
1706+
values = np.array([self._compute_damping(s) for s in strains])
16901707

1691-
self._damping.value = damping
1708+
return np.rec.array((strains, values), names=["strain", "damping"])
16921709

16931710
@property
16941711
def soil_type(self):

src/pystrata/tools.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,3 +468,70 @@ def adjust_damping_values(
468468
profile.reset_layers()
469469

470470
return profile, site_atten_scatter
471+
472+
473+
def calc_mean_eff_stress(
474+
depths: npt.ArrayLike,
475+
unit_wt: npt.ArrayLike,
476+
water_table_depth: float,
477+
k0: float = 0.5,
478+
) -> np.ndarray:
479+
"""Compute mean effective stress at layer midpoints.
480+
481+
Calculates the total vertical stress from the overburden, subtracts pore
482+
water pressure below the water table, and converts to mean effective stress
483+
using the at-rest earth pressure coefficient.
484+
485+
Parameters
486+
----------
487+
depths : array_like
488+
Depth to the top of each layer [m].
489+
unit_wt : array_like
490+
Unit weight of each layer [kN/m³]. Values outside the typical range of
491+
12–30 kN/m³ trigger a warning.
492+
water_table_depth : float
493+
Depth to the water table from the surface [m].
494+
k0 : float, optional
495+
At-rest earth pressure coefficient (default 0.5).
496+
497+
Returns
498+
-------
499+
np.ndarray
500+
Mean effective stress at the midpoint of each layer [kN/m²].
501+
502+
Warns
503+
-----
504+
UserWarning
505+
If any ``unit_wt`` values fall outside 12–30 kN/m³, which may indicate
506+
incorrect units (e.g., kg/m³ instead of kN/m³).
507+
"""
508+
import warnings
509+
510+
depths = np.asarray(depths, dtype=float)
511+
unit_wt = np.asarray(unit_wt, dtype=float)
512+
513+
if np.any((unit_wt < 10) | (unit_wt > 30)):
514+
warnings.warn(
515+
"Some unit_wt values are outside the typical range of 10–30 kN/m³. "
516+
"Ensure values are in kN/m³ (not kg/m³ or pcf).",
517+
stacklevel=2,
518+
)
519+
520+
UNIT_WT_WATER = 9.81 # kN/m³
521+
522+
# Layer thicknesses (last layer = halfspace with 0 thickness)
523+
thicknesses = np.diff(depths, append=depths[-1])
524+
525+
# Total vertical stress at layer midpoints
526+
depth_mids = depths + thicknesses / 2
527+
stress_increments = unit_wt * thicknesses
528+
stress_vert_total = np.cumsum(stress_increments) - stress_increments / 2
529+
530+
# Pore water pressure (zero above water table)
531+
pore_pressure = np.maximum(0.0, UNIT_WT_WATER * (depth_mids - water_table_depth))
532+
533+
# Effective vertical stress -> mean effective stress
534+
stress_vert_eff = stress_vert_total - pore_pressure
535+
stress_mean = stress_vert_eff * (1 + 2 * k0) / 3
536+
537+
return stress_mean

0 commit comments

Comments
 (0)