cfs-python 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- app_panel.py +1058 -0
- cfs_lib/__init__.py +0 -0
- cfs_lib/coulomb_math.py +142 -0
- cfs_lib/io_parser.py +165 -0
- cfs_lib/main.py +173 -0
- cfs_lib/okada_math.py +652 -0
- cfs_lib/okada_wrapper.py +148 -0
- cfs_python-0.1.0.dist-info/METADATA +51 -0
- cfs_python-0.1.0.dist-info/RECORD +11 -0
- cfs_python-0.1.0.dist-info/WHEEL +5 -0
- cfs_python-0.1.0.dist-info/top_level.txt +2 -0
app_panel.py
ADDED
|
@@ -0,0 +1,1058 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Coulomb Stress Change – Interactive Panel Dashboard
|
|
3
|
+
====================================================
|
|
4
|
+
Run with: panel serve app_panel.py --show
|
|
5
|
+
"""
|
|
6
|
+
import io, os, tempfile
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pandas as pd
|
|
9
|
+
import panel as pn
|
|
10
|
+
import plotly.graph_objects as go
|
|
11
|
+
import geopandas as gpd
|
|
12
|
+
from shapely.geometry import Point
|
|
13
|
+
import rasterio
|
|
14
|
+
from rasterio.transform import from_bounds
|
|
15
|
+
|
|
16
|
+
from cfs_lib.io_parser import open_input_file_cui, open_batch_file
|
|
17
|
+
from cfs_lib.okada_wrapper import okada_elastic_halfspace
|
|
18
|
+
from cfs_lib.coulomb_math import calc_coulomb
|
|
19
|
+
|
|
20
|
+
pn.extension("plotly", sizing_mode="stretch_width", theme="dark")
|
|
21
|
+
os.makedirs("cache", exist_ok=True)
|
|
22
|
+
|
|
23
|
+
# =====================================================================
|
|
24
|
+
# STATE
|
|
25
|
+
# =====================================================================
|
|
26
|
+
STATE = {
|
|
27
|
+
"el": None, # fault elements (n, 9)
|
|
28
|
+
"kode": None,
|
|
29
|
+
"pois": 0.25,
|
|
30
|
+
"young": 8e5,
|
|
31
|
+
"fric": 0.4,
|
|
32
|
+
"cdepth": 5.0,
|
|
33
|
+
"xvec": None, # 1-D grid vectors
|
|
34
|
+
"yvec": None,
|
|
35
|
+
"dc3d": None, # full output (ncell, 14)
|
|
36
|
+
"shear": None,
|
|
37
|
+
"normal_stress": None,
|
|
38
|
+
"coulomb_stress": None,
|
|
39
|
+
"nx": 0,
|
|
40
|
+
"ny": 0,
|
|
41
|
+
"xsec_data": None, # Store cross-section dataframe for download
|
|
42
|
+
"click_state": "A", # Which point (A or B) to set next on map click
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# =====================================================================
|
|
46
|
+
# WIDGETS – Sidebar
|
|
47
|
+
# =====================================================================
|
|
48
|
+
file_input = pn.widgets.FileInput(name="Source file (.inp)", accept=".inp,.dat", multiple=False)
|
|
49
|
+
batch_input = pn.widgets.FileInput(name="Batch / Single-point file (.dat)", accept=".dat,.txt", multiple=False)
|
|
50
|
+
|
|
51
|
+
calc_mode = pn.widgets.Select(
|
|
52
|
+
name="Calculation Mode",
|
|
53
|
+
options={
|
|
54
|
+
"Grid – Coulomb Stress": "coulomb",
|
|
55
|
+
"Grid – Deformation": "deformation",
|
|
56
|
+
"Batch (file-based)": "batch",
|
|
57
|
+
"Single Target Point": "single",
|
|
58
|
+
},
|
|
59
|
+
value="coulomb",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Grid limits
|
|
63
|
+
grid_xmin = pn.widgets.FloatInput(name="X min (km)", value=-50., step=1.)
|
|
64
|
+
grid_xmax = pn.widgets.FloatInput(name="X max (km)", value=50., step=1.)
|
|
65
|
+
grid_ymin = pn.widgets.FloatInput(name="Y min (km)", value=-50., step=1.)
|
|
66
|
+
grid_ymax = pn.widgets.FloatInput(name="Y max (km)", value=50., step=1.)
|
|
67
|
+
grid_inc = pn.widgets.FloatInput(name="Spacing (km)", value=5., step=0.1, start=0.1)
|
|
68
|
+
grid_depth = pn.widgets.FloatInput(name="Depth (km)", value=5., step=0.1)
|
|
69
|
+
|
|
70
|
+
# Receiver angles
|
|
71
|
+
rec_strike = pn.widgets.FloatInput(name="R. Strike (°)", value=30.)
|
|
72
|
+
rec_dip = pn.widgets.FloatInput(name="R. Dip (°)", value=90.)
|
|
73
|
+
rec_rake = pn.widgets.FloatInput(name="R. Rake (°)", value=180.)
|
|
74
|
+
|
|
75
|
+
# Single point
|
|
76
|
+
sp_x = pn.widgets.FloatInput(name="X (km)", value=0.)
|
|
77
|
+
sp_y = pn.widgets.FloatInput(name="Y (km)", value=0.)
|
|
78
|
+
sp_z = pn.widgets.FloatInput(name="Z (km)", value=5.)
|
|
79
|
+
sp_strike = pn.widgets.FloatInput(name="Strike (°)", value=30.)
|
|
80
|
+
sp_dip = pn.widgets.FloatInput(name="Dip (°)", value=90.)
|
|
81
|
+
sp_rake = pn.widgets.FloatInput(name="Rake (°)", value=180.)
|
|
82
|
+
|
|
83
|
+
run_btn = pn.widgets.Button(name="▶ Run Calculation", button_type="primary",
|
|
84
|
+
sizing_mode="stretch_width")
|
|
85
|
+
status_md = pn.pane.Markdown("**Status:** waiting for input file",
|
|
86
|
+
styles={"color": "#999"})
|
|
87
|
+
|
|
88
|
+
# =====================================================================
|
|
89
|
+
# DISPLAY WIDGETS – Main area
|
|
90
|
+
# =====================================================================
|
|
91
|
+
VAR_OPTIONS = ["ux", "uy", "uz",
|
|
92
|
+
"sxx", "syy", "szz", "sxy", "sxz", "syz",
|
|
93
|
+
"shear", "normal", "coulomb"]
|
|
94
|
+
COLORSCALES = [
|
|
95
|
+
"Jet", "Viridis", "Plasma", "Inferno", "Rainbow", "Cividis",
|
|
96
|
+
"balance", "RdBu", "PiYG", "PRGn", "BrBG", "PuOr"
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
plot_var = pn.widgets.Select(name="Plot variable", options=VAR_OPTIONS, value="coulomb")
|
|
100
|
+
map_cmap = pn.widgets.Select(name="Color Map", options=COLORSCALES, value="Jet", width=120)
|
|
101
|
+
map_vmin = pn.widgets.FloatInput(name="Min value", value=None, width=100)
|
|
102
|
+
map_vmax = pn.widgets.FloatInput(name="Max value", value=None, width=100)
|
|
103
|
+
toggle_3d = pn.widgets.Checkbox(name="3D view", value=False)
|
|
104
|
+
refresh_btn = pn.widgets.Button(name="🔄 Refresh Map", button_type="warning", width=150)
|
|
105
|
+
|
|
106
|
+
map_pane = pn.pane.Plotly(go.Figure(), height=650, sizing_mode="stretch_width")
|
|
107
|
+
|
|
108
|
+
# --- Cross-section widgets ---
|
|
109
|
+
xsec_ax = pn.widgets.FloatInput(name="A → X", value=-30., step=1.)
|
|
110
|
+
xsec_ay = pn.widgets.FloatInput(name="A → Y", value=0., step=1.)
|
|
111
|
+
xsec_bx = pn.widgets.FloatInput(name="B → X", value=30., step=1.)
|
|
112
|
+
xsec_by = pn.widgets.FloatInput(name="B → Y", value=0., step=1.)
|
|
113
|
+
xsec_zmin = pn.widgets.FloatInput(name="Depth min (km)", value=0., step=0.5)
|
|
114
|
+
xsec_zmax = pn.widgets.FloatInput(name="Depth max (km)", value=20., step=0.5)
|
|
115
|
+
xsec_zinc = pn.widgets.FloatInput(name="Depth step (km)", value=1., step=0.1, start=0.1)
|
|
116
|
+
xsec_npts = pn.widgets.IntInput(name="Points along line", value=50, step=5, start=5)
|
|
117
|
+
xsec_var = pn.widgets.Select(name="X-sec variable", options=VAR_OPTIONS, value="coulomb")
|
|
118
|
+
xsec_btn = pn.widgets.Button(name="▶ Compute Cross-Section", button_type="success",
|
|
119
|
+
sizing_mode="stretch_width")
|
|
120
|
+
xsec_cmap = pn.widgets.Select(name="Color Map", options=COLORSCALES, value="Jet", width=120)
|
|
121
|
+
xsec_vmin = pn.widgets.FloatInput(name="Min value", value=None, width=100)
|
|
122
|
+
xsec_vmax = pn.widgets.FloatInput(name="Max value", value=None, width=100)
|
|
123
|
+
|
|
124
|
+
xsec_pane = pn.pane.Plotly(go.Figure(), height=500, sizing_mode="stretch_width")
|
|
125
|
+
xsec_status = pn.pane.Markdown("")
|
|
126
|
+
xsec_download = pn.widgets.FileDownload(
|
|
127
|
+
label="⬇️ Download X-Sec Data (.csv)",
|
|
128
|
+
filename="cross_section.csv",
|
|
129
|
+
button_type="primary"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# Table
|
|
133
|
+
table_pane = pn.pane.DataFrame(pd.DataFrame(), sizing_mode="stretch_width",
|
|
134
|
+
max_rows=200, height=500)
|
|
135
|
+
|
|
136
|
+
coord_sys = pn.widgets.RadioBoxGroup(name="Coordinate System", options=["Local (km)", "Lat/Lon"], value="Local (km)", inline=True)
|
|
137
|
+
save_format = pn.widgets.Select(name="Save Format", options=["CSV", "SHP", "TIF"], value="CSV", width=100)
|
|
138
|
+
save_crs = pn.widgets.Select(name="Save Map CRS", options=["Local (km)", "Lat/Lon"], value="Local (km)", width=100)
|
|
139
|
+
|
|
140
|
+
map_download = pn.widgets.FileDownload(
|
|
141
|
+
label="⬇️ Download Map Data",
|
|
142
|
+
filename="coulomb_out.csv",
|
|
143
|
+
button_type="success"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# =====================================================================
|
|
147
|
+
# HELPERS
|
|
148
|
+
# =====================================================================
|
|
149
|
+
def _save_upload(widget, prefix=""):
|
|
150
|
+
if widget.value is None:
|
|
151
|
+
return None
|
|
152
|
+
path = os.path.join("cache", prefix + widget.filename)
|
|
153
|
+
with open(path, "wb") as f:
|
|
154
|
+
f.write(widget.value)
|
|
155
|
+
return path
|
|
156
|
+
|
|
157
|
+
def _set_status(msg, error=False):
|
|
158
|
+
color = "red" if error else "#4fc3f7"
|
|
159
|
+
status_md.object = f"**Status:** <span style='color:{color}'>{msg}</span>"
|
|
160
|
+
|
|
161
|
+
COL_MAP = {"ux": 5, "uy": 6, "uz": 7,
|
|
162
|
+
"sxx": 8, "syy": 9, "szz": 10,
|
|
163
|
+
"syz": 11, "sxz": 12, "sxy": 13}
|
|
164
|
+
|
|
165
|
+
def _get_vals(var, dc3d=None, shear=None, normal=None, coulomb=None):
|
|
166
|
+
"""Return a 1-D array of the chosen variable from dc3d / stress arrays."""
|
|
167
|
+
if dc3d is None:
|
|
168
|
+
return None
|
|
169
|
+
if var in COL_MAP:
|
|
170
|
+
return dc3d[:, COL_MAP[var]]
|
|
171
|
+
elif var == "shear" and shear is not None:
|
|
172
|
+
return shear
|
|
173
|
+
elif var == "normal" and normal is not None:
|
|
174
|
+
return normal
|
|
175
|
+
elif var == "coulomb" and coulomb is not None:
|
|
176
|
+
return coulomb
|
|
177
|
+
return dc3d[:, 5] # fallback ux
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _draw_faults_2d(fig, el):
|
|
181
|
+
if el is None:
|
|
182
|
+
return
|
|
183
|
+
for i, f in enumerate(el):
|
|
184
|
+
fig.add_trace(go.Scatter(
|
|
185
|
+
x=[f[0], f[2]], y=[f[1], f[3]],
|
|
186
|
+
mode="lines+markers",
|
|
187
|
+
line=dict(color="red", width=3),
|
|
188
|
+
marker=dict(size=5, color="red"),
|
|
189
|
+
name=f"Fault {i}",
|
|
190
|
+
showlegend=(i == 0),
|
|
191
|
+
legendgroup="faults",
|
|
192
|
+
))
|
|
193
|
+
|
|
194
|
+
def _draw_faults_3d(fig, el):
|
|
195
|
+
if el is None:
|
|
196
|
+
return
|
|
197
|
+
for i, f in enumerate(el):
|
|
198
|
+
xs, ys, xf, yf = f[0], f[1], f[2], f[3]
|
|
199
|
+
dip = f[6]; top, bot = f[7], f[8]
|
|
200
|
+
dip_rad = np.radians(dip)
|
|
201
|
+
h = (bot - top) / np.tan(dip_rad) if abs(dip) != 90 else 0
|
|
202
|
+
dx, dy = xf - xs, yf - ys
|
|
203
|
+
length = np.sqrt(dx**2 + dy**2)
|
|
204
|
+
if length == 0:
|
|
205
|
+
continue
|
|
206
|
+
nx_v, ny_v = dy / length, -dx / length
|
|
207
|
+
xsb, ysb = xs + nx_v * h, ys + ny_v * h
|
|
208
|
+
xfb, yfb = xf + nx_v * h, yf + ny_v * h
|
|
209
|
+
fig.add_trace(go.Scatter3d(
|
|
210
|
+
x=[xs, xf, xfb, xsb, xs],
|
|
211
|
+
y=[ys, yf, yfb, ysb, ys],
|
|
212
|
+
z=[top, top, bot, bot, top],
|
|
213
|
+
mode="lines",
|
|
214
|
+
line=dict(color="red", width=5),
|
|
215
|
+
name=f"Fault {i}",
|
|
216
|
+
showlegend=(i == 0),
|
|
217
|
+
legendgroup="faults",
|
|
218
|
+
))
|
|
219
|
+
|
|
220
|
+
def km_to_deg(dx_km, dy_km, lon0, lat0):
|
|
221
|
+
"""Convert local dx/dy (km) to lat/lon based on reference point."""
|
|
222
|
+
lat = lat0 + (dy_km / 111.32)
|
|
223
|
+
# 1 degree of longitude = 111.32 * cos(lat0) km
|
|
224
|
+
lon_deg_per_km = 1.0 / (111.32 * np.cos(np.radians(lat0))) if np.abs(lat0) < 89 else 0
|
|
225
|
+
lon = lon0 + (dx_km * lon_deg_per_km)
|
|
226
|
+
return lon, lat
|
|
227
|
+
|
|
228
|
+
def deg_to_km(lon, lat, lon0, lat0):
|
|
229
|
+
"""Convert lat/lon to local dx/dy (km) based on reference point."""
|
|
230
|
+
dy_km = (lat - lat0) * 111.32
|
|
231
|
+
dx_km = (lon - lon0) * (111.32 * np.cos(np.radians(lat0)))
|
|
232
|
+
return dx_km, dy_km
|
|
233
|
+
|
|
234
|
+
def _on_coord_sys_change(event):
|
|
235
|
+
is_latlon = (event.new == "Lat/Lon")
|
|
236
|
+
map_info = STATE.get("map_info", {})
|
|
237
|
+
lon0 = map_info.get("zero_lon", 0.0)
|
|
238
|
+
lat0 = map_info.get("zero_lat", 0.0)
|
|
239
|
+
|
|
240
|
+
# Update widget names
|
|
241
|
+
unit = "°" if is_latlon else "km"
|
|
242
|
+
grid_xmin.name = f"X min ({unit})"
|
|
243
|
+
grid_xmax.name = f"X max ({unit})"
|
|
244
|
+
grid_ymin.name = f"Y min ({unit})"
|
|
245
|
+
grid_ymax.name = f"Y max ({unit})"
|
|
246
|
+
grid_inc.name = f"Spacing ({unit})"
|
|
247
|
+
xsec_ax.name = f"A → X ({unit})"
|
|
248
|
+
xsec_ay.name = f"A → Y ({unit})"
|
|
249
|
+
xsec_bx.name = f"B → X ({unit})"
|
|
250
|
+
xsec_by.name = f"B → Y ({unit})"
|
|
251
|
+
sp_x.name = f"X ({unit})"
|
|
252
|
+
sp_y.name = f"Y ({unit})"
|
|
253
|
+
|
|
254
|
+
# Simple conversion of current values
|
|
255
|
+
if lon0 != 0.0 or lat0 != 0.0:
|
|
256
|
+
if is_latlon:
|
|
257
|
+
# Convert km -> deg
|
|
258
|
+
grid_xmin.value, grid_ymin.value = km_to_deg(grid_xmin.value, grid_ymin.value, lon0, lat0)
|
|
259
|
+
grid_xmax.value, grid_ymax.value = km_to_deg(grid_xmax.value, grid_ymax.value, lon0, lat0)
|
|
260
|
+
grid_inc.value = grid_inc.value / 111.32 # Rough spacing
|
|
261
|
+
xsec_ax.value, xsec_ay.value = km_to_deg(xsec_ax.value, xsec_ay.value, lon0, lat0)
|
|
262
|
+
xsec_bx.value, xsec_by.value = km_to_deg(xsec_bx.value, xsec_by.value, lon0, lat0)
|
|
263
|
+
sp_x.value, sp_y.value = km_to_deg(sp_x.value, sp_y.value, lon0, lat0)
|
|
264
|
+
else:
|
|
265
|
+
# Convert deg -> km
|
|
266
|
+
grid_xmin.value, grid_ymin.value = deg_to_km(grid_xmin.value, grid_ymin.value, lon0, lat0)
|
|
267
|
+
grid_xmax.value, grid_ymax.value = deg_to_km(grid_xmax.value, grid_ymax.value, lon0, lat0)
|
|
268
|
+
grid_inc.value = grid_inc.value * 111.32
|
|
269
|
+
xsec_ax.value, xsec_ay.value = deg_to_km(xsec_ax.value, xsec_ay.value, lon0, lat0)
|
|
270
|
+
xsec_bx.value, xsec_by.value = deg_to_km(xsec_bx.value, xsec_by.value, lon0, lat0)
|
|
271
|
+
sp_x.value, sp_y.value = deg_to_km(sp_x.value, sp_y.value, lon0, lat0)
|
|
272
|
+
|
|
273
|
+
coord_sys.param.watch(_on_coord_sys_change, "value")
|
|
274
|
+
|
|
275
|
+
# =====================================================================
|
|
276
|
+
# CORE – parse source file on upload
|
|
277
|
+
# =====================================================================
|
|
278
|
+
def _on_file_upload(event):
|
|
279
|
+
path = _save_upload(file_input)
|
|
280
|
+
if path is None:
|
|
281
|
+
return
|
|
282
|
+
try:
|
|
283
|
+
xvec, yvec, z, el, kode, pois, young, cdepth, fric, _, map_info, cross_section = open_input_file_cui(path)
|
|
284
|
+
except Exception as exc:
|
|
285
|
+
import traceback; traceback.print_exc()
|
|
286
|
+
_set_status(f"Parse error: {exc}", error=True)
|
|
287
|
+
return
|
|
288
|
+
|
|
289
|
+
STATE.update(el=el, kode=kode, pois=pois, young=young,
|
|
290
|
+
fric=fric, cdepth=cdepth, xvec=xvec, yvec=yvec,
|
|
291
|
+
dc3d=None, shear=None, normal_stress=None, coulomb_stress=None,
|
|
292
|
+
map_info=map_info)
|
|
293
|
+
|
|
294
|
+
# Force Local (km) when new file loads
|
|
295
|
+
coord_sys.value = "Local (km)"
|
|
296
|
+
grid_xmin.name = "X min (km)"
|
|
297
|
+
grid_xmax.name = "X max (km)"
|
|
298
|
+
grid_ymin.name = "Y min (km)"
|
|
299
|
+
grid_ymax.name = "Y max (km)"
|
|
300
|
+
grid_inc.name = "Spacing (km)"
|
|
301
|
+
|
|
302
|
+
grid_xmin.value = float(xvec[0])
|
|
303
|
+
grid_xmax.value = float(xvec[-1])
|
|
304
|
+
grid_ymin.value = float(yvec[0])
|
|
305
|
+
grid_ymax.value = float(yvec[-1])
|
|
306
|
+
if len(xvec) > 1:
|
|
307
|
+
grid_inc.value = float(xvec[1] - xvec[0])
|
|
308
|
+
grid_depth.value = float(cdepth)
|
|
309
|
+
|
|
310
|
+
# Load default cross section if available
|
|
311
|
+
if cross_section:
|
|
312
|
+
if "start_x" in cross_section: xsec_ax.value = float(cross_section["start_x"])
|
|
313
|
+
if "start_y" in cross_section: xsec_ay.value = float(cross_section["start_y"])
|
|
314
|
+
if "finish_x" in cross_section: xsec_bx.value = float(cross_section["finish_x"])
|
|
315
|
+
if "finish_y" in cross_section: xsec_by.value = float(cross_section["finish_y"])
|
|
316
|
+
|
|
317
|
+
_set_status(f"Parsed {len(el)} fault segments from {file_input.filename}")
|
|
318
|
+
_refresh_map()
|
|
319
|
+
|
|
320
|
+
file_input.param.watch(_on_file_upload, "value")
|
|
321
|
+
|
|
322
|
+
# =====================================================================
|
|
323
|
+
# CORE – run calculation
|
|
324
|
+
# =====================================================================
|
|
325
|
+
def _on_run(event):
|
|
326
|
+
if STATE["el"] is None:
|
|
327
|
+
_set_status("Upload a source file first.", error=True)
|
|
328
|
+
return
|
|
329
|
+
|
|
330
|
+
mode = calc_mode.value
|
|
331
|
+
el = STATE["el"]
|
|
332
|
+
kode = STATE["kode"]
|
|
333
|
+
pois = STATE["pois"]
|
|
334
|
+
young = STATE["young"]
|
|
335
|
+
fric = STATE["fric"]
|
|
336
|
+
|
|
337
|
+
_set_status("Computing … please wait")
|
|
338
|
+
|
|
339
|
+
try:
|
|
340
|
+
is_latlon = (coord_sys.value == "Lat/Lon")
|
|
341
|
+
map_info = STATE.get("map_info") or {}
|
|
342
|
+
lon0 = map_info.get("zero_lon", 0.0) if map_info else 0.0
|
|
343
|
+
lat0 = map_info.get("zero_lat", 0.0) if map_info else 0.0
|
|
344
|
+
|
|
345
|
+
if mode in ("coulomb", "deformation"):
|
|
346
|
+
xvec_in = np.arange(grid_xmin.value,
|
|
347
|
+
grid_xmax.value + grid_inc.value * 0.5,
|
|
348
|
+
grid_inc.value)
|
|
349
|
+
yvec_in = np.arange(grid_ymin.value,
|
|
350
|
+
grid_ymax.value + grid_inc.value * 0.5,
|
|
351
|
+
grid_inc.value)
|
|
352
|
+
cdepth = grid_depth.value
|
|
353
|
+
|
|
354
|
+
if is_latlon and (lon0 != 0.0 or lat0 != 0.0):
|
|
355
|
+
xvec_km, _ = deg_to_km(xvec_in, np.full_like(xvec_in, lat0), lon0, lat0)
|
|
356
|
+
_, yvec_km = deg_to_km(np.full_like(yvec_in, lon0), yvec_in, lon0, lat0)
|
|
357
|
+
xvec, yvec = xvec_km, yvec_km
|
|
358
|
+
else:
|
|
359
|
+
xvec, yvec = xvec_in, yvec_in
|
|
360
|
+
|
|
361
|
+
STATE["xvec"] = xvec
|
|
362
|
+
STATE["yvec"] = yvec
|
|
363
|
+
STATE["cdepth"] = cdepth
|
|
364
|
+
STATE["nx"] = len(xvec)
|
|
365
|
+
STATE["ny"] = len(yvec)
|
|
366
|
+
|
|
367
|
+
dc3d = okada_elastic_halfspace(xvec, yvec, el, young, pois, cdepth, kode)
|
|
368
|
+
STATE["dc3d"] = dc3d
|
|
369
|
+
|
|
370
|
+
if mode == "coulomb":
|
|
371
|
+
n = dc3d.shape[0]
|
|
372
|
+
ss = dc3d[:, 8:14].T
|
|
373
|
+
s, nt, c = calc_coulomb(
|
|
374
|
+
rec_strike.value * np.ones(n),
|
|
375
|
+
rec_dip.value * np.ones(n),
|
|
376
|
+
rec_rake.value * np.ones(n),
|
|
377
|
+
fric * np.ones(n), ss)
|
|
378
|
+
STATE["shear"] = s
|
|
379
|
+
STATE["normal_stress"] = nt
|
|
380
|
+
STATE["coulomb_stress"] = c
|
|
381
|
+
else:
|
|
382
|
+
STATE["shear"] = STATE["normal_stress"] = STATE["coulomb_stress"] = None
|
|
383
|
+
|
|
384
|
+
elif mode == "batch":
|
|
385
|
+
bpath = _save_upload(batch_input, prefix="batch_")
|
|
386
|
+
if bpath is None:
|
|
387
|
+
_set_status("Upload a batch file", error=True)
|
|
388
|
+
return
|
|
389
|
+
pos, strike_m, dip_m, rake_m = open_batch_file(bpath)
|
|
390
|
+
x_g, y_g, z_g = pos[:, 0], pos[:, 1], pos[:, 2]
|
|
391
|
+
|
|
392
|
+
if is_latlon and (lon0 != 0.0 or lat0 != 0.0):
|
|
393
|
+
x_g, y_g = deg_to_km(x_g, y_g, lon0, lat0)
|
|
394
|
+
|
|
395
|
+
dc3d = okada_elastic_halfspace(x_g, y_g, el, young, pois, -z_g, kode)
|
|
396
|
+
STATE["dc3d"] = dc3d
|
|
397
|
+
STATE["nx"] = STATE["ny"] = 0
|
|
398
|
+
ss = dc3d[:, 8:14].T
|
|
399
|
+
s, nt, c = calc_coulomb(strike_m, dip_m, rake_m,
|
|
400
|
+
np.full(pos.shape[0], fric), ss)
|
|
401
|
+
STATE["shear"] = s
|
|
402
|
+
STATE["normal_stress"] = nt
|
|
403
|
+
STATE["coulomb_stress"] = c
|
|
404
|
+
|
|
405
|
+
elif mode == "single":
|
|
406
|
+
if is_latlon and (lon0 != 0.0 or lat0 != 0.0):
|
|
407
|
+
x_km, y_km = deg_to_km(np.array([sp_x.value]), np.array([sp_y.value]), lon0, lat0)
|
|
408
|
+
x_g, y_g = x_km, y_km
|
|
409
|
+
else:
|
|
410
|
+
x_g = np.array([sp_x.value])
|
|
411
|
+
y_g = np.array([sp_y.value])
|
|
412
|
+
z_g = np.array([sp_z.value])
|
|
413
|
+
dc3d = okada_elastic_halfspace(x_g, y_g, el, young, pois, -z_g, kode)
|
|
414
|
+
STATE["dc3d"] = dc3d
|
|
415
|
+
STATE["nx"] = STATE["ny"] = 0
|
|
416
|
+
ss = dc3d[:, 8:14].T
|
|
417
|
+
s, nt, c = calc_coulomb(
|
|
418
|
+
np.array([sp_strike.value]),
|
|
419
|
+
np.array([sp_dip.value]),
|
|
420
|
+
np.array([sp_rake.value]),
|
|
421
|
+
np.full(1, fric), ss)
|
|
422
|
+
STATE["shear"] = s
|
|
423
|
+
STATE["normal_stress"] = nt
|
|
424
|
+
STATE["coulomb_stress"] = c
|
|
425
|
+
|
|
426
|
+
_set_status(f"Done – {dc3d.shape[0]} points computed")
|
|
427
|
+
_refresh_map()
|
|
428
|
+
_refresh_table()
|
|
429
|
+
_update_map_download()
|
|
430
|
+
|
|
431
|
+
except Exception as exc:
|
|
432
|
+
import traceback; traceback.print_exc()
|
|
433
|
+
_set_status(f"Error: {exc}", error=True)
|
|
434
|
+
|
|
435
|
+
run_btn.on_click(_on_run)
|
|
436
|
+
|
|
437
|
+
# =====================================================================
|
|
438
|
+
# MAP RENDER
|
|
439
|
+
# =====================================================================
|
|
440
|
+
def _refresh_map(*_):
|
|
441
|
+
fig = go.Figure()
|
|
442
|
+
dc3d = STATE.get("dc3d")
|
|
443
|
+
el = STATE.get("el")
|
|
444
|
+
is3d = toggle_3d.value
|
|
445
|
+
is_latlon = (coord_sys.value == "Lat/Lon")
|
|
446
|
+
|
|
447
|
+
map_info = STATE.get("map_info", {})
|
|
448
|
+
lon0 = map_info.get("zero_lon", 0.0)
|
|
449
|
+
lat0 = map_info.get("zero_lat", 0.0)
|
|
450
|
+
|
|
451
|
+
def _convert_pts(xs, ys):
|
|
452
|
+
if is_latlon and (lon0 != 0.0 or lat0 != 0.0):
|
|
453
|
+
return km_to_deg(np.array(xs), np.array(ys), lon0, lat0)
|
|
454
|
+
return xs, ys
|
|
455
|
+
|
|
456
|
+
# ---- faults ----
|
|
457
|
+
if is3d:
|
|
458
|
+
if el is not None:
|
|
459
|
+
for i, f in enumerate(el):
|
|
460
|
+
xs, ys, xf, yf = f[0], f[1], f[2], f[3]
|
|
461
|
+
dip = f[6]; top, bot = f[7], f[8]
|
|
462
|
+
dip_rad = np.radians(dip)
|
|
463
|
+
h = (bot - top) / np.tan(dip_rad) if abs(dip) != 90 else 0
|
|
464
|
+
dx, dy = xf - xs, yf - ys
|
|
465
|
+
length = np.sqrt(dx**2 + dy**2)
|
|
466
|
+
if length > 0:
|
|
467
|
+
nx_v, ny_v = dy / length, -dx / length
|
|
468
|
+
xsb, ysb = xs + nx_v * h, ys + ny_v * h
|
|
469
|
+
xfb, yfb = xf + nx_v * h, yf + ny_v * h
|
|
470
|
+
|
|
471
|
+
px, py = _convert_pts([xs, xf, xfb, xsb, xs], [ys, yf, yfb, ysb, ys])
|
|
472
|
+
fig.add_trace(go.Scatter3d(
|
|
473
|
+
x=px, y=py, z=[top, top, bot, bot, top],
|
|
474
|
+
mode="lines", line=dict(color="red", width=5),
|
|
475
|
+
name=f"Fault {i}", showlegend=(i==0), legendgroup="faults"
|
|
476
|
+
))
|
|
477
|
+
else:
|
|
478
|
+
if el is not None:
|
|
479
|
+
for i, f in enumerate(el):
|
|
480
|
+
px, py = _convert_pts([f[0], f[2]], [f[1], f[3]])
|
|
481
|
+
fig.add_trace(go.Scatter(
|
|
482
|
+
x=px, y=py, mode="lines+markers",
|
|
483
|
+
line=dict(color="red", width=3), marker=dict(size=5, color="red"),
|
|
484
|
+
name=f"Fault {i}", showlegend=(i==0), legendgroup="faults"
|
|
485
|
+
))
|
|
486
|
+
|
|
487
|
+
# ---- computed data ----
|
|
488
|
+
if dc3d is not None:
|
|
489
|
+
vals = _get_vals(plot_var.value, dc3d,
|
|
490
|
+
STATE["shear"], STATE["normal_stress"],
|
|
491
|
+
STATE["coulomb_stress"])
|
|
492
|
+
nx, ny = STATE["nx"], STATE["ny"]
|
|
493
|
+
is_grid = nx > 1 and ny > 1
|
|
494
|
+
|
|
495
|
+
px, py = _convert_pts(dc3d[:, 0], dc3d[:, 1])
|
|
496
|
+
|
|
497
|
+
if is_grid and not is3d:
|
|
498
|
+
xvec = STATE["xvec"]
|
|
499
|
+
yvec = STATE["yvec"]
|
|
500
|
+
vx, vy = _convert_pts(xvec, yvec)
|
|
501
|
+
|
|
502
|
+
Z = np.array(vals).reshape(nx, ny).T # (ny, nx) for Plotly Heatmap
|
|
503
|
+
|
|
504
|
+
kwargs = {}
|
|
505
|
+
if map_vmin.value is not None: kwargs["zmin"] = map_vmin.value
|
|
506
|
+
if map_vmax.value is not None: kwargs["zmax"] = map_vmax.value
|
|
507
|
+
|
|
508
|
+
fig.add_trace(go.Heatmap(
|
|
509
|
+
x=vx, y=vy, z=Z,
|
|
510
|
+
colorscale=map_cmap.value,
|
|
511
|
+
colorbar=dict(title=plot_var.value.upper()),
|
|
512
|
+
**kwargs
|
|
513
|
+
))
|
|
514
|
+
elif is3d:
|
|
515
|
+
marker_args = dict(size=3, color=vals, colorscale=map_cmap.value,
|
|
516
|
+
showscale=True, colorbar=dict(title=plot_var.value.upper()))
|
|
517
|
+
if map_vmin.value is not None: marker_args["cmin"] = map_vmin.value
|
|
518
|
+
if map_vmax.value is not None: marker_args["cmax"] = map_vmax.value
|
|
519
|
+
|
|
520
|
+
fig.add_trace(go.Scatter3d(
|
|
521
|
+
x=px, y=py, z=-dc3d[:, 4],
|
|
522
|
+
mode="markers",
|
|
523
|
+
marker=marker_args,
|
|
524
|
+
name=plot_var.value,
|
|
525
|
+
))
|
|
526
|
+
else:
|
|
527
|
+
marker_args = dict(size=6, color=vals, colorscale=map_cmap.value,
|
|
528
|
+
showscale=True, colorbar=dict(title=plot_var.value.upper()))
|
|
529
|
+
if map_vmin.value is not None: marker_args["cmin"] = map_vmin.value
|
|
530
|
+
if map_vmax.value is not None: marker_args["cmax"] = map_vmax.value
|
|
531
|
+
|
|
532
|
+
fig.add_trace(go.Scatter(
|
|
533
|
+
x=px, y=py,
|
|
534
|
+
mode="markers",
|
|
535
|
+
marker=marker_args,
|
|
536
|
+
name=plot_var.value,
|
|
537
|
+
))
|
|
538
|
+
|
|
539
|
+
# ---- layout ----
|
|
540
|
+
unit_str = "°" if is_latlon else "km"
|
|
541
|
+
xlabel = "Lon (°)" if is_latlon else "X (km)"
|
|
542
|
+
ylabel = "Lat (°)" if is_latlon else "Y (km)"
|
|
543
|
+
|
|
544
|
+
if is3d:
|
|
545
|
+
fig.update_layout(scene=dict(
|
|
546
|
+
xaxis_title=xlabel, yaxis_title=ylabel, zaxis_title="Depth (km)",
|
|
547
|
+
zaxis=dict(autorange="reversed"),
|
|
548
|
+
))
|
|
549
|
+
else:
|
|
550
|
+
fig.update_layout(
|
|
551
|
+
xaxis_title=xlabel, yaxis_title=ylabel,
|
|
552
|
+
xaxis=dict(autorange=True, constrain="domain"),
|
|
553
|
+
yaxis=dict(autorange=True, constrain="domain"),
|
|
554
|
+
clickmode="event+select"
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
# ---- invisible click-catcher so clicks work anywhere on map ----
|
|
558
|
+
has_grid = (dc3d is not None
|
|
559
|
+
and STATE.get("nx", 0) > 1
|
|
560
|
+
and STATE.get("ny", 0) > 1
|
|
561
|
+
and not is3d)
|
|
562
|
+
if not is3d and not has_grid:
|
|
563
|
+
xmin = grid_xmin.value or -50
|
|
564
|
+
xmax = grid_xmax.value or 50
|
|
565
|
+
ymin = grid_ymin.value or -50
|
|
566
|
+
ymax = grid_ymax.value or 50
|
|
567
|
+
nclick = 51
|
|
568
|
+
|
|
569
|
+
cx = np.linspace(xmin, xmax, nclick)
|
|
570
|
+
cy = np.linspace(ymin, ymax, nclick)
|
|
571
|
+
gx, gy = [], []
|
|
572
|
+
for xi in cx:
|
|
573
|
+
for yi in cy:
|
|
574
|
+
gx.append(xi)
|
|
575
|
+
gy.append(yi)
|
|
576
|
+
fig.add_trace(go.Scatter(
|
|
577
|
+
x=gx, y=gy,
|
|
578
|
+
mode="markers",
|
|
579
|
+
marker=dict(size=1, opacity=0),
|
|
580
|
+
hoverinfo="x+y",
|
|
581
|
+
showlegend=False,
|
|
582
|
+
name="_click_catcher",
|
|
583
|
+
))
|
|
584
|
+
|
|
585
|
+
# ---- X-section line ----
|
|
586
|
+
if not is3d and xsec_ax.value is not None:
|
|
587
|
+
fig.add_trace(go.Scatter(
|
|
588
|
+
x=[xsec_ax.value, xsec_bx.value], y=[xsec_ay.value, xsec_by.value],
|
|
589
|
+
mode="lines+markers+text",
|
|
590
|
+
line=dict(color="lime", width=3, dash="dash"),
|
|
591
|
+
marker=dict(size=10, color="lime"),
|
|
592
|
+
text=["A", "B"], textposition="top center",
|
|
593
|
+
textfont=dict(color="lime", size=14),
|
|
594
|
+
name="X-Section line",
|
|
595
|
+
hoverinfo="x+y+text"
|
|
596
|
+
))
|
|
597
|
+
|
|
598
|
+
fig.update_layout(
|
|
599
|
+
margin=dict(l=0, r=0, t=30, b=0),
|
|
600
|
+
template="plotly_dark",
|
|
601
|
+
height=650,
|
|
602
|
+
)
|
|
603
|
+
# FIX 2: replace the entire Plotly pane object to avoid dirty 3D scene state
|
|
604
|
+
map_pane.object = fig
|
|
605
|
+
|
|
606
|
+
plot_var.param.watch(_refresh_map, "value")
|
|
607
|
+
map_cmap.param.watch(_refresh_map, "value")
|
|
608
|
+
map_vmin.param.watch(_refresh_map, "value")
|
|
609
|
+
map_vmax.param.watch(_refresh_map, "value")
|
|
610
|
+
toggle_3d.param.watch(_refresh_map, "value")
|
|
611
|
+
refresh_btn.on_click(_refresh_map)
|
|
612
|
+
|
|
613
|
+
# --- Interactive Map Clicking ---
|
|
614
|
+
_click_lock = {"active": False}
|
|
615
|
+
|
|
616
|
+
def _on_map_click(event):
|
|
617
|
+
if _click_lock["active"]:
|
|
618
|
+
return
|
|
619
|
+
if event.new is None or not isinstance(event.new, dict):
|
|
620
|
+
return
|
|
621
|
+
points = event.new.get("points", [])
|
|
622
|
+
if not points:
|
|
623
|
+
return
|
|
624
|
+
|
|
625
|
+
# Only allow 2D map clicks for cross section
|
|
626
|
+
if toggle_3d.value:
|
|
627
|
+
return
|
|
628
|
+
|
|
629
|
+
_click_lock["active"] = True
|
|
630
|
+
try:
|
|
631
|
+
pt = points[0]
|
|
632
|
+
cx = float(pt.get("x", 0))
|
|
633
|
+
cy = float(pt.get("y", 0))
|
|
634
|
+
if STATE["click_state"] == "A":
|
|
635
|
+
xsec_ax.value = round(cx, 2)
|
|
636
|
+
xsec_ay.value = round(cy, 2)
|
|
637
|
+
STATE["click_state"] = "B"
|
|
638
|
+
_set_status(f"Point A set to ({cx:.2f}, {cy:.2f}). Click map for Point B.")
|
|
639
|
+
else:
|
|
640
|
+
xsec_bx.value = round(cx, 2)
|
|
641
|
+
xsec_by.value = round(cy, 2)
|
|
642
|
+
STATE["click_state"] = "A"
|
|
643
|
+
_set_status(f"Point B set to ({cx:.2f}, {cy:.2f}). Ready to compute.")
|
|
644
|
+
# Single refresh after both coords updated
|
|
645
|
+
_refresh_map()
|
|
646
|
+
finally:
|
|
647
|
+
_click_lock["active"] = False
|
|
648
|
+
|
|
649
|
+
map_pane.param.watch(_on_map_click, "click_data")
|
|
650
|
+
|
|
651
|
+
# =====================================================================
|
|
652
|
+
# CROSS-SECTION (depth-vs-distance with recalculation)
|
|
653
|
+
|
|
654
|
+
# =====================================================================
|
|
655
|
+
def _on_xsec(event):
|
|
656
|
+
"""
|
|
657
|
+
1. Sample `npts` points along the line A→B.
|
|
658
|
+
2. For each depth in [zmin, zmax, step], run okada + coulomb.
|
|
659
|
+
3. Plot heatmap: X = distance along profile, Y = depth.
|
|
660
|
+
"""
|
|
661
|
+
if STATE["el"] is None:
|
|
662
|
+
xsec_status.object = "**Upload source file first.**"
|
|
663
|
+
return
|
|
664
|
+
|
|
665
|
+
ax, ay = xsec_ax.value, xsec_ay.value
|
|
666
|
+
bx, by = xsec_bx.value, xsec_by.value
|
|
667
|
+
zmin = xsec_zmin.value
|
|
668
|
+
zmax = xsec_zmax.value
|
|
669
|
+
zinc = xsec_zinc.value
|
|
670
|
+
npts = xsec_npts.value
|
|
671
|
+
var = xsec_var.value
|
|
672
|
+
|
|
673
|
+
el = STATE["el"]
|
|
674
|
+
kode = STATE["kode"]
|
|
675
|
+
is_latlon = (coord_sys.value == "Lat/Lon")
|
|
676
|
+
map_info = STATE.get("map_info") or {}
|
|
677
|
+
lon0 = map_info.get("zero_lon", 0.0) if map_info else 0.0
|
|
678
|
+
lat0 = map_info.get("zero_lat", 0.0) if map_info else 0.0
|
|
679
|
+
|
|
680
|
+
ax, ay = xsec_ax.value, xsec_ay.value
|
|
681
|
+
bx, by = xsec_bx.value, xsec_by.value
|
|
682
|
+
|
|
683
|
+
if is_latlon and (lon0 != 0.0 or lat0 != 0.0):
|
|
684
|
+
ax, ay = deg_to_km(ax, ay, lon0, lat0)
|
|
685
|
+
bx, by = deg_to_km(bx, by, lon0, lat0)
|
|
686
|
+
|
|
687
|
+
t = np.linspace(0, 1, npts)
|
|
688
|
+
px = ax + (bx - ax) * t
|
|
689
|
+
py = ay + (by - ay) * t
|
|
690
|
+
pois = STATE["pois"]
|
|
691
|
+
young = STATE["young"]
|
|
692
|
+
fric = STATE["fric"]
|
|
693
|
+
|
|
694
|
+
# Profile points
|
|
695
|
+
dist = np.sqrt((px - ax)**2 + (py - ay)**2) # distance along profile
|
|
696
|
+
|
|
697
|
+
depths = np.arange(zmin, zmax + zinc * 0.5, zinc)
|
|
698
|
+
ndepth = len(depths)
|
|
699
|
+
|
|
700
|
+
xsec_status.object = f"Computing {npts} × {ndepth} = **{npts * ndepth}** points …"
|
|
701
|
+
|
|
702
|
+
try:
|
|
703
|
+
grid_vals = np.zeros((ndepth, npts))
|
|
704
|
+
|
|
705
|
+
for iz, zval in enumerate(depths):
|
|
706
|
+
cdepth_arr = np.full(npts, zval)
|
|
707
|
+
dc3d = okada_elastic_halfspace(px, py, el, young, pois, cdepth_arr, kode)
|
|
708
|
+
dc_arr = np.array(dc3d)
|
|
709
|
+
|
|
710
|
+
if var in ("shear", "normal", "coulomb"):
|
|
711
|
+
ss = dc_arr[:, 8:14].T
|
|
712
|
+
s, nt, c = calc_coulomb(
|
|
713
|
+
rec_strike.value * np.ones(npts),
|
|
714
|
+
rec_dip.value * np.ones(npts),
|
|
715
|
+
rec_rake.value * np.ones(npts),
|
|
716
|
+
fric * np.ones(npts), ss)
|
|
717
|
+
if var == "shear":
|
|
718
|
+
grid_vals[iz, :] = s
|
|
719
|
+
elif var == "normal":
|
|
720
|
+
grid_vals[iz, :] = nt
|
|
721
|
+
else:
|
|
722
|
+
grid_vals[iz, :] = c
|
|
723
|
+
else:
|
|
724
|
+
grid_vals[iz, :] = _get_vals(var, dc_arr)
|
|
725
|
+
|
|
726
|
+
kwargs = {}
|
|
727
|
+
if xsec_vmin.value is not None: kwargs["zmin"] = xsec_vmin.value
|
|
728
|
+
if xsec_vmax.value is not None: kwargs["zmax"] = xsec_vmax.value
|
|
729
|
+
|
|
730
|
+
fig = go.Figure(go.Heatmap(
|
|
731
|
+
x=dist,
|
|
732
|
+
y=depths,
|
|
733
|
+
z=grid_vals,
|
|
734
|
+
colorscale=xsec_cmap.value,
|
|
735
|
+
colorbar=dict(title=var.upper()),
|
|
736
|
+
**kwargs
|
|
737
|
+
))
|
|
738
|
+
fig.update_layout(
|
|
739
|
+
title=f"Cross-Section A({ax},{ay}) → B({bx},{by})",
|
|
740
|
+
xaxis_title="Distance along profile (km)",
|
|
741
|
+
yaxis_title="Depth (km)",
|
|
742
|
+
yaxis=dict(autorange="reversed"),
|
|
743
|
+
template="plotly_dark",
|
|
744
|
+
height=500,
|
|
745
|
+
margin=dict(l=50, r=20, t=50, b=50),
|
|
746
|
+
clickmode="none"
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
xsec_pane.object = fig
|
|
750
|
+
xsec_status.object = f"✅ Done – {npts * ndepth} points"
|
|
751
|
+
|
|
752
|
+
# Save X-sec dataframe for download
|
|
753
|
+
rows = []
|
|
754
|
+
|
|
755
|
+
# We also want to compute exact spatial coordinates for export
|
|
756
|
+
# If the map is using Lat/Lon we might already have the zero reference converted
|
|
757
|
+
map_info = STATE.get("map_info") or {}
|
|
758
|
+
lon0 = map_info.get("zero_lon", 0.0) if map_info else 0.0
|
|
759
|
+
lat0 = map_info.get("zero_lat", 0.0) if map_info else 0.0
|
|
760
|
+
|
|
761
|
+
has_deg_ref = (lon0 != 0.0 or lat0 != 0.0)
|
|
762
|
+
|
|
763
|
+
if has_deg_ref:
|
|
764
|
+
lon_arr, lat_arr = km_to_deg(px, py, lon0, lat0)
|
|
765
|
+
|
|
766
|
+
# For uniform format, we calculate shear/normal/coulomb for EVERY cross-section point
|
|
767
|
+
# instead of just the plotted `var`.
|
|
768
|
+
for iz, zval in enumerate(depths):
|
|
769
|
+
# Recalculate dc3d to grab raw tensors directly for CSV export
|
|
770
|
+
cdepth_arr = np.full(npts, zval)
|
|
771
|
+
dc_arr = np.array(okada_elastic_halfspace(px, py, el, young, pois, cdepth_arr, kode))
|
|
772
|
+
ss = dc_arr[:, 8:14].T
|
|
773
|
+
s, nt, c = calc_coulomb(
|
|
774
|
+
rec_strike.value * np.ones(npts),
|
|
775
|
+
rec_dip.value * np.ones(npts),
|
|
776
|
+
rec_rake.value * np.ones(npts),
|
|
777
|
+
fric * np.ones(npts), ss)
|
|
778
|
+
|
|
779
|
+
for ip in range(npts):
|
|
780
|
+
r = {
|
|
781
|
+
"X_km": px[ip],
|
|
782
|
+
"Y_km": py[ip],
|
|
783
|
+
"Z_km": dc_arr[ip, 4],
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
if has_deg_ref:
|
|
787
|
+
r["Lon"] = lon_arr[ip]
|
|
788
|
+
r["Lat"] = lat_arr[ip]
|
|
789
|
+
|
|
790
|
+
r.update({
|
|
791
|
+
"ux_m": dc_arr[ip, 5],
|
|
792
|
+
"uy_m": dc_arr[ip, 6],
|
|
793
|
+
"uz_m": dc_arr[ip, 7],
|
|
794
|
+
"sxx_bar": dc_arr[ip, 8],
|
|
795
|
+
"syy_bar": dc_arr[ip, 9],
|
|
796
|
+
"szz_bar": dc_arr[ip, 10],
|
|
797
|
+
"syz_bar": dc_arr[ip, 11],
|
|
798
|
+
"sxz_bar": dc_arr[ip, 12],
|
|
799
|
+
"sxy_bar": dc_arr[ip, 13],
|
|
800
|
+
"Shear_bar": s[ip],
|
|
801
|
+
"Normal_bar": nt[ip],
|
|
802
|
+
"Coulomb_bar": c[ip],
|
|
803
|
+
})
|
|
804
|
+
rows.append(r)
|
|
805
|
+
|
|
806
|
+
STATE["xsec_data"] = pd.DataFrame(rows)
|
|
807
|
+
_update_xsec_download()
|
|
808
|
+
|
|
809
|
+
except Exception as exc:
|
|
810
|
+
import traceback; traceback.print_exc()
|
|
811
|
+
xsec_status.object = f"**Error:** {exc}"
|
|
812
|
+
|
|
813
|
+
xsec_btn.on_click(_on_xsec)
|
|
814
|
+
|
|
815
|
+
# =====================================================================
|
|
816
|
+
# TABLE
|
|
817
|
+
# =====================================================================
|
|
818
|
+
def _refresh_table(*_):
|
|
819
|
+
dc3d = STATE["dc3d"]
|
|
820
|
+
if dc3d is None:
|
|
821
|
+
return
|
|
822
|
+
|
|
823
|
+
dc_arr = np.array(dc3d)
|
|
824
|
+
d = {
|
|
825
|
+
"X_km": dc_arr[:, 0], "Y_km": dc_arr[:, 1], "Z_km": dc_arr[:, 4],
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
map_info = STATE.get("map_info") or {}
|
|
829
|
+
lon0 = map_info.get("zero_lon", 0.0) if map_info else 0.0
|
|
830
|
+
lat0 = map_info.get("zero_lat", 0.0) if map_info else 0.0
|
|
831
|
+
|
|
832
|
+
if lon0 != 0.0 or lat0 != 0.0:
|
|
833
|
+
lon, lat = km_to_deg(dc_arr[:, 0], dc_arr[:, 1], lon0, lat0)
|
|
834
|
+
d["Lon"] = lon
|
|
835
|
+
d["Lat"] = lat
|
|
836
|
+
|
|
837
|
+
d.update({
|
|
838
|
+
"ux_m": dc_arr[:, 5], "uy_m": dc_arr[:, 6], "uz_m": dc_arr[:, 7],
|
|
839
|
+
"sxx_bar": dc_arr[:, 8], "syy_bar": dc_arr[:, 9], "szz_bar": dc_arr[:, 10],
|
|
840
|
+
"syz_bar": dc_arr[:, 11], "sxz_bar": dc_arr[:, 12], "sxy_bar": dc_arr[:, 13],
|
|
841
|
+
})
|
|
842
|
+
|
|
843
|
+
if STATE.get("shear") is not None:
|
|
844
|
+
d["Shear_bar"] = STATE["shear"]
|
|
845
|
+
d["Normal_bar"] = STATE["normal_stress"]
|
|
846
|
+
d["Coulomb_bar"] = STATE["coulomb_stress"]
|
|
847
|
+
|
|
848
|
+
table_pane.object = pd.DataFrame(d)
|
|
849
|
+
|
|
850
|
+
# =====================================================================
|
|
851
|
+
# SAVE FILE → FILE DOWNLOAD
|
|
852
|
+
# =====================================================================
|
|
853
|
+
def _update_map_download(*_):
|
|
854
|
+
df = table_pane.object
|
|
855
|
+
if df is None or not isinstance(df, pd.DataFrame) or df.empty:
|
|
856
|
+
map_download.file = None
|
|
857
|
+
return
|
|
858
|
+
|
|
859
|
+
mode = calc_mode.value
|
|
860
|
+
fmt = save_format.value
|
|
861
|
+
save_in_latlon = (save_crs.value == "Lat/Lon")
|
|
862
|
+
|
|
863
|
+
nx, ny = STATE.get("nx", 0), STATE.get("ny", 0)
|
|
864
|
+
|
|
865
|
+
# Common variables
|
|
866
|
+
base_name = "coulomb_out" if mode in ("coulomb", "batch", "single") else "halfspace_def_out"
|
|
867
|
+
ext = fmt.lower()
|
|
868
|
+
fname = f"{base_name}.{ext}"
|
|
869
|
+
map_download.filename = fname
|
|
870
|
+
|
|
871
|
+
# --- EXPORT LOGIC ---
|
|
872
|
+
if fmt == "CSV":
|
|
873
|
+
# Save CSV to memory
|
|
874
|
+
buf = io.StringIO()
|
|
875
|
+
df.to_csv(buf, index=False)
|
|
876
|
+
buf.seek(0)
|
|
877
|
+
map_download.file = buf
|
|
878
|
+
return
|
|
879
|
+
|
|
880
|
+
# Use a temporary file for rasterio and geopandas since they need actual paths or complex file-like objects
|
|
881
|
+
tmp_path = os.path.join("cache", fname)
|
|
882
|
+
|
|
883
|
+
if fmt == "SHP":
|
|
884
|
+
# Save Shapefile
|
|
885
|
+
if save_in_latlon and "Lon" in df.columns:
|
|
886
|
+
gdf = gpd.GeoDataFrame(df, geometry=gpd.points_from_xy(df.Lon, df.Lat), crs="EPSG:4326")
|
|
887
|
+
else:
|
|
888
|
+
gdf = gpd.GeoDataFrame(df, geometry=gpd.points_from_xy(df.X_km, df.Y_km)) # Local CRS
|
|
889
|
+
|
|
890
|
+
# Shapefile involves multiple files (.shp, .shx, .dbf, etc).
|
|
891
|
+
# We will create a zip file for the shapefile.
|
|
892
|
+
import zipfile
|
|
893
|
+
zip_fname = f"{base_name}.zip"
|
|
894
|
+
zip_path = os.path.join("cache", zip_fname)
|
|
895
|
+
|
|
896
|
+
# Save shapefile parts to cache dir
|
|
897
|
+
shp_path = os.path.join("cache", fname)
|
|
898
|
+
gdf.to_file(shp_path)
|
|
899
|
+
|
|
900
|
+
# Zip them up
|
|
901
|
+
with zipfile.ZipFile(zip_path, 'w') as zipf:
|
|
902
|
+
for ext_name in ['.shp', '.shx', '.dbf', '.prj', '.cpg']:
|
|
903
|
+
p = os.path.join("cache", f"{base_name}{ext_name}")
|
|
904
|
+
if os.path.exists(p):
|
|
905
|
+
zipf.write(p, arcname=f"{base_name}{ext_name}")
|
|
906
|
+
|
|
907
|
+
# Serve the zip
|
|
908
|
+
map_download.filename = zip_fname
|
|
909
|
+
map_download.file = os.path.abspath(zip_path)
|
|
910
|
+
|
|
911
|
+
elif fmt == "TIF":
|
|
912
|
+
nx, ny = STATE.get("nx", 0), STATE.get("ny", 0)
|
|
913
|
+
if nx <= 1 or ny <= 1:
|
|
914
|
+
_set_status("TIF export only supports Grid calculations.", error=True)
|
|
915
|
+
map_download.file = None
|
|
916
|
+
return
|
|
917
|
+
|
|
918
|
+
# Get target variable
|
|
919
|
+
vals = _get_vals(plot_var.value, STATE.get("dc3d"), STATE.get("shear"), STATE.get("normal_stress"), STATE.get("coulomb_stress"))
|
|
920
|
+
if vals is None:
|
|
921
|
+
return
|
|
922
|
+
|
|
923
|
+
grid_data = np.array(vals).reshape(nx, ny).T.astype(np.float32) # Reshape to (nx, ny) then Transpose to (ny, nx) for image coordinates
|
|
924
|
+
|
|
925
|
+
if save_in_latlon and "Lon" in df.columns:
|
|
926
|
+
xmin = df["Lon"].min()
|
|
927
|
+
xmax = df["Lon"].max()
|
|
928
|
+
ymin = df["Lat"].min()
|
|
929
|
+
ymax = df["Lat"].max()
|
|
930
|
+
crs = "EPSG:4326"
|
|
931
|
+
else:
|
|
932
|
+
xmin = df["X_km"].min()
|
|
933
|
+
xmax = df["X_km"].max()
|
|
934
|
+
ymin = df["Y_km"].min()
|
|
935
|
+
ymax = df["Y_km"].max()
|
|
936
|
+
crs = None
|
|
937
|
+
|
|
938
|
+
# When we write to a Raster using `nx, ny`, the raster takes the absolute bounding box.
|
|
939
|
+
# Since df["Lon"] max/min are point centers, adding half-pixel allows the edge to align.
|
|
940
|
+
# However, due to QGIS coordinate logic on degrees, we must be precise.
|
|
941
|
+
# Actually, let's just use from_bounds without manual half-pixel pad. Rasterio handles it.
|
|
942
|
+
# Wait, from_bounds(west, south, east, north)
|
|
943
|
+
|
|
944
|
+
# In QGIS, if we use from_bounds(xmin, ymin, xmax, ymax) it considers the corners as the centers.
|
|
945
|
+
# In QGIS, if we use from_bounds(xmin, ymin, xmax, ymax) it considers the corners as the centers.
|
|
946
|
+
transform = from_bounds(xmin, ymin, xmax, ymax, nx, ny)
|
|
947
|
+
|
|
948
|
+
with rasterio.open(
|
|
949
|
+
tmp_path, 'w', driver='GTiff',
|
|
950
|
+
height=grid_data.shape[0], width=grid_data.shape[1],
|
|
951
|
+
count=1, dtype=str(grid_data.dtype),
|
|
952
|
+
crs=crs, transform=transform,
|
|
953
|
+
) as dst:
|
|
954
|
+
dst.write(np.flipud(grid_data), 1)
|
|
955
|
+
|
|
956
|
+
map_download.file = os.path.abspath(tmp_path)
|
|
957
|
+
|
|
958
|
+
save_format.param.watch(_update_map_download, 'value')
|
|
959
|
+
save_crs.param.watch(_update_map_download, 'value')
|
|
960
|
+
|
|
961
|
+
def _update_xsec_download():
|
|
962
|
+
df = STATE.get("xsec_data", None)
|
|
963
|
+
if df is None or df.empty:
|
|
964
|
+
xsec_download.file = None
|
|
965
|
+
return
|
|
966
|
+
|
|
967
|
+
buf = io.StringIO()
|
|
968
|
+
df.to_csv(buf, index=False)
|
|
969
|
+
buf.seek(0)
|
|
970
|
+
xsec_download.file = buf
|
|
971
|
+
|
|
972
|
+
# =====================================================================
|
|
973
|
+
# LAYOUT
|
|
974
|
+
|
|
975
|
+
# =====================================================================
|
|
976
|
+
grid_box = pn.Column(
|
|
977
|
+
"### Grid Limits",
|
|
978
|
+
pn.Row(grid_xmin, grid_xmax),
|
|
979
|
+
pn.Row(grid_ymin, grid_ymax),
|
|
980
|
+
pn.Row(grid_inc, grid_depth),
|
|
981
|
+
)
|
|
982
|
+
receiver_box = pn.Column(
|
|
983
|
+
"### Receiver Angles",
|
|
984
|
+
pn.Row(rec_strike, rec_dip, rec_rake),
|
|
985
|
+
)
|
|
986
|
+
batch_box = pn.Column(
|
|
987
|
+
"### Batch Target File",
|
|
988
|
+
batch_input,
|
|
989
|
+
)
|
|
990
|
+
single_box = pn.Column(
|
|
991
|
+
"### Target Coordinates",
|
|
992
|
+
pn.Row(sp_x, sp_y, sp_z),
|
|
993
|
+
"### Target Fault Angles",
|
|
994
|
+
pn.Row(sp_strike, sp_dip, sp_rake),
|
|
995
|
+
)
|
|
996
|
+
|
|
997
|
+
@pn.depends(calc_mode.param.value)
|
|
998
|
+
def _mode_panel(mode):
|
|
999
|
+
if mode in ("coulomb", "deformation"):
|
|
1000
|
+
return pn.Column(grid_box, receiver_box)
|
|
1001
|
+
elif mode == "batch":
|
|
1002
|
+
return pn.Column(batch_box, pn.pane.Markdown("*Receiver angles read from file.*"))
|
|
1003
|
+
elif mode == "single":
|
|
1004
|
+
return single_box
|
|
1005
|
+
return pn.Column()
|
|
1006
|
+
|
|
1007
|
+
sidebar = pn.Column(
|
|
1008
|
+
"# ⚡ Coulomb Stress",
|
|
1009
|
+
pn.layout.Divider(),
|
|
1010
|
+
file_input,
|
|
1011
|
+
calc_mode,
|
|
1012
|
+
coord_sys,
|
|
1013
|
+
_mode_panel,
|
|
1014
|
+
pn.layout.Divider(),
|
|
1015
|
+
run_btn,
|
|
1016
|
+
status_md,
|
|
1017
|
+
width=360,
|
|
1018
|
+
sizing_mode="stretch_height",
|
|
1019
|
+
)
|
|
1020
|
+
|
|
1021
|
+
# Move cross-section to map tab
|
|
1022
|
+
map_view_tools = pn.Column(
|
|
1023
|
+
pn.Row(plot_var, map_cmap, map_vmin, map_vmax),
|
|
1024
|
+
pn.Row(toggle_3d, refresh_btn, save_format, save_crs, map_download),
|
|
1025
|
+
pn.layout.Divider(),
|
|
1026
|
+
"**Define Profile Line A → B**",
|
|
1027
|
+
pn.Row(xsec_ax, xsec_ay, pn.pane.Markdown("**→**", margin=(10, 10)), xsec_bx, xsec_by),
|
|
1028
|
+
)
|
|
1029
|
+
|
|
1030
|
+
# Cross-section tab contents (only keep depth, points, computation)
|
|
1031
|
+
xsec_tab = pn.Column(
|
|
1032
|
+
"### Profile configurations",
|
|
1033
|
+
pn.Row(xsec_zmin, xsec_zmax, xsec_zinc),
|
|
1034
|
+
pn.Row(xsec_npts, xsec_var),
|
|
1035
|
+
pn.Row(xsec_cmap, xsec_vmin, xsec_vmax),
|
|
1036
|
+
xsec_btn,
|
|
1037
|
+
xsec_status,
|
|
1038
|
+
pn.Row(xsec_download),
|
|
1039
|
+
xsec_pane,
|
|
1040
|
+
)
|
|
1041
|
+
|
|
1042
|
+
main_tabs = pn.Tabs(
|
|
1043
|
+
("2D / 3D Map", pn.Column(map_view_tools, map_pane)),
|
|
1044
|
+
("Cross Section View", xsec_tab),
|
|
1045
|
+
("Table Preview", pn.Column(table_pane)),
|
|
1046
|
+
dynamic=True,
|
|
1047
|
+
)
|
|
1048
|
+
|
|
1049
|
+
template = pn.template.FastListTemplate(
|
|
1050
|
+
title="Coulomb Stress Change Dashboard",
|
|
1051
|
+
sidebar=[sidebar],
|
|
1052
|
+
main=[main_tabs],
|
|
1053
|
+
accent_base_color="#4fc3f7",
|
|
1054
|
+
header_background="#1e1e2e",
|
|
1055
|
+
theme="dark",
|
|
1056
|
+
)
|
|
1057
|
+
|
|
1058
|
+
template.servable()
|