xslope 0.1.2__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.
- xslope/__init__.py +1 -0
- xslope/_version.py +4 -0
- xslope/advanced.py +460 -0
- xslope/fem.py +2753 -0
- xslope/fileio.py +671 -0
- xslope/global_config.py +59 -0
- xslope/mesh.py +2719 -0
- xslope/plot.py +1484 -0
- xslope/plot_fem.py +1658 -0
- xslope/plot_seep.py +634 -0
- xslope/search.py +416 -0
- xslope/seep.py +2080 -0
- xslope/slice.py +1075 -0
- xslope/solve.py +1259 -0
- xslope-0.1.2.dist-info/LICENSE +196 -0
- xslope-0.1.2.dist-info/METADATA +56 -0
- xslope-0.1.2.dist-info/NOTICE +14 -0
- xslope-0.1.2.dist-info/RECORD +20 -0
- xslope-0.1.2.dist-info/WHEEL +5 -0
- xslope-0.1.2.dist-info/top_level.txt +1 -0
xslope/solve.py
ADDED
|
@@ -0,0 +1,1259 @@
|
|
|
1
|
+
# Copyright 2025 Norman L. Jones
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
from math import sin, cos, tan, radians, atan, atan2, degrees
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
import pandas as pd
|
|
19
|
+
from scipy.optimize import minimize_scalar, root_scalar, newton
|
|
20
|
+
from shapely.geometry import LineString, Point
|
|
21
|
+
from tabulate import tabulate
|
|
22
|
+
|
|
23
|
+
from .advanced import rapid_drawdown
|
|
24
|
+
|
|
25
|
+
def solve_selected(method_name, slice_df, rapid=False):
|
|
26
|
+
"""
|
|
27
|
+
Executes a specified limit equilibrium solution method and displays results.
|
|
28
|
+
|
|
29
|
+
Parameters
|
|
30
|
+
----------
|
|
31
|
+
method_name : str
|
|
32
|
+
Name of the solution method function to call. Must be one of:
|
|
33
|
+
'oms', 'bishop', 'janbu', 'spencer', 'corps_engineers', 'lowe_karafiath'
|
|
34
|
+
slice_df : pandas.DataFrame
|
|
35
|
+
Slice dataframe containing all required columns for the specified method
|
|
36
|
+
(see individual method documentation for column requirements)
|
|
37
|
+
rapid : bool, optional
|
|
38
|
+
If True, performs rapid drawdown analysis using the specified method.
|
|
39
|
+
Default is False.
|
|
40
|
+
|
|
41
|
+
Returns
|
|
42
|
+
-------
|
|
43
|
+
dict or str
|
|
44
|
+
If successful: dictionary containing method results (includes 'FS' and method-specific parameters)
|
|
45
|
+
If failed: error message string
|
|
46
|
+
|
|
47
|
+
Notes
|
|
48
|
+
-----
|
|
49
|
+
This function automatically prints the factor of safety and method-specific
|
|
50
|
+
parameters to the console. For methods with additional parameters:
|
|
51
|
+
- Spencer: displays theta (interslice force angle)
|
|
52
|
+
- Janbu: displays fo (correction factor)
|
|
53
|
+
- Corps of Engineers: displays theta
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
func = globals()[method_name]
|
|
57
|
+
|
|
58
|
+
if rapid:
|
|
59
|
+
success, result = rapid_drawdown(slice_df, method_name)
|
|
60
|
+
else:
|
|
61
|
+
success, result = func(slice_df)
|
|
62
|
+
if not success:
|
|
63
|
+
print(f'Error: {result}')
|
|
64
|
+
return result
|
|
65
|
+
|
|
66
|
+
if func == oms:
|
|
67
|
+
print(f'OMS: FS={result["FS"]:.3f}')
|
|
68
|
+
elif func == bishop:
|
|
69
|
+
print(f'Bishop: FS={result["FS"]:.3f}')
|
|
70
|
+
elif func == spencer:
|
|
71
|
+
print(f'Spencer: FS={result["FS"]:.3f}, theta={result["theta"]:.2f}')
|
|
72
|
+
elif func == janbu:
|
|
73
|
+
print(f'Janbu Corrected FS={result["FS"]:.3f}, fo={result["fo"]:.2f}')
|
|
74
|
+
elif func == corps_engineers:
|
|
75
|
+
print(f'Corps Engineers: FS={result["FS"]:.3f}, theta={result["theta"]:.2f}')
|
|
76
|
+
elif func == lowe_karafiath:
|
|
77
|
+
print(f'Lowe & Karafiath: FS={result["FS"]:.3f}')
|
|
78
|
+
return result
|
|
79
|
+
|
|
80
|
+
def solve_all(slice_df):
|
|
81
|
+
"""
|
|
82
|
+
Executes all available limit equilibrium solution methods sequentially.
|
|
83
|
+
|
|
84
|
+
Runs six different limit equilibrium methods on the provided slice dataframe
|
|
85
|
+
and displays the factor of safety for each method. This is useful for comparing
|
|
86
|
+
results across multiple solution approaches.
|
|
87
|
+
|
|
88
|
+
Parameters
|
|
89
|
+
----------
|
|
90
|
+
slice_df : pandas.DataFrame
|
|
91
|
+
Slice dataframe containing all required columns for all methods.
|
|
92
|
+
Must include: 'alpha', 'phi', 'c', 'w', 'u', 'dl', 'dload', 'd_x', 'd_y',
|
|
93
|
+
'beta', 'kw', 't', 'y_t', 'p', 'x_c', 'y_cg', and additional columns
|
|
94
|
+
required for specific methods (e.g., 'r', 'xo', 'yo' for circular methods).
|
|
95
|
+
|
|
96
|
+
Returns
|
|
97
|
+
-------
|
|
98
|
+
None
|
|
99
|
+
Results are printed to console for each method.
|
|
100
|
+
|
|
101
|
+
Notes
|
|
102
|
+
-----
|
|
103
|
+
Methods executed in order:
|
|
104
|
+
1. Ordinary Method of Slices (OMS)
|
|
105
|
+
2. Bishop's Simplified Method
|
|
106
|
+
3. Janbu's Simplified Method
|
|
107
|
+
4. Corps of Engineers Method
|
|
108
|
+
5. Lowe & Karafiath Method
|
|
109
|
+
6. Spencer's Method
|
|
110
|
+
|
|
111
|
+
If any method fails, an error message is displayed but execution continues
|
|
112
|
+
with the remaining methods.
|
|
113
|
+
"""
|
|
114
|
+
solve_selected('oms', slice_df)
|
|
115
|
+
solve_selected('bishop', slice_df)
|
|
116
|
+
solve_selected('janbu', slice_df)
|
|
117
|
+
solve_selected('corps_engineers', slice_df)
|
|
118
|
+
solve_selected('lowe_karafiath', slice_df)
|
|
119
|
+
solve_selected('spencer', slice_df)
|
|
120
|
+
|
|
121
|
+
def oms(slice_df, debug=False):
|
|
122
|
+
"""
|
|
123
|
+
Computes FS by direct application of Equation 9 (Ordinary Method of Slices).
|
|
124
|
+
|
|
125
|
+
Inputs
|
|
126
|
+
------
|
|
127
|
+
slice_df : pandas.DataFrame
|
|
128
|
+
Must contain exactly these columns (length = n slices):
|
|
129
|
+
'alpha' (deg) = base inclination αᵢ
|
|
130
|
+
'phi' (deg) = friction angle φᵢ
|
|
131
|
+
'c' = cohesion cᵢ
|
|
132
|
+
'w' = slice weight Wᵢ
|
|
133
|
+
'u' = pore pressure force/unit‐length on base, uᵢ
|
|
134
|
+
'dl' = base length Δℓᵢ
|
|
135
|
+
'd' = resultant distributed load Dᵢ
|
|
136
|
+
'd_x','d_y' = centroid (x,y) at which Dᵢ acts
|
|
137
|
+
'beta' (deg) = top slope βᵢ
|
|
138
|
+
'kw' = seismic horizontal kWᵢ
|
|
139
|
+
't' = tension‐crack horizontal Tᵢ (zero except one slice)
|
|
140
|
+
'y_t' = y‐loc of Tᵢ's line of action (zero except that one slice)
|
|
141
|
+
'p' = reinforcement uplift pᵢ (zero if none)
|
|
142
|
+
'x_c','y_cg' = slice‐centroid (x,y) for seismic moment arm
|
|
143
|
+
'r' = radius of circular failure surface
|
|
144
|
+
'xo','yo' = x,y coordinates of circle center
|
|
145
|
+
|
|
146
|
+
Returns
|
|
147
|
+
-------
|
|
148
|
+
(bool, dict_or_str)
|
|
149
|
+
• If success: (True, {'method':'oms', 'FS': <computed value>})
|
|
150
|
+
• If denominator → 0 or other fatal error: (False, "<error message>")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
"""
|
|
154
|
+
if 'r' not in slice_df.columns:
|
|
155
|
+
return False, "Circle is required for OMS method."
|
|
156
|
+
|
|
157
|
+
# 1) Unpack circle‐center and radius as single values
|
|
158
|
+
Xo = slice_df['xo'].iloc[0] # Xoᵢ (x-coordinate of circle center)
|
|
159
|
+
Yo = slice_df['yo'].iloc[0] # Yoᵢ (y-coordinate of circle center)
|
|
160
|
+
R = slice_df['r'].iloc[0] # Rᵢ (radius of circular failure surface)
|
|
161
|
+
|
|
162
|
+
# 2) Pull arrays directly from slice_df
|
|
163
|
+
alpha_deg = slice_df['alpha'].values # αᵢ in degrees
|
|
164
|
+
phi_deg = slice_df['phi'].values # φᵢ in degrees
|
|
165
|
+
c = slice_df['c'].values # cᵢ
|
|
166
|
+
W = slice_df['w'].values # Wᵢ
|
|
167
|
+
u = slice_df['u'].values # uᵢ (pore‐force per unit length)
|
|
168
|
+
dl = slice_df['dl'].values # Δℓᵢ
|
|
169
|
+
D = slice_df['dload'].values # Dᵢ
|
|
170
|
+
d_x = slice_df['d_x'].values # d_{x,i}
|
|
171
|
+
d_y = slice_df['d_y'].values # d_{y,i}
|
|
172
|
+
beta_deg = slice_df['beta'].values # βᵢ in degrees
|
|
173
|
+
kw = slice_df['kw'].values # kWᵢ
|
|
174
|
+
T = slice_df['t'].values # Tᵢ (zero except one slice)
|
|
175
|
+
y_t = slice_df['y_t'].values # y_{t,i} (zero except one slice)
|
|
176
|
+
P = slice_df['p'].values # pᵢ
|
|
177
|
+
x_c = slice_df['x_c'].values # x_{c,i}
|
|
178
|
+
y_cg = slice_df['y_cg'].values # y_{cg,i} coordinate of slice centroid
|
|
179
|
+
|
|
180
|
+
# 3) Convert angles to radians
|
|
181
|
+
alpha = np.radians(alpha_deg) # αᵢ [rad]
|
|
182
|
+
phi = np.radians(phi_deg) # φᵢ [rad]
|
|
183
|
+
beta = np.radians(beta_deg) # βᵢ [rad]
|
|
184
|
+
|
|
185
|
+
# 4) Precompute sines/cosines
|
|
186
|
+
sin_alpha = np.sin(alpha) # sin(αᵢ)
|
|
187
|
+
cos_alpha = np.cos(alpha) # cos(αᵢ)
|
|
188
|
+
sin_ab = np.sin(alpha - beta) # sin(αᵢ−βᵢ)
|
|
189
|
+
cos_ab = np.cos(alpha - beta) # cos(αᵢ−βᵢ)
|
|
190
|
+
tan_phi = np.tan(phi) # tan(φᵢ)
|
|
191
|
+
|
|
192
|
+
# ————————————————————————————————————————————————————————
|
|
193
|
+
# 5) Build the NUMERATOR = Σᵢ [ cᵢ·Δℓᵢ
|
|
194
|
+
# + (Wᵢ·cosαᵢ + Dᵢ·cos(αᵢ−βᵢ) − kWᵢ·sinαᵢ − Tᵢ·sinαᵢ − uᵢ·Δℓᵢ )·tanφᵢ
|
|
195
|
+
# + pᵢ ] + Σ Dᵢ·sinβᵢ·(Yo - d_{y,i})
|
|
196
|
+
#
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# N′ᵢ = Wᵢ·cosαᵢ + Dᵢ·cos(αᵢ−βᵢ) − kWᵢ·sinαᵢ − Tᵢ·sinαᵢ − uᵢ·Δℓᵢ
|
|
200
|
+
N_eff = (
|
|
201
|
+
W * cos_alpha
|
|
202
|
+
+ D * cos_ab
|
|
203
|
+
- kw * sin_alpha
|
|
204
|
+
- T * sin_alpha
|
|
205
|
+
- (u * dl)
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# Σ Dᵢ·sinβᵢ·(Yo - d_{y,i})
|
|
209
|
+
a_dy = Yo - d_y
|
|
210
|
+
sum_Dy = np.sum(D * np.sin(beta) * a_dy)
|
|
211
|
+
|
|
212
|
+
numerator = np.sum(c * dl + N_eff * tan_phi + P)+ (1.0 / R) * sum_Dy
|
|
213
|
+
|
|
214
|
+
# ————————————————————————————————————————————————————————
|
|
215
|
+
# 6) Build each piece of the DENOMINATOR exactly as Eqn 9:
|
|
216
|
+
|
|
217
|
+
# (A) = Σ [ Wᵢ · sinαᵢ ]
|
|
218
|
+
sum_W = np.sum(W * sin_alpha)
|
|
219
|
+
|
|
220
|
+
# (B) = Σ Dᵢ·cosβᵢ·(Xo - d_{x,i})
|
|
221
|
+
a_dx = d_x - Xo
|
|
222
|
+
sum_Dx = np.sum(D * np.cos(beta) * a_dx)
|
|
223
|
+
|
|
224
|
+
# (C) = Σ [ kWᵢ * (Yo - y_{cg,i}) ]
|
|
225
|
+
a_s = Yo - y_cg
|
|
226
|
+
sum_kw = np.sum(kw * a_s)
|
|
227
|
+
|
|
228
|
+
# (D) = Σ [ Tᵢ * (Yo - y_{t,i}) ]
|
|
229
|
+
a_t = Yo - y_t
|
|
230
|
+
sum_T = np.sum(T * a_t)
|
|
231
|
+
|
|
232
|
+
# Put them together with their 1/R factors:
|
|
233
|
+
denominator = sum_W + (1.0 / R) * (sum_Dx + sum_kw + sum_T)
|
|
234
|
+
|
|
235
|
+
# 7) Finally compute FS = (numerator)/(denominator)
|
|
236
|
+
FS = numerator / denominator
|
|
237
|
+
|
|
238
|
+
# 8) Store effective normal forces in the DataFrame
|
|
239
|
+
slice_df['n_eff'] = N_eff
|
|
240
|
+
|
|
241
|
+
if debug==True:
|
|
242
|
+
print(f'numerator = {numerator:.4f}')
|
|
243
|
+
print(f'denominator = {denominator:.4f}')
|
|
244
|
+
print(f'Sum_W = {sum_W:.4f}')
|
|
245
|
+
print(f'Sum_Dx = {sum_Dx:.4f}')
|
|
246
|
+
print(f'Sum_Dy = {sum_Dy:.4f}')
|
|
247
|
+
print(f'Sum_kw = {sum_kw:.4f}')
|
|
248
|
+
print(f'Sum_T = {sum_T:.4f}')
|
|
249
|
+
print('N_eff =', np.array2string(N_eff, precision=4, separator=', '))
|
|
250
|
+
|
|
251
|
+
# 9) Return success and the FS
|
|
252
|
+
return True, {'method': 'oms', 'FS': FS}
|
|
253
|
+
|
|
254
|
+
def bishop(slice_df, debug=False, tol=1e-6, max_iter=100):
|
|
255
|
+
"""
|
|
256
|
+
Computes FS using the complete Bishop's Simplified Method (Equation 10) and computes N_eff (Equation 8).
|
|
257
|
+
Requires circular slip surface and full input data structure consistent with OMS.
|
|
258
|
+
|
|
259
|
+
Parameters:
|
|
260
|
+
slice_df : pandas.DataFrame with required columns (see OMS spec)
|
|
261
|
+
debug : bool, if True prints diagnostic info
|
|
262
|
+
tol : float, convergence tolerance
|
|
263
|
+
max_iter : int, maximum iteration steps
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
(bool, dict | str): (True, {'method': 'bishop', 'FS': value}) or (False, error message)
|
|
267
|
+
"""
|
|
268
|
+
|
|
269
|
+
if 'r' not in slice_df.columns:
|
|
270
|
+
return False, "Circle is required for Bishop method."
|
|
271
|
+
|
|
272
|
+
# 1) Unpack circle‐center and radius as single values
|
|
273
|
+
Xo = slice_df['xo'].iloc[0] # Xoᵢ (x-coordinate of circle center)
|
|
274
|
+
Yo = slice_df['yo'].iloc[0] # Yoᵢ (y-coordinate of circle center)
|
|
275
|
+
R = slice_df['r'].iloc[0] # Rᵢ (radius of circular failure surface)
|
|
276
|
+
|
|
277
|
+
# Load input arrays
|
|
278
|
+
alpha = np.radians(slice_df['alpha'].values)
|
|
279
|
+
phi = np.radians(slice_df['phi'].values)
|
|
280
|
+
c = slice_df['c'].values
|
|
281
|
+
W = slice_df['w'].values
|
|
282
|
+
u = slice_df['u'].values
|
|
283
|
+
dl = slice_df['dl'].values
|
|
284
|
+
D = slice_df['dload'].values
|
|
285
|
+
d_x = slice_df['d_x'].values
|
|
286
|
+
d_y = slice_df['d_y'].values
|
|
287
|
+
beta = np.radians(slice_df['beta'].values)
|
|
288
|
+
kw = slice_df['kw'].values
|
|
289
|
+
T = slice_df['t'].values
|
|
290
|
+
y_t = slice_df['y_t'].values
|
|
291
|
+
P = slice_df['p'].values
|
|
292
|
+
x_c = slice_df['x_c'].values
|
|
293
|
+
y_cg = slice_df['y_cg'].values
|
|
294
|
+
|
|
295
|
+
# Trigonometric terms
|
|
296
|
+
sin_alpha = np.sin(alpha)
|
|
297
|
+
cos_alpha = np.cos(alpha)
|
|
298
|
+
tan_phi = np.tan(phi)
|
|
299
|
+
sin_beta = np.sin(beta)
|
|
300
|
+
cos_beta = np.cos(beta)
|
|
301
|
+
|
|
302
|
+
# Moment arms
|
|
303
|
+
a_dx = d_x - Xo
|
|
304
|
+
a_dy = Yo - d_y
|
|
305
|
+
a_s = Yo - y_cg
|
|
306
|
+
a_t = Yo - y_t
|
|
307
|
+
|
|
308
|
+
# Denominator (moment equilibrium)
|
|
309
|
+
sum_W = np.sum(W * sin_alpha)
|
|
310
|
+
sum_Dx = np.sum(D * cos_beta * a_dx)
|
|
311
|
+
sum_Dy = np.sum(D * sin_beta * a_dy)
|
|
312
|
+
sum_kw = np.sum(kw * a_s)
|
|
313
|
+
sum_T = np.sum(T * a_t)
|
|
314
|
+
denominator = sum_W + (1.0 / R) * (sum_Dx + sum_kw + sum_T)
|
|
315
|
+
|
|
316
|
+
# Iterative solution
|
|
317
|
+
F = 1.0
|
|
318
|
+
for _ in range(max_iter):
|
|
319
|
+
# Compute N_eff from Equation (8)
|
|
320
|
+
num_N = (
|
|
321
|
+
W + D * cos_beta - P * sin_alpha
|
|
322
|
+
- u * dl * cos_alpha
|
|
323
|
+
- (c * dl * sin_alpha) / F
|
|
324
|
+
)
|
|
325
|
+
denom_N = cos_alpha + (sin_alpha * tan_phi) / F
|
|
326
|
+
N_eff = num_N / denom_N
|
|
327
|
+
|
|
328
|
+
# Numerator for FS from Equation (10)
|
|
329
|
+
shear = (
|
|
330
|
+
c * dl * cos_alpha
|
|
331
|
+
+ (W + D * cos_beta - P * sin_alpha - u * dl * cos_alpha) * tan_phi
|
|
332
|
+
+ P
|
|
333
|
+
)
|
|
334
|
+
numerator = np.sum(shear / denom_N) + (1.0 / R) * sum_Dy
|
|
335
|
+
F_new = numerator / denominator
|
|
336
|
+
|
|
337
|
+
if abs(F_new - F) < tol:
|
|
338
|
+
slice_df['n_eff'] = N_eff
|
|
339
|
+
if debug:
|
|
340
|
+
print(f"FS = {F_new:.6f}")
|
|
341
|
+
print(f"Numerator = {numerator:.6f}")
|
|
342
|
+
print(f"Denominator = {denominator:.6f}")
|
|
343
|
+
print("N_eff =", np.array2string(N_eff, precision=4, separator=', '))
|
|
344
|
+
return True, {'method': 'bishop', 'FS': F_new}
|
|
345
|
+
|
|
346
|
+
F = F_new
|
|
347
|
+
|
|
348
|
+
return False, "Bishop method did not converge within the maximum number of iterations."
|
|
349
|
+
|
|
350
|
+
def janbu(slice_df, debug=False):
|
|
351
|
+
"""
|
|
352
|
+
Computes FS using Janbu's Simplified Method with correction factor (Equation 7).
|
|
353
|
+
|
|
354
|
+
Implements the complete formulation including distributed loads, seismic forces,
|
|
355
|
+
reinforcement, and tension crack water forces. Applies Janbu correction factor
|
|
356
|
+
based on d/L ratio and soil type.
|
|
357
|
+
|
|
358
|
+
Parameters:
|
|
359
|
+
slice_df : pandas.DataFrame with required columns (see OMS spec)
|
|
360
|
+
debug : bool, if True prints diagnostic info
|
|
361
|
+
|
|
362
|
+
Returns:
|
|
363
|
+
(bool, dict | str): (True, {'method': 'janbu_simplified', 'FS': value, 'fo': correction_factor})
|
|
364
|
+
or (False, error message)
|
|
365
|
+
"""
|
|
366
|
+
|
|
367
|
+
# Load input arrays
|
|
368
|
+
alpha = np.radians(slice_df['alpha'].values)
|
|
369
|
+
phi = np.radians(slice_df['phi'].values)
|
|
370
|
+
c = slice_df['c'].values
|
|
371
|
+
W = slice_df['w'].values
|
|
372
|
+
u = slice_df['u'].values
|
|
373
|
+
dl = slice_df['dl'].values
|
|
374
|
+
D = slice_df['dload'].values
|
|
375
|
+
beta = np.radians(slice_df['beta'].values)
|
|
376
|
+
kw = slice_df['kw'].values
|
|
377
|
+
T = slice_df['t'].values
|
|
378
|
+
P = slice_df['p'].values
|
|
379
|
+
|
|
380
|
+
# Trigonometric terms
|
|
381
|
+
sin_alpha = np.sin(alpha)
|
|
382
|
+
cos_alpha = np.cos(alpha)
|
|
383
|
+
tan_phi = np.tan(phi)
|
|
384
|
+
sin_beta_alpha = np.sin(beta - alpha)
|
|
385
|
+
cos_beta_alpha = np.cos(beta - alpha)
|
|
386
|
+
|
|
387
|
+
# Effective normal forces (Equation 10)
|
|
388
|
+
N_eff = W * cos_alpha - kw * sin_alpha + D * cos_beta_alpha - T * sin_alpha - u * dl
|
|
389
|
+
|
|
390
|
+
# Numerator: resisting forces (shear resistance)
|
|
391
|
+
numerator = np.sum(c * dl + N_eff * tan_phi + P)
|
|
392
|
+
|
|
393
|
+
# Denominator: driving forces parallel to base (Equation 6)
|
|
394
|
+
denominator = np.sum(W * sin_alpha + kw * cos_alpha - D * sin_beta_alpha + T * cos_alpha)
|
|
395
|
+
|
|
396
|
+
# Base factor of safety (Equation 7)
|
|
397
|
+
if abs(denominator) < 1e-12:
|
|
398
|
+
return False, "Division by zero in Janbu method: driving forces sum to zero"
|
|
399
|
+
|
|
400
|
+
FS_base = numerator / denominator
|
|
401
|
+
|
|
402
|
+
# === Compute Janbu correction factor ===
|
|
403
|
+
|
|
404
|
+
# Get failure surface endpoints
|
|
405
|
+
x_l = slice_df['x_l'].iloc[0] # leftmost x
|
|
406
|
+
y_lt = slice_df['y_lt'].iloc[0] # leftmost top y
|
|
407
|
+
x_r = slice_df['x_r'].iloc[-1] # rightmost x
|
|
408
|
+
y_rt = slice_df['y_rt'].iloc[-1] # rightmost top y
|
|
409
|
+
|
|
410
|
+
# Length of failure surface (straight line approximation)
|
|
411
|
+
L = np.hypot(x_r - x_l, y_rt - y_lt)
|
|
412
|
+
|
|
413
|
+
# Calculate perpendicular distance from each slice center to failure surface line
|
|
414
|
+
x0 = slice_df['x_c'].values
|
|
415
|
+
y0 = slice_df['y_cb'].values
|
|
416
|
+
|
|
417
|
+
# Distance from point to line formula: |ax + by + c| / sqrt(a² + b²)
|
|
418
|
+
# Line equation: (y_rt - y_lt)x - (x_r - x_l)y + (x_r * y_lt - y_rt * x_l) = 0
|
|
419
|
+
numerator_dist = np.abs((y_rt - y_lt) * x0 - (x_r - x_l) * y0 + x_r * y_lt - y_rt * x_l)
|
|
420
|
+
dists = numerator_dist / L
|
|
421
|
+
d = np.max(dists) # maximum perpendicular distance
|
|
422
|
+
|
|
423
|
+
dL_ratio = d / L
|
|
424
|
+
|
|
425
|
+
# Determine b1 factor based on soil type
|
|
426
|
+
phi_sum = slice_df['phi'].sum()
|
|
427
|
+
c_sum = slice_df['c'].sum()
|
|
428
|
+
|
|
429
|
+
if phi_sum == 0: # c-only soil (undrained, φ = 0)
|
|
430
|
+
b1 = 0.67
|
|
431
|
+
elif c_sum == 0: # φ-only soil (no cohesion)
|
|
432
|
+
b1 = 0.31
|
|
433
|
+
else: # c-φ soil
|
|
434
|
+
b1 = 0.50
|
|
435
|
+
|
|
436
|
+
# Correction factor
|
|
437
|
+
fo = 1 + b1 * (dL_ratio - 1.4 * dL_ratio ** 2)
|
|
438
|
+
|
|
439
|
+
# Final corrected factor of safety
|
|
440
|
+
FS = FS_base * fo
|
|
441
|
+
|
|
442
|
+
# Store effective normal forces in DataFrame
|
|
443
|
+
slice_df['n_eff'] = N_eff
|
|
444
|
+
|
|
445
|
+
if debug:
|
|
446
|
+
print(f"FS_base = {FS_base:.6f}")
|
|
447
|
+
print(f"d/L ratio = {dL_ratio:.4f}")
|
|
448
|
+
print(f"b1 factor = {b1:.2f}")
|
|
449
|
+
print(f"fo correction = {fo:.4f}")
|
|
450
|
+
print(f"FS_corrected = {FS:.6f}")
|
|
451
|
+
print(f"Numerator = {numerator:.6f}")
|
|
452
|
+
print(f"Denominator = {denominator:.6f}")
|
|
453
|
+
print("N_eff =", np.array2string(N_eff, precision=4, separator=', '))
|
|
454
|
+
|
|
455
|
+
return True, {
|
|
456
|
+
'method': 'janbu',
|
|
457
|
+
'FS': FS,
|
|
458
|
+
'fo': fo
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def force_equilibrium(slice_df, theta_list, fs_guess=1.5, tol=1e-6, max_iter=50, debug=False):
|
|
463
|
+
"""
|
|
464
|
+
Limit‐equilibrium by force equilibrium in X & Y with variable interslice angles.
|
|
465
|
+
|
|
466
|
+
Parameters:
|
|
467
|
+
slice_df (pd.DataFrame): must contain columns
|
|
468
|
+
'alpha' (slice base inclination, degrees),
|
|
469
|
+
'phi' (slice friction angle, degrees),
|
|
470
|
+
'c' (cohesion),
|
|
471
|
+
'dl' (slice base length),
|
|
472
|
+
'w' (slice weight),
|
|
473
|
+
'u' (pore force per unit length),
|
|
474
|
+
'd' (distributed load),
|
|
475
|
+
'beta' (distributed load inclination, degrees),
|
|
476
|
+
'kw' (seismic force),
|
|
477
|
+
't' (tension crack water force),
|
|
478
|
+
'p' (reinforcement force)
|
|
479
|
+
theta_list (array-like): slice‐boundary force inclinations (degrees),
|
|
480
|
+
length must be n+1 if there are n slices
|
|
481
|
+
fs_guess (float): initial guess for factor of safety
|
|
482
|
+
tol (float): convergence tolerance on residual
|
|
483
|
+
max_iter (int): maximum number of Newton (secant) iterations
|
|
484
|
+
debug (bool): print residuals during iteration
|
|
485
|
+
|
|
486
|
+
Returns:
|
|
487
|
+
(bool, dict or str):
|
|
488
|
+
- If converged: (True, {'method':'force_equilibrium','FS':<value>})
|
|
489
|
+
- If failed: (False, "error message")
|
|
490
|
+
"""
|
|
491
|
+
import numpy as np
|
|
492
|
+
|
|
493
|
+
n = len(slice_df)
|
|
494
|
+
if len(theta_list) != n+1:
|
|
495
|
+
return False, f"theta_list length ({len(theta_list)}) must be n+1 ({n+1})"
|
|
496
|
+
|
|
497
|
+
# extract and convert to radians
|
|
498
|
+
alpha = np.radians(slice_df['alpha'].values)
|
|
499
|
+
phi = np.radians(slice_df['phi'].values)
|
|
500
|
+
c = slice_df['c'].values
|
|
501
|
+
w = slice_df['w'].values
|
|
502
|
+
u = slice_df['u'].values
|
|
503
|
+
dl = slice_df['dl'].values
|
|
504
|
+
D = slice_df['dload'].values
|
|
505
|
+
beta = np.radians(slice_df['beta'].values)
|
|
506
|
+
kw = slice_df['kw'].values
|
|
507
|
+
T = slice_df['t'].values
|
|
508
|
+
P = slice_df['p'].values
|
|
509
|
+
theta = np.radians(np.asarray(theta_list))
|
|
510
|
+
N = np.zeros(n) # normal forces on slice bases
|
|
511
|
+
Z = np.zeros(n+1) # interslice forces, Z[0] = 0 by definition (no force entering leftmost slice)
|
|
512
|
+
|
|
513
|
+
def residual(FS):
|
|
514
|
+
"""Return the right‐side interslice force Z[n] for a given FS."""
|
|
515
|
+
c_m = c / FS
|
|
516
|
+
tan_phi_m = np.tan(phi) / FS
|
|
517
|
+
Z[:] = 0.0 # reset Z for each call
|
|
518
|
+
for i in range(n):
|
|
519
|
+
ca, sa = np.cos(alpha[i]), np.sin(alpha[i])
|
|
520
|
+
cb, sb = np.cos(beta[i]), np.sin(beta[i])
|
|
521
|
+
|
|
522
|
+
# Matrix A coefficients from equations (6) and (7)
|
|
523
|
+
A = np.array([
|
|
524
|
+
[tan_phi_m[i]*ca - sa, -np.cos(theta[i+1])],
|
|
525
|
+
[tan_phi_m[i]*sa + ca, -np.sin(theta[i+1])]
|
|
526
|
+
])
|
|
527
|
+
|
|
528
|
+
# Vector b from equations (6) and (7)
|
|
529
|
+
b0 = (
|
|
530
|
+
-c_m[i]*dl[i]*ca
|
|
531
|
+
- P[i]*ca
|
|
532
|
+
+ u[i]*dl[i]*sa
|
|
533
|
+
- Z[i]*np.cos(theta[i])
|
|
534
|
+
- D[i]*sb
|
|
535
|
+
+ kw[i]
|
|
536
|
+
+ T[i]
|
|
537
|
+
)
|
|
538
|
+
b1 = (
|
|
539
|
+
-c_m[i]*dl[i]*sa
|
|
540
|
+
- P[i]*sa
|
|
541
|
+
- u[i]*dl[i]*ca
|
|
542
|
+
+ w[i]
|
|
543
|
+
- Z[i]*np.sin(theta[i])
|
|
544
|
+
+ D[i]*cb
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
N_i, Z_ip1 = np.linalg.solve(A, np.array([b0, b1]))
|
|
548
|
+
Z[i+1] = Z_ip1
|
|
549
|
+
N[i] = N_i # store normal force on slice base
|
|
550
|
+
return Z[n]
|
|
551
|
+
|
|
552
|
+
if debug:
|
|
553
|
+
r0 = residual(fs_guess)
|
|
554
|
+
print(f"FS_guess={fs_guess:.6f} → residual={r0:.4g}")
|
|
555
|
+
|
|
556
|
+
# use Newton‐secant (no derivative) with single initial guess
|
|
557
|
+
try:
|
|
558
|
+
FS_opt = newton(residual, fs_guess, tol=tol, maxiter=max_iter)
|
|
559
|
+
except Exception as e:
|
|
560
|
+
return False, f"force_equilibrium failed to converge: {e}"
|
|
561
|
+
|
|
562
|
+
slice_df['n_eff'] = N # store effective normal forces in slice_df
|
|
563
|
+
slice_df['z'] = Z[:-1] # store interslice forces in slice_df, adjust length to n slices
|
|
564
|
+
|
|
565
|
+
if debug:
|
|
566
|
+
r_opt = residual(FS_opt)
|
|
567
|
+
print(f" Converged FS = {FS_opt:.6f}, residual = {r_opt:.4g}")
|
|
568
|
+
|
|
569
|
+
return True, {'FS': FS_opt}
|
|
570
|
+
|
|
571
|
+
def corps_engineers(slice_df, debug=False):
|
|
572
|
+
"""
|
|
573
|
+
Corps of Engineers style force equilibrium solver.
|
|
574
|
+
|
|
575
|
+
1. Computes a single θ from the slope between
|
|
576
|
+
(x_l[0], y_lt[0]) and (x_r[-1], y_rt[-1]).
|
|
577
|
+
2. Builds a constant θ array of length n+1.
|
|
578
|
+
3. Calls force_equilibrium(slice_df, theta_array).
|
|
579
|
+
|
|
580
|
+
Parameters:
|
|
581
|
+
slice_df (pd.DataFrame): Must include at least ['x_l','y_lt','x_r','y_rt']
|
|
582
|
+
plus all the columns required by force_equilibrium:
|
|
583
|
+
['alpha','phi','c','dl','w','u','dx'].
|
|
584
|
+
|
|
585
|
+
Returns:
|
|
586
|
+
Tuple(bool, dict or str): Whatever force_equilibrium returns.
|
|
587
|
+
"""
|
|
588
|
+
# endpoints of the slip surface
|
|
589
|
+
x0, y0 = slice_df['x_l'].iat[0], slice_df['y_lt'].iat[0]
|
|
590
|
+
x1, y1 = slice_df['x_r'].iat[-1], slice_df['y_rt'].iat[-1]
|
|
591
|
+
|
|
592
|
+
# compute positive slope‐angle
|
|
593
|
+
dx = x1 - x0
|
|
594
|
+
dy = y1 - y0
|
|
595
|
+
if abs(dx) < 1e-12:
|
|
596
|
+
theta_deg = 90.0
|
|
597
|
+
else:
|
|
598
|
+
theta_deg = abs(np.degrees(np.arctan2(dy, dx)))
|
|
599
|
+
|
|
600
|
+
# one theta per slice boundary
|
|
601
|
+
n = len(slice_df)
|
|
602
|
+
theta_list = np.full(n+1, theta_deg)
|
|
603
|
+
|
|
604
|
+
slice_df['theta'] = theta_list[:-1] # store theta in slice_df. Adjust length to n slices.
|
|
605
|
+
|
|
606
|
+
# delegate to your force_equilibrium solver
|
|
607
|
+
success, results = force_equilibrium(slice_df, theta_list, debug=debug)
|
|
608
|
+
if not success:
|
|
609
|
+
return success, results
|
|
610
|
+
else:
|
|
611
|
+
results['method'] = 'corps_engineers' # append method
|
|
612
|
+
results['theta'] = theta_deg # append theta
|
|
613
|
+
return success, results
|
|
614
|
+
|
|
615
|
+
def lowe_karafiath(slice_df, debug=False):
|
|
616
|
+
"""
|
|
617
|
+
Lowe-Karafiath limit equilibrium: variable interslice inclinations equal to
|
|
618
|
+
the average of the top‐and bottom‐surface slopes of the two adjacent slices
|
|
619
|
+
at each boundary.
|
|
620
|
+
"""
|
|
621
|
+
n = len(slice_df)
|
|
622
|
+
|
|
623
|
+
# grab boundary coords
|
|
624
|
+
x_l = slice_df['x_l'].values
|
|
625
|
+
y_lt = slice_df['y_lt'].values
|
|
626
|
+
y_lb = slice_df['y_lb'].values
|
|
627
|
+
x_r = slice_df['x_r'].values
|
|
628
|
+
y_rt = slice_df['y_rt'].values
|
|
629
|
+
y_rb = slice_df['y_rb'].values
|
|
630
|
+
|
|
631
|
+
# determine facing
|
|
632
|
+
right_facing = (y_lt[0] > y_rt[-1])
|
|
633
|
+
|
|
634
|
+
# precompute each slice's top & bottom slopes
|
|
635
|
+
widths = (x_r - x_l)
|
|
636
|
+
slope_top = (y_rt - y_lt) / widths
|
|
637
|
+
slope_bottom = (y_rb - y_lb) / widths
|
|
638
|
+
|
|
639
|
+
# build θ_list for j=0..n
|
|
640
|
+
if debug:
|
|
641
|
+
print("boundary slopes (top/bottom) avg, θ_list:") # header for debug list
|
|
642
|
+
|
|
643
|
+
theta_list = np.zeros(n+1)
|
|
644
|
+
for j in range(n+1):
|
|
645
|
+
if j == 0:
|
|
646
|
+
st = slope_top[0]
|
|
647
|
+
sb = slope_bottom[0]
|
|
648
|
+
elif j == n:
|
|
649
|
+
st = slope_top[-1]
|
|
650
|
+
sb = slope_bottom[-1]
|
|
651
|
+
else:
|
|
652
|
+
st = 0.5*(slope_top[j-1] + slope_top[j])
|
|
653
|
+
sb = 0.5*(slope_bottom[j-1] + slope_bottom[j])
|
|
654
|
+
|
|
655
|
+
avg_slope = 0.5*(st + sb)
|
|
656
|
+
theta = np.degrees(np.arctan(avg_slope))
|
|
657
|
+
|
|
658
|
+
# sign convention
|
|
659
|
+
if right_facing:
|
|
660
|
+
theta_list[j] = -theta
|
|
661
|
+
else:
|
|
662
|
+
theta_list[j] = theta
|
|
663
|
+
|
|
664
|
+
if debug:
|
|
665
|
+
print(f" j={j:2d}: st={st:.3f}, sb={sb:.3f}, θ={theta:.3f}°")
|
|
666
|
+
|
|
667
|
+
slice_df['theta'] = theta_list[:-1] # store theta in slice_df. Adjust length to n slices.
|
|
668
|
+
|
|
669
|
+
# call your force_equilibrium solver
|
|
670
|
+
success, results = force_equilibrium(slice_df, theta_list, debug=debug)
|
|
671
|
+
if not success:
|
|
672
|
+
return success, results
|
|
673
|
+
else:
|
|
674
|
+
results['method'] = 'lowe_karafiath' # append method
|
|
675
|
+
return success, results
|
|
676
|
+
|
|
677
|
+
def spencer(slice_df, tol=1e-4, max_iter = 100, debug_level=0):
|
|
678
|
+
"""
|
|
679
|
+
Spencer's Method using Steve G. Wright's formulation from the UTEXAS v2 user manual.
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
Parameters:
|
|
683
|
+
slice_df (pd.DataFrame): must contain columns
|
|
684
|
+
'alpha' (slice base inclination, degrees),
|
|
685
|
+
'phi' (slice friction angle, degrees),
|
|
686
|
+
'c' (cohesion),
|
|
687
|
+
'dl' (slice base length),
|
|
688
|
+
'w' (slice weight),
|
|
689
|
+
'u' (pore force per unit length),
|
|
690
|
+
'd' (distributed load),
|
|
691
|
+
'beta' (distributed load inclination, degrees),
|
|
692
|
+
'kw' (seismic force),
|
|
693
|
+
't' (tension crack water force),
|
|
694
|
+
'p' (reinforcement force)
|
|
695
|
+
|
|
696
|
+
Returns:
|
|
697
|
+
float: FS where FS_force = FS_moment
|
|
698
|
+
float: beta (degrees)
|
|
699
|
+
bool: converged flag
|
|
700
|
+
"""
|
|
701
|
+
|
|
702
|
+
alpha = np.radians(slice_df['alpha'].values) # slice base inclination, degrees
|
|
703
|
+
phi = np.radians(slice_df['phi'].values) # slice friction angle, degrees
|
|
704
|
+
c = slice_df['c'].values # cohesion
|
|
705
|
+
dx = slice_df['dx'].values # slice width
|
|
706
|
+
dl = slice_df['dl'].values # slice base length
|
|
707
|
+
W = slice_df['w'].values # slice weight
|
|
708
|
+
u = slice_df['u'].values # pore presssure
|
|
709
|
+
x_c = slice_df['x_c'].values # center of base x-coordinate
|
|
710
|
+
y_cb = slice_df['y_cb'].values # center of base y-coordinate
|
|
711
|
+
y_lb = slice_df['y_lb'].values # left side base y-coordinate
|
|
712
|
+
y_rb = slice_df['y_rb'].values # right side base y-coordinate
|
|
713
|
+
P = slice_df['dload'].values # distributed load resultant
|
|
714
|
+
beta = np.radians(slice_df['beta'].values) # distributed load inclination, degrees
|
|
715
|
+
kw = slice_df['kw'].values # seismic force
|
|
716
|
+
V = slice_df['t'].values # tension crack water force
|
|
717
|
+
y_v = slice_df['y_t'].values # tension crack water force y-coordinate
|
|
718
|
+
R = slice_df['p'].values # reinforcement force
|
|
719
|
+
|
|
720
|
+
# For now, we assume that reinforcement is flexible and therefore is parallel to the failure surface
|
|
721
|
+
# at the bottom of the slice. Therefore, the psi value used in the derivation is set to alpha,
|
|
722
|
+
# and the point of action is the center of the base of the slice.
|
|
723
|
+
psi = alpha # psi is the angle of the reinforcement force from the horizontal
|
|
724
|
+
y_r = y_cb # y_r is the y-coordinate of the point of action of the reinforcement
|
|
725
|
+
x_r = x_c # x_r is the x-coordinate of the point of action of the reinforcement
|
|
726
|
+
|
|
727
|
+
# use variable names to match the derivation.
|
|
728
|
+
x_p = slice_df['d_x'].values # distributed load x-coordinate
|
|
729
|
+
y_p = slice_df['d_y'].values # distributed load y-coordinate
|
|
730
|
+
y_k = slice_df['y_cg'].values # seismic force y-coordinate
|
|
731
|
+
x_b = x_c # center of base x-coordinate
|
|
732
|
+
y_b = y_cb # center of base y-coordinate
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
tan_p = np.tan(phi) # tan(phi)
|
|
736
|
+
|
|
737
|
+
y_ct = slice_df['y_ct'].values
|
|
738
|
+
right_facing = (y_ct[0] > y_ct[-1])
|
|
739
|
+
# If right facing, swap angles and strengths. For most methods, you can use the normal angle conventions
|
|
740
|
+
# and get the right answer. But for Spencer, due to the way that the moment equation is written,
|
|
741
|
+
# you need to swap the angles and strengths if the slope is right facing.
|
|
742
|
+
if right_facing:
|
|
743
|
+
alpha = -alpha
|
|
744
|
+
beta = -beta
|
|
745
|
+
psi = -psi
|
|
746
|
+
R = -R
|
|
747
|
+
c = -c
|
|
748
|
+
kw = -kw
|
|
749
|
+
tan_p = -tan_p
|
|
750
|
+
|
|
751
|
+
# pre-compute the trigonometric functions
|
|
752
|
+
cos_a = np.cos(alpha) # cos(alpha)
|
|
753
|
+
sin_a = np.sin(alpha) # sin(alpha)
|
|
754
|
+
#tan_p = np.tan(phi) # tan(phi) # moved above
|
|
755
|
+
cos_b = np.cos(beta) # cos(beta)
|
|
756
|
+
sin_b = np.sin(beta) # sin(beta)
|
|
757
|
+
sin_psi = np.sin(psi) # sin(psi)
|
|
758
|
+
cos_psi = np.cos(psi) # cos(psi)
|
|
759
|
+
|
|
760
|
+
Fh = - kw - V + P * sin_b + R * cos_psi # Equation (1)
|
|
761
|
+
Fv = - W - P * cos_b + R * sin_psi # Equation (2)
|
|
762
|
+
Mo = - P * sin_b * (y_p - y_b) - P * cos_b * (x_p - x_b) \
|
|
763
|
+
+ kw * (y_k - y_b) + V * (y_v - y_b) - R * cos_psi * (y_r - y_b) + R * sin_psi * (x_r - x_b) # Equation (3)
|
|
764
|
+
|
|
765
|
+
# ========== BEGIN SOLUTION ==========
|
|
766
|
+
|
|
767
|
+
def compute_Q_and_yQ(F, theta_rad):
|
|
768
|
+
"""Compute Q and y_Q for given F and theta values."""
|
|
769
|
+
# Equation (24): m_alpha
|
|
770
|
+
ma = 1 / (np.cos(alpha - theta_rad) + np.sin(alpha - theta_rad) * tan_p / F)
|
|
771
|
+
|
|
772
|
+
# Equation (23): Q
|
|
773
|
+
Q = (- Fv * sin_a - Fh * cos_a - (c / F) * dl + (Fv * cos_a - Fh * sin_a + u * dl) * tan_p / F) * ma
|
|
774
|
+
|
|
775
|
+
# Equation (26): y_Q
|
|
776
|
+
y_q = y_b + Mo / (Q * np.cos(theta_rad))
|
|
777
|
+
|
|
778
|
+
return Q, y_q
|
|
779
|
+
|
|
780
|
+
def compute_residuals(F, theta_rad):
|
|
781
|
+
"""Compute residuals R1 and R2 for given F and theta values."""
|
|
782
|
+
Q, y_q = compute_Q_and_yQ(F, theta_rad)
|
|
783
|
+
|
|
784
|
+
# Equation (27): R1 = sum(Q)
|
|
785
|
+
R1 = np.sum(Q)
|
|
786
|
+
|
|
787
|
+
# Equation (28): R2 = sum(Q * (x_b * sin(theta) - y_Q * cos(theta)))
|
|
788
|
+
R2 = np.sum(Q * (x_b * np.sin(theta_rad) - y_q * np.cos(theta_rad)))
|
|
789
|
+
|
|
790
|
+
return R1, R2, Q, y_q
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def compute_derivatives(F, theta_rad, Q, y_q):
|
|
794
|
+
|
|
795
|
+
"""Compute all derivatives needed for Newton's method."""
|
|
796
|
+
# Precompute trigonometric terms
|
|
797
|
+
cos_alpha_theta = np.cos(alpha - theta_rad)
|
|
798
|
+
sin_alpha_theta = np.sin(alpha - theta_rad)
|
|
799
|
+
cos_theta = np.cos(theta_rad)
|
|
800
|
+
sin_theta = np.sin(theta_rad)
|
|
801
|
+
|
|
802
|
+
# Constants for Q expression (Equations 45-49)
|
|
803
|
+
C1 = -Fv * sin_a - Fh * cos_a
|
|
804
|
+
C2 = -c * dl + (Fv * cos_a - Fh * sin_a + u * dl) * tan_p
|
|
805
|
+
C3 = cos_alpha_theta
|
|
806
|
+
C4 = sin_alpha_theta * tan_p
|
|
807
|
+
|
|
808
|
+
# Denominator for Q
|
|
809
|
+
denom_Q = C3 + C4 / F
|
|
810
|
+
|
|
811
|
+
# First-order partial derivatives of Q (Equations 50-51)
|
|
812
|
+
dQ_dF = (-1 / denom_Q**2) * ((denom_Q * C2 / F**2) - (C1 + C2 / F) * C4 / F**2)
|
|
813
|
+
|
|
814
|
+
dC3_dtheta = sin_alpha_theta # Equation (55)
|
|
815
|
+
dC4_dtheta = -cos_alpha_theta * tan_p # Equation (56)
|
|
816
|
+
dQ_dtheta = (-1 / denom_Q**2) * (C1 + C2 / F) * (dC3_dtheta + dC4_dtheta / F)
|
|
817
|
+
|
|
818
|
+
# Partial derivatives of y_Q (Equations 59-60)
|
|
819
|
+
dyQ_dF = (-1 / (Q * cos_theta)**2) * Mo * dQ_dF * cos_theta
|
|
820
|
+
dyQ_dtheta = (-1 / (Q * cos_theta)**2) * Mo * (dQ_dtheta * cos_theta - Q * sin_theta)
|
|
821
|
+
|
|
822
|
+
# First-order partial derivatives of R1 (Equations 35-36)
|
|
823
|
+
dR1_dF = np.sum(dQ_dF)
|
|
824
|
+
dR1_dtheta = np.sum(dQ_dtheta)
|
|
825
|
+
|
|
826
|
+
# First-order partial derivatives of R2 (Equations 40-41)
|
|
827
|
+
dR2_dF = np.sum(dQ_dF * (x_b * sin_theta - y_q * cos_theta)) - np.sum(Q * dyQ_dF * cos_theta)
|
|
828
|
+
dR2_dtheta = np.sum(dQ_dtheta * (x_b * sin_theta - y_q * cos_theta)) + np.sum(Q * (x_b * cos_theta + y_q * sin_theta - dyQ_dtheta * cos_theta))
|
|
829
|
+
|
|
830
|
+
return dR1_dF, dR1_dtheta, dR2_dF, dR2_dtheta
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
# Initial guesses
|
|
834
|
+
F0 = 1.5
|
|
835
|
+
if right_facing:
|
|
836
|
+
theta0_rad = np.radians(-8.0)
|
|
837
|
+
else:
|
|
838
|
+
theta0_rad = np.radians(8)
|
|
839
|
+
|
|
840
|
+
# Newton iteration
|
|
841
|
+
F = F0
|
|
842
|
+
theta_rad = theta0_rad
|
|
843
|
+
|
|
844
|
+
for iteration in range(max_iter):
|
|
845
|
+
# Compute residuals
|
|
846
|
+
R1, R2, Q, y_q = compute_residuals(F, theta_rad)
|
|
847
|
+
|
|
848
|
+
if debug_level >= 1:
|
|
849
|
+
if iteration == 0:
|
|
850
|
+
print(f"Iteration {1} - Initial: F = {F:.3f}, theta = {np.degrees(theta_rad):.3f}°, R1 = {R1:.6e}, R2 = {R2:.6e}")
|
|
851
|
+
else:
|
|
852
|
+
print(f"Iteration {iteration + 1} - Updated: F = {F:.3f}, theta = {np.degrees(theta_rad):.3f}°, R1 = {R1:.6e}, R2 = {R2:.6e}")
|
|
853
|
+
|
|
854
|
+
# Check convergence
|
|
855
|
+
if abs(R1) < tol and abs(R2) < tol:
|
|
856
|
+
if debug_level >= 1:
|
|
857
|
+
print(f"Converged in {iteration + 1} iterations, R1 = {R1:.6e}, R2 = {R2:.6e}")
|
|
858
|
+
break
|
|
859
|
+
|
|
860
|
+
# Compute derivatives
|
|
861
|
+
dR1_dF, dR1_dtheta, dR2_dF, dR2_dtheta = compute_derivatives(F, theta_rad, Q, y_q)
|
|
862
|
+
|
|
863
|
+
# Basic Newton method (Equations 31-32)
|
|
864
|
+
# Build Jacobian matrix
|
|
865
|
+
J = np.array([[dR1_dF, dR1_dtheta],
|
|
866
|
+
[dR2_dF, dR2_dtheta]])
|
|
867
|
+
|
|
868
|
+
# Check condition number for numerical stability
|
|
869
|
+
try:
|
|
870
|
+
cond_num = np.linalg.cond(J)
|
|
871
|
+
if cond_num > 1e12:
|
|
872
|
+
return False, f"Ill-conditioned Jacobian matrix (condition number: {cond_num:.2e})"
|
|
873
|
+
except:
|
|
874
|
+
return False, "Unable to compute Jacobian condition number"
|
|
875
|
+
|
|
876
|
+
# Solve using matrix form for better numerical stability
|
|
877
|
+
try:
|
|
878
|
+
delta_solution = np.linalg.solve(J, np.array([-R1, -R2]))
|
|
879
|
+
delta_F = delta_solution[0]
|
|
880
|
+
delta_theta = delta_solution[1]
|
|
881
|
+
except np.linalg.LinAlgError:
|
|
882
|
+
return False, "Singular Jacobian matrix in Newton iteration"
|
|
883
|
+
|
|
884
|
+
if debug_level >= 1:
|
|
885
|
+
print(f" Newton: delta_F = {delta_F:.3f}, delta_theta = {np.degrees(delta_theta):.3f}°, {delta_theta: .3f} (rad)")
|
|
886
|
+
|
|
887
|
+
# Add step size control to prevent large jumps
|
|
888
|
+
max_delta_F = 0.5 # Maximum allowed change in F per iteration
|
|
889
|
+
max_delta_theta = np.radians(20) # Maximum allowed change in theta per iteration (20 degrees)
|
|
890
|
+
|
|
891
|
+
# Apply step size limiting
|
|
892
|
+
if abs(delta_F) > max_delta_F:
|
|
893
|
+
delta_F = np.sign(delta_F) * max_delta_F
|
|
894
|
+
if debug_level >= 1:
|
|
895
|
+
print(f" Step limited: delta_F clamped to {delta_F:.3f}")
|
|
896
|
+
|
|
897
|
+
if abs(delta_theta) > max_delta_theta:
|
|
898
|
+
delta_theta = np.sign(delta_theta) * max_delta_theta
|
|
899
|
+
if debug_level >= 1:
|
|
900
|
+
print(f" Step limited: delta_theta clamped to {np.degrees(delta_theta):.3f}°")
|
|
901
|
+
|
|
902
|
+
# Update values
|
|
903
|
+
F += delta_F
|
|
904
|
+
theta_rad += delta_theta
|
|
905
|
+
|
|
906
|
+
# Ensure F stays positive
|
|
907
|
+
if F <= 0:
|
|
908
|
+
F = 0.1
|
|
909
|
+
|
|
910
|
+
# Limit theta to reasonable range
|
|
911
|
+
theta_rad = np.clip(theta_rad, -np.pi/2, np.pi/2)
|
|
912
|
+
|
|
913
|
+
# Check if we converged
|
|
914
|
+
if iteration >= max_iter - 1:
|
|
915
|
+
return False, "Spencer's method did not converge within the maximum number of iterations."
|
|
916
|
+
|
|
917
|
+
# Final computation of Q and y_q
|
|
918
|
+
R1, R2, Q, y_q = compute_residuals(F, theta_rad)
|
|
919
|
+
|
|
920
|
+
if debug_level >= 2:
|
|
921
|
+
ma = 1 / (np.cos(alpha - theta_rad) + np.sin(alpha - theta_rad) * tan_p / F)
|
|
922
|
+
slice_df['ma'] = ma
|
|
923
|
+
slice_df['Q'] = Q
|
|
924
|
+
slice_df['y_q'] = y_q
|
|
925
|
+
slice_df['Fh'] = Fh
|
|
926
|
+
slice_df['Fv'] = Fv
|
|
927
|
+
slice_df['Mo'] = Mo
|
|
928
|
+
# Print F and theta to 12 decimal places
|
|
929
|
+
print(f"F = {F:.12f}, theta = {np.degrees(theta_rad):.12f}°")
|
|
930
|
+
# Report the residuals
|
|
931
|
+
print(f"R1 = {R1:.6e}, R2 = {R2:.6e}")
|
|
932
|
+
# Debug print values per slice
|
|
933
|
+
for i in range(len(Q)):
|
|
934
|
+
print(f"Slice {i+1}: ma = {ma[i]:.3f}, Q = {Q[i]:.1f}, y_q = {y_q[i]:.2f}, Fh = {Fh[i]:.1f}, Fv = {Fv[i]:.1f}, Mo = {Mo[i]:.2f}")
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
# Convert theta to degrees for output
|
|
938
|
+
theta_opt = np.degrees(theta_rad)
|
|
939
|
+
|
|
940
|
+
# ========== END SOLUTION ==========
|
|
941
|
+
|
|
942
|
+
# Store theta in df
|
|
943
|
+
slice_df['theta'] = theta_opt
|
|
944
|
+
|
|
945
|
+
# --- Compute N_eff using Equation (18) ---
|
|
946
|
+
N_eff = - Fv * cos_a + Fh * sin_a + Q * np.sin(alpha - theta_rad) - u * dl
|
|
947
|
+
slice_df['n_eff'] = N_eff
|
|
948
|
+
|
|
949
|
+
# --- Compute interslice forces Z using Equation (67) ---
|
|
950
|
+
n = len(Q)
|
|
951
|
+
Z = np.zeros(n+1)
|
|
952
|
+
for i in range(n):
|
|
953
|
+
Z[i+1] = Z[i] - Q[i]
|
|
954
|
+
slice_df['z'] = Z[:-1] # Z_i acting on slice i's left face
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
# --- Compute line of thrust using Equation (69) ---
|
|
958
|
+
yt_l = np.zeros(n) # the y-coordinate of the line of thrust on the left side of the slice.
|
|
959
|
+
yt_r = np.zeros(n) # the y-coordinate of the line of thrust on the right side of the slice.
|
|
960
|
+
yt_l[0] = y_lb[0]
|
|
961
|
+
sin_theta = np.sin(theta_rad)
|
|
962
|
+
cos_theta = np.cos(theta_rad)
|
|
963
|
+
for i in range(n):
|
|
964
|
+
if i == n - 1:
|
|
965
|
+
yt_r[i] = y_rb[i]
|
|
966
|
+
else:
|
|
967
|
+
yt_r[i] = y_b[i] - ((Mo[i] - Z[i] * sin_theta * dx[i] / 2 - Z[i+1] * sin_theta * dx[i] / 2 - Z[i] * cos_theta * (yt_l[i] - y_b[i])) / (Z[i+1] * cos_theta))
|
|
968
|
+
yt_l[i+1] = yt_r[i]
|
|
969
|
+
slice_df['yt_l'] = yt_l
|
|
970
|
+
slice_df['yt_r'] = yt_r
|
|
971
|
+
|
|
972
|
+
# --- Return results ---
|
|
973
|
+
results = {}
|
|
974
|
+
results['method'] = 'spencer'
|
|
975
|
+
results['FS'] = F
|
|
976
|
+
results['theta'] = theta_opt
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
return True, results
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
def spencer_OLD(df, tol=1e-4, max_iter = 100, debug_level=2):
|
|
983
|
+
"""
|
|
984
|
+
Spencer's Method using Steve G. Wright's formulation from the UTEXAS v2 user manual.
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
Parameters:
|
|
988
|
+
df (pd.DataFrame): must contain columns
|
|
989
|
+
'alpha' (slice base inclination, degrees),
|
|
990
|
+
'phi' (slice friction angle, degrees),
|
|
991
|
+
'c' (cohesion),
|
|
992
|
+
'dl' (slice base length),
|
|
993
|
+
'w' (slice weight),
|
|
994
|
+
'u' (pore force per unit length),
|
|
995
|
+
'd' (distributed load),
|
|
996
|
+
'beta' (distributed load inclination, degrees),
|
|
997
|
+
'kw' (seismic force),
|
|
998
|
+
't' (tension crack water force),
|
|
999
|
+
'p' (reinforcement force)
|
|
1000
|
+
|
|
1001
|
+
Returns:
|
|
1002
|
+
float: FS where FS_force = FS_moment
|
|
1003
|
+
float: beta (degrees)
|
|
1004
|
+
bool: converged flag
|
|
1005
|
+
"""
|
|
1006
|
+
|
|
1007
|
+
alpha = np.radians(df['alpha'].values) # slice base inclination, degrees
|
|
1008
|
+
phi = np.radians(df['phi'].values) # slice friction angle, degrees
|
|
1009
|
+
c = df['c'].values # cohesion
|
|
1010
|
+
dx = df['dx'].values # slice width
|
|
1011
|
+
dl = df['dl'].values # slice base length
|
|
1012
|
+
W = df['w'].values # slice weight
|
|
1013
|
+
u = df['u'].values # pore presssure
|
|
1014
|
+
x_c = df['x_c'].values # center of base x-coordinate
|
|
1015
|
+
y_cb = df['y_cb'].values # center of base y-coordinate
|
|
1016
|
+
y_lb = df['y_lb'].values # left side base y-coordinate
|
|
1017
|
+
y_rb = df['y_rb'].values # right side base y-coordinate
|
|
1018
|
+
P = df['dload'].values # distributed load resultant
|
|
1019
|
+
beta = np.radians(df['beta'].values) # distributed load inclination, degrees
|
|
1020
|
+
kw = df['kw'].values # seismic force
|
|
1021
|
+
V = df['t'].values # tension crack water force
|
|
1022
|
+
y_v = df['y_t'].values # tension crack water force y-coordinate
|
|
1023
|
+
R = df['p'].values # reinforcement force
|
|
1024
|
+
|
|
1025
|
+
# For now, we assume that reinforcement is flexible and therefore is parallel to the failure surface
|
|
1026
|
+
# at the bottom of the slice. Therefore, the psi value used in the derivation is set to alpha,
|
|
1027
|
+
# and the point of action is the center of the base of the slice.
|
|
1028
|
+
psi = alpha # psi is the angle of the reinforcement force from the horizontal
|
|
1029
|
+
y_r = y_cb # y_r is the y-coordinate of the point of action of the reinforcement
|
|
1030
|
+
x_r = x_c # x_r is the x-coordinate of the point of action of the reinforcement
|
|
1031
|
+
|
|
1032
|
+
# use variable names to match the derivation.
|
|
1033
|
+
x_p = df['d_x'].values # distributed load x-coordinate
|
|
1034
|
+
y_p = df['d_y'].values # distributed load y-coordinate
|
|
1035
|
+
y_k = df['y_cg'].values # seismic force y-coordinate
|
|
1036
|
+
x_b = x_c # center of base x-coordinate
|
|
1037
|
+
y_b = y_cb # center of base y-coordinate
|
|
1038
|
+
|
|
1039
|
+
# pre-compute the trigonometric functions
|
|
1040
|
+
cos_a = np.cos(alpha) # cos(alpha)
|
|
1041
|
+
sin_a = np.sin(alpha) # sin(alpha)
|
|
1042
|
+
tan_p = np.tan(phi) # tan(phi)
|
|
1043
|
+
cos_b = np.cos(beta) # cos(beta)
|
|
1044
|
+
sin_b = np.sin(beta) # sin(beta)
|
|
1045
|
+
sin_psi = np.sin(psi) # sin(psi)
|
|
1046
|
+
cos_psi = np.cos(psi) # cos(psi)
|
|
1047
|
+
|
|
1048
|
+
Fh = - kw - V + P * sin_b + R * cos_psi # Equation (1)
|
|
1049
|
+
Fv = - W - P * cos_b + R * sin_psi # Equation (2)
|
|
1050
|
+
Mo = - P * sin_b * (y_p - y_b) - P * cos_b * (x_p - x_b) \
|
|
1051
|
+
+ kw * (y_k - y_b) + V * (y_v - y_b) - R * cos_psi * (y_r - y_b) + R * sin_psi * (x_r - x_b) # Equation (3)
|
|
1052
|
+
|
|
1053
|
+
def compute_Q(F, theta_rad):
|
|
1054
|
+
ma = 1 / (np.cos(alpha - theta_rad) + np.sin(alpha - theta_rad) * tan_p / F) # Equation (24)
|
|
1055
|
+
Q = (- Fv * sin_a - Fh * cos_a - (c / F) * dl + (Fv * cos_a - Fh * sin_a + u * dl) * tan_p / F) * ma # Equation (23)
|
|
1056
|
+
y_q = y_b + Mo / (Q * np.cos(theta_rad)) # Equation (26)
|
|
1057
|
+
return Q, y_q
|
|
1058
|
+
|
|
1059
|
+
fs_min = 0.01
|
|
1060
|
+
fs_max = 20.0
|
|
1061
|
+
|
|
1062
|
+
def fs_force(theta_rad):
|
|
1063
|
+
def residual(F):
|
|
1064
|
+
Q, y_q = compute_Q(F, theta_rad)
|
|
1065
|
+
return Q.sum() # Equation (15)
|
|
1066
|
+
result = minimize_scalar(lambda F: abs(residual(F)), bounds=(fs_min, fs_max), method='bounded', options={'xatol': tol})
|
|
1067
|
+
return result.x
|
|
1068
|
+
|
|
1069
|
+
def fs_moment(theta_rad):
|
|
1070
|
+
def residual(F):
|
|
1071
|
+
Q, y_q = compute_Q(F, theta_rad)
|
|
1072
|
+
return np.sum(Q * (x_b * np.sin(theta_rad) - y_q * np.cos(theta_rad))) # Equation (16)
|
|
1073
|
+
result = minimize_scalar(lambda F: abs(residual(F)), bounds=(fs_min, fs_max), method='bounded', options={'xatol': tol})
|
|
1074
|
+
return result.x
|
|
1075
|
+
|
|
1076
|
+
def fs_difference(theta_deg):
|
|
1077
|
+
theta_rad = np.radians(theta_deg)
|
|
1078
|
+
Ff = fs_force(theta_rad)
|
|
1079
|
+
Fm = fs_moment(theta_rad)
|
|
1080
|
+
return Ff - Fm
|
|
1081
|
+
|
|
1082
|
+
# Robust theta root-finding with multiple strategies
|
|
1083
|
+
theta_opt = None
|
|
1084
|
+
convergence_error = None
|
|
1085
|
+
|
|
1086
|
+
# Strategy 1: Try multiple starting points for Newton's method
|
|
1087
|
+
|
|
1088
|
+
newton_starting_points = [3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
|
|
1089
|
+
|
|
1090
|
+
# Pre-evaluate fs_difference for all starting points and sort by absolute value
|
|
1091
|
+
starting_point_evaluations = []
|
|
1092
|
+
for theta_guess in newton_starting_points:
|
|
1093
|
+
try:
|
|
1094
|
+
fs_diff = fs_difference(theta_guess)
|
|
1095
|
+
starting_point_evaluations.append((theta_guess, abs(fs_diff), fs_diff))
|
|
1096
|
+
except Exception as e:
|
|
1097
|
+
if debug_level >= 1:
|
|
1098
|
+
print(f"Failed to evaluate fs_difference at {theta_guess:.1f} deg: {e}")
|
|
1099
|
+
continue
|
|
1100
|
+
|
|
1101
|
+
# Sort by absolute value of fs_difference (smallest first)
|
|
1102
|
+
starting_point_evaluations.sort(key=lambda x: x[1])
|
|
1103
|
+
|
|
1104
|
+
if debug_level >= 1:
|
|
1105
|
+
print("Starting points sorted by |fs_difference|:")
|
|
1106
|
+
for theta_guess, abs_fs_diff, fs_diff in starting_point_evaluations:
|
|
1107
|
+
print(f" {theta_guess:.1f}°: |fs_diff| = {abs_fs_diff:.6f}, fs_diff = {fs_diff:.6f}")
|
|
1108
|
+
|
|
1109
|
+
for theta_guess, abs_fs_diff, fs_diff in starting_point_evaluations:
|
|
1110
|
+
try:
|
|
1111
|
+
if debug_level >= 1:
|
|
1112
|
+
print(f"Trying Newton's method with initial guess {theta_guess:.1f} deg (|fs_diff| = {abs_fs_diff:.6f})")
|
|
1113
|
+
theta_candidate = newton(fs_difference, x0=theta_guess, tol=tol, maxiter=max_iter)
|
|
1114
|
+
|
|
1115
|
+
# Check if the solution is valid
|
|
1116
|
+
if (abs(theta_candidate) <= 59 and
|
|
1117
|
+
abs(fs_difference(theta_candidate)) <= 0.01 and
|
|
1118
|
+
fs_force(np.radians(theta_candidate)) < fs_max - 1e-3):
|
|
1119
|
+
theta_opt = theta_candidate
|
|
1120
|
+
if debug_level >= 1:
|
|
1121
|
+
print(f"Newton's method succeeded with starting point {theta_guess:.1f} deg")
|
|
1122
|
+
break
|
|
1123
|
+
except Exception as e:
|
|
1124
|
+
if debug_level >= 1:
|
|
1125
|
+
print(f"Newton's method failed with starting point {theta_guess:.1f} deg: {e}")
|
|
1126
|
+
continue
|
|
1127
|
+
|
|
1128
|
+
# Strategy 2: If Newton's method failed, try adaptive grid search
|
|
1129
|
+
if theta_opt is None:
|
|
1130
|
+
if debug_level >= 1:
|
|
1131
|
+
print("Newton's method failed for all starting points, trying adaptive grid search...")
|
|
1132
|
+
|
|
1133
|
+
# First, do a coarse sweep to identify promising regions
|
|
1134
|
+
theta_coarse = np.linspace(-60, 60, 121) # More points for better resolution
|
|
1135
|
+
fs_diff_coarse = []
|
|
1136
|
+
|
|
1137
|
+
for theta in theta_coarse:
|
|
1138
|
+
try:
|
|
1139
|
+
fs_diff_coarse.append(fs_difference(theta))
|
|
1140
|
+
except Exception:
|
|
1141
|
+
fs_diff_coarse.append(np.nan)
|
|
1142
|
+
|
|
1143
|
+
fs_diff_coarse = np.array(fs_diff_coarse)
|
|
1144
|
+
|
|
1145
|
+
# Find regions where sign changes occur
|
|
1146
|
+
sign_changes = []
|
|
1147
|
+
for i in range(len(fs_diff_coarse) - 1):
|
|
1148
|
+
if (not np.isnan(fs_diff_coarse[i]) and
|
|
1149
|
+
not np.isnan(fs_diff_coarse[i+1]) and
|
|
1150
|
+
fs_diff_coarse[i] * fs_diff_coarse[i+1] < 0):
|
|
1151
|
+
sign_changes.append((theta_coarse[i], theta_coarse[i+1]))
|
|
1152
|
+
|
|
1153
|
+
# Try root_scalar on each bracket
|
|
1154
|
+
for bracket in sign_changes:
|
|
1155
|
+
try:
|
|
1156
|
+
if debug_level >= 1:
|
|
1157
|
+
print(f"Trying root_scalar with bracket {bracket}")
|
|
1158
|
+
sol = root_scalar(fs_difference, bracket=bracket, method='brentq', xtol=tol)
|
|
1159
|
+
theta_candidate = sol.root
|
|
1160
|
+
|
|
1161
|
+
# Check if the solution is valid
|
|
1162
|
+
if (abs(theta_candidate) <= 59 and
|
|
1163
|
+
abs(fs_difference(theta_candidate)) <= 0.01 and
|
|
1164
|
+
fs_force(np.radians(theta_candidate)) < fs_max - 1e-3):
|
|
1165
|
+
theta_opt = theta_candidate
|
|
1166
|
+
if debug_level >= 1:
|
|
1167
|
+
print(f"root_scalar succeeded with bracket {bracket}")
|
|
1168
|
+
break
|
|
1169
|
+
except Exception as e:
|
|
1170
|
+
if debug_level >= 1:
|
|
1171
|
+
print(f"root_scalar failed with bracket {bracket}: {e}")
|
|
1172
|
+
continue
|
|
1173
|
+
|
|
1174
|
+
# Strategy 3: If still no solution, try global optimization
|
|
1175
|
+
if theta_opt is None:
|
|
1176
|
+
if debug_level >= 1:
|
|
1177
|
+
print("All root-finding methods failed, trying global optimization...")
|
|
1178
|
+
|
|
1179
|
+
try:
|
|
1180
|
+
# Use minimize_scalar to find the minimum of |fs_difference|
|
|
1181
|
+
result = minimize_scalar(
|
|
1182
|
+
lambda theta: abs(fs_difference(theta)),
|
|
1183
|
+
bounds=(-60, 60),
|
|
1184
|
+
method='bounded',
|
|
1185
|
+
options={'xatol': tol}
|
|
1186
|
+
)
|
|
1187
|
+
|
|
1188
|
+
if result.success and abs(fs_difference(result.x)) <= 0.01:
|
|
1189
|
+
theta_opt = result.x
|
|
1190
|
+
if debug_level >= 1:
|
|
1191
|
+
print(f"Global optimization succeeded with theta = {theta_opt:.6f} deg")
|
|
1192
|
+
else:
|
|
1193
|
+
convergence_error = f"Global optimization failed: {result.message}"
|
|
1194
|
+
|
|
1195
|
+
except Exception as e:
|
|
1196
|
+
convergence_error = f"Global optimization failed: {e}"
|
|
1197
|
+
|
|
1198
|
+
# Check if we found a solution
|
|
1199
|
+
if theta_opt is None:
|
|
1200
|
+
if convergence_error:
|
|
1201
|
+
return False, f"Spencer's method failed to converge: {convergence_error}"
|
|
1202
|
+
else:
|
|
1203
|
+
return False, "Spencer's method: No valid solution found with any method."
|
|
1204
|
+
|
|
1205
|
+
theta_rad = np.radians(theta_opt)
|
|
1206
|
+
FS_force = fs_force(theta_rad)
|
|
1207
|
+
FS_moment = fs_moment(theta_rad)
|
|
1208
|
+
|
|
1209
|
+
df['theta'] = theta_opt # store theta in df.
|
|
1210
|
+
|
|
1211
|
+
# --- Compute N_eff ---
|
|
1212
|
+
Q, y_q = compute_Q(FS_force, theta_rad)
|
|
1213
|
+
N_eff = - Fv * cos_a + Fh * sin_a + Q * np.sin(alpha - theta_rad) - u * dl # Equation (18)
|
|
1214
|
+
|
|
1215
|
+
# --- compute interslice forces Z ---
|
|
1216
|
+
n = len(Q)
|
|
1217
|
+
Z = np.zeros(n+1)
|
|
1218
|
+
for i in range(n):
|
|
1219
|
+
Z[i+1] = Z[i] - Q[i]
|
|
1220
|
+
|
|
1221
|
+
# --- store back into df ---
|
|
1222
|
+
df['z'] = Z[:-1] # Z_i acting on slice i's left face
|
|
1223
|
+
df['n_eff'] = N_eff
|
|
1224
|
+
|
|
1225
|
+
# --- compute line of thrust ---
|
|
1226
|
+
yt_l = np.zeros(n) # the y-coordinate of the line of thrust on the left side of the slice.
|
|
1227
|
+
yt_r = np.zeros(n) # the y-coordinate of the line of thrust on the right side of the slice.
|
|
1228
|
+
yt_l[0] = y_lb[0]
|
|
1229
|
+
sin_theta = np.sin(theta_rad)
|
|
1230
|
+
cos_theta = np.cos(theta_rad)
|
|
1231
|
+
for i in range(n):
|
|
1232
|
+
if i == n - 1:
|
|
1233
|
+
yt_r[i] = y_rb[i]
|
|
1234
|
+
else:
|
|
1235
|
+
yt_r[i] = y_b[i] - ((Mo[i] - Z[i] * sin_theta * dx[i] / 2 - Z[i+1] * sin_theta * dx[i] / 2 - Z[i] * cos_theta * (yt_l[i] - y_b[i])) / (Z[i+1] * cos_theta)) # Equation (30)
|
|
1236
|
+
yt_l[i+1] = yt_r[i]
|
|
1237
|
+
df['yt_l'] = yt_l
|
|
1238
|
+
df['yt_r'] = yt_r
|
|
1239
|
+
|
|
1240
|
+
# --- Check convergence ---
|
|
1241
|
+
converged = abs(FS_force - FS_moment) < tol
|
|
1242
|
+
if not converged:
|
|
1243
|
+
return False, "Spencer's method did not converge within the maximum number of iterations."
|
|
1244
|
+
else:
|
|
1245
|
+
results = {}
|
|
1246
|
+
results['method'] = 'spencer'
|
|
1247
|
+
results['FS'] = FS_force
|
|
1248
|
+
results['theta'] = theta_opt
|
|
1249
|
+
|
|
1250
|
+
# debug print values per slice
|
|
1251
|
+
if debug_level >= 2:
|
|
1252
|
+
for i in range(len(Q)):
|
|
1253
|
+
print(f"Slice {i+1}: Q = {Q[i]:.1f}, y_q = {y_q[i]:.2f}, Fh = {Fh[i]:.1f}, Fv = {Fv[i]:.1f}, Mo = {Mo[i]:.2f}")
|
|
1254
|
+
|
|
1255
|
+
return True, results
|
|
1256
|
+
|
|
1257
|
+
|
|
1258
|
+
|
|
1259
|
+
|