weac 2.6.4__py3-none-any.whl → 3.0.1__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.
- weac/__init__.py +2 -14
- weac/analysis/__init__.py +23 -0
- weac/analysis/analyzer.py +790 -0
- weac/analysis/criteria_evaluator.py +1169 -0
- weac/analysis/plotter.py +1922 -0
- weac/components/__init__.py +21 -0
- weac/components/config.py +33 -0
- weac/components/criteria_config.py +86 -0
- weac/components/layer.py +284 -0
- weac/components/model_input.py +103 -0
- weac/components/scenario_config.py +72 -0
- weac/components/segment.py +31 -0
- weac/constants.py +37 -0
- weac/core/__init__.py +10 -0
- weac/core/eigensystem.py +405 -0
- weac/core/field_quantities.py +273 -0
- weac/core/scenario.py +200 -0
- weac/core/slab.py +149 -0
- weac/core/slab_touchdown.py +363 -0
- weac/core/system_model.py +413 -0
- weac/core/unknown_constants_solver.py +444 -0
- weac/logging_config.py +39 -0
- weac/utils/__init__.py +0 -0
- weac/utils/geldsetzer.py +166 -0
- weac/utils/misc.py +127 -0
- weac/utils/snow_types.py +82 -0
- weac/utils/snowpilot_parser.py +332 -0
- {weac-2.6.4.dist-info → weac-3.0.1.dist-info}/METADATA +196 -64
- weac-3.0.1.dist-info/RECORD +32 -0
- weac-3.0.1.dist-info/licenses/LICENSE +21 -0
- weac/eigensystem.py +0 -658
- weac/inverse.py +0 -51
- weac/layered.py +0 -64
- weac/mixins.py +0 -2083
- weac/plot.py +0 -675
- weac/tools.py +0 -334
- weac-2.6.4.dist-info/RECORD +0 -12
- weac-2.6.4.dist-info/licenses/LICENSE +0 -24
- {weac-2.6.4.dist-info → weac-3.0.1.dist-info}/WHEEL +0 -0
- {weac-2.6.4.dist-info → weac-3.0.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,1169 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module provides the CriteriaEvaluator class, which is used to evaluate various
|
|
3
|
+
fracture criteria based on the model results.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
# Standard library imports
|
|
7
|
+
import copy
|
|
8
|
+
import logging
|
|
9
|
+
import time
|
|
10
|
+
import warnings
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import List, Optional, Union
|
|
13
|
+
|
|
14
|
+
# Third party imports
|
|
15
|
+
import numpy as np
|
|
16
|
+
from scipy.optimize import brentq, root_scalar
|
|
17
|
+
|
|
18
|
+
from weac.analysis.analyzer import Analyzer
|
|
19
|
+
|
|
20
|
+
# weac imports
|
|
21
|
+
from weac.components import (
|
|
22
|
+
CriteriaConfig,
|
|
23
|
+
ScenarioConfig,
|
|
24
|
+
Segment,
|
|
25
|
+
WeakLayer,
|
|
26
|
+
)
|
|
27
|
+
from weac.constants import RHO_ICE
|
|
28
|
+
from weac.core.system_model import SystemModel
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class CoupledCriterionHistory:
|
|
35
|
+
"""Stores the history of the coupled criterion evaluation."""
|
|
36
|
+
|
|
37
|
+
skier_weights: List[float]
|
|
38
|
+
crack_lengths: List[float]
|
|
39
|
+
incr_energies: List[np.ndarray]
|
|
40
|
+
g_deltas: List[float]
|
|
41
|
+
dist_maxs: List[float]
|
|
42
|
+
dist_mins: List[float]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class CoupledCriterionResult:
|
|
47
|
+
"""
|
|
48
|
+
Holds the results of the coupled criterion evaluation.
|
|
49
|
+
|
|
50
|
+
Attributes:
|
|
51
|
+
-----------
|
|
52
|
+
converged : bool
|
|
53
|
+
Whether the algorithm converged.
|
|
54
|
+
message : str
|
|
55
|
+
The message of the evaluation.
|
|
56
|
+
self_collapse : bool
|
|
57
|
+
Whether the system collapsed.
|
|
58
|
+
pure_stress_criteria : bool
|
|
59
|
+
Whether the pure stress criteria is satisfied.
|
|
60
|
+
critical_skier_weight : float
|
|
61
|
+
The critical skier weight.
|
|
62
|
+
initial_critical_skier_weight : float
|
|
63
|
+
The initial critical skier weight.
|
|
64
|
+
crack_length : float
|
|
65
|
+
The crack length.
|
|
66
|
+
g_delta : float
|
|
67
|
+
The g_delta value.
|
|
68
|
+
dist_ERR_envelope : float
|
|
69
|
+
The distance to the ERR envelope.
|
|
70
|
+
iterations : int
|
|
71
|
+
The number of iterations.
|
|
72
|
+
history : CoupledCriterionHistory
|
|
73
|
+
The history of the evaluation.
|
|
74
|
+
final_system : SystemModel
|
|
75
|
+
The final system model.
|
|
76
|
+
max_dist_stress : float
|
|
77
|
+
The maximum distance to failure.
|
|
78
|
+
min_dist_stress : float
|
|
79
|
+
The minimum distance to failure.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
converged: bool
|
|
83
|
+
message: str
|
|
84
|
+
self_collapse: bool
|
|
85
|
+
pure_stress_criteria: bool
|
|
86
|
+
critical_skier_weight: float
|
|
87
|
+
initial_critical_skier_weight: float
|
|
88
|
+
crack_length: float
|
|
89
|
+
g_delta: float
|
|
90
|
+
dist_ERR_envelope: float
|
|
91
|
+
iterations: int
|
|
92
|
+
history: Optional[CoupledCriterionHistory]
|
|
93
|
+
final_system: SystemModel
|
|
94
|
+
max_dist_stress: float
|
|
95
|
+
min_dist_stress: float
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class SSERRResult:
|
|
100
|
+
"""
|
|
101
|
+
Holds the results of the SSERR evaluation.
|
|
102
|
+
|
|
103
|
+
Attributes:
|
|
104
|
+
-----------
|
|
105
|
+
converged : bool
|
|
106
|
+
Whether the algorithm converged.
|
|
107
|
+
message : str
|
|
108
|
+
The message of the evaluation.
|
|
109
|
+
touchdown_distance : float
|
|
110
|
+
The touchdown distance.
|
|
111
|
+
SSERR : float
|
|
112
|
+
The Steady-State Energy Release Rate calculated with the
|
|
113
|
+
touchdown distance from G_I and G_II.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
converged: bool
|
|
117
|
+
message: str
|
|
118
|
+
touchdown_distance: float
|
|
119
|
+
SSERR: float
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass
|
|
123
|
+
class FindMinimumForceResult:
|
|
124
|
+
"""
|
|
125
|
+
Holds the results of the find_minimum_force evaluation.
|
|
126
|
+
|
|
127
|
+
Attributes:
|
|
128
|
+
-----------
|
|
129
|
+
success : bool
|
|
130
|
+
Whether the algorithm converged.
|
|
131
|
+
critical_skier_weight : float
|
|
132
|
+
The critical skier weight.
|
|
133
|
+
new_segments : List[Segment]
|
|
134
|
+
The new segments.
|
|
135
|
+
old_segments : List[Segment]
|
|
136
|
+
The old segments.
|
|
137
|
+
iterations : int
|
|
138
|
+
The number of iterations.
|
|
139
|
+
max_dist_stress : float
|
|
140
|
+
The maximum distance to failure.
|
|
141
|
+
min_dist_stress : float
|
|
142
|
+
The minimum distance to failure.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
success: bool
|
|
146
|
+
critical_skier_weight: float
|
|
147
|
+
new_segments: List[Segment]
|
|
148
|
+
old_segments: List[Segment]
|
|
149
|
+
iterations: Optional[int]
|
|
150
|
+
max_dist_stress: float
|
|
151
|
+
min_dist_stress: float
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class CriteriaEvaluator:
|
|
155
|
+
"""
|
|
156
|
+
Provides methods for stability analysis of layered slabs on compliant
|
|
157
|
+
elastic foundations, based on the logic from criterion_check.py.
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
criteria_config: CriteriaConfig
|
|
161
|
+
|
|
162
|
+
def __init__(self, criteria_config: CriteriaConfig):
|
|
163
|
+
"""
|
|
164
|
+
Initializes the evaluator with global simulation and criteria configurations.
|
|
165
|
+
|
|
166
|
+
Parameters:
|
|
167
|
+
----------
|
|
168
|
+
criteria_config (CriteriaConfig): The configuration for failure criteria.
|
|
169
|
+
"""
|
|
170
|
+
self.criteria_config = criteria_config
|
|
171
|
+
|
|
172
|
+
def fracture_toughness_envelope(
|
|
173
|
+
self, G_I: float | np.ndarray, G_II: float | np.ndarray, weak_layer: WeakLayer
|
|
174
|
+
) -> float | np.ndarray:
|
|
175
|
+
"""
|
|
176
|
+
Evaluates the fracture toughness criterion for a given combination of
|
|
177
|
+
Mode I (G_I) and Mode II (G_II) energy release rates.
|
|
178
|
+
|
|
179
|
+
The criterion is defined as:
|
|
180
|
+
g_delta = (|G_I| / G_Ic)^gn + (|G_II| / G_IIc)^gm
|
|
181
|
+
|
|
182
|
+
A value of 1 indicates the boundary of the fracture toughness envelope is reached.
|
|
183
|
+
|
|
184
|
+
Parameters:
|
|
185
|
+
-----------
|
|
186
|
+
G_I : float
|
|
187
|
+
Mode I energy release rate (ERR) in J/m².
|
|
188
|
+
G_II : float
|
|
189
|
+
Mode II energy release rate (ERR) in J/m².
|
|
190
|
+
weak_layer : WeakLayer
|
|
191
|
+
The weak layer object containing G_Ic and G_IIc.
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
-------
|
|
195
|
+
g_delta : float
|
|
196
|
+
Evaluation of the fracture toughness envelope.
|
|
197
|
+
"""
|
|
198
|
+
g_delta = (np.abs(G_I) / weak_layer.G_Ic) ** self.criteria_config.gn + (
|
|
199
|
+
np.abs(G_II) / weak_layer.G_IIc
|
|
200
|
+
) ** self.criteria_config.gm
|
|
201
|
+
|
|
202
|
+
return g_delta
|
|
203
|
+
|
|
204
|
+
def stress_envelope(
|
|
205
|
+
self,
|
|
206
|
+
sigma: Union[float, np.ndarray],
|
|
207
|
+
tau: Union[float, np.ndarray],
|
|
208
|
+
weak_layer: WeakLayer,
|
|
209
|
+
method: Optional[str] = None,
|
|
210
|
+
) -> np.ndarray:
|
|
211
|
+
"""
|
|
212
|
+
Evaluate the stress envelope for given stress components.
|
|
213
|
+
Weak Layer failure is defined as the stress envelope crossing 1.
|
|
214
|
+
|
|
215
|
+
Parameters
|
|
216
|
+
----------
|
|
217
|
+
sigma: ndarray
|
|
218
|
+
Normal stress components (kPa).
|
|
219
|
+
tau: ndarray
|
|
220
|
+
Shear stress components (kPa).
|
|
221
|
+
weak_layer: WeakLayer
|
|
222
|
+
The weak layer object, used to get density.
|
|
223
|
+
method: str, optional
|
|
224
|
+
Method to use for the stress envelope. Defaults to None.
|
|
225
|
+
|
|
226
|
+
Returns
|
|
227
|
+
-------
|
|
228
|
+
stress_envelope: ndarray
|
|
229
|
+
Stress envelope evaluation values in [0, inf].
|
|
230
|
+
Values > 1 indicate failure.
|
|
231
|
+
|
|
232
|
+
Notes
|
|
233
|
+
-----
|
|
234
|
+
- Mede's envelopes ('mede_s-RG1', 'mede_s-RG2', 'mede_s-FCDH') are derived
|
|
235
|
+
from the work of Mede et al. (2018), "Snow Failure Modes Under Mixed
|
|
236
|
+
Loading," published in Geophysical Research Letters.
|
|
237
|
+
- Schöttner's envelope ('schottner') is based on the preprint by Schöttner
|
|
238
|
+
et al. (2025), "On the Compressive Strength of Weak Snow Layers of
|
|
239
|
+
Depth Hoar".
|
|
240
|
+
- The 'adam_unpublished' envelope scales with weak layer density linearly
|
|
241
|
+
(compared to density baseline) by a 'scaling_factor'
|
|
242
|
+
(weak layer density / density baseline), unless modified by
|
|
243
|
+
'order_of_magnitude'.
|
|
244
|
+
- Mede's criteria ('mede_s-RG1', 'mede_s-RG2', 'mede_s-FCDH') define
|
|
245
|
+
failure based on a piecewise function of stress ranges.
|
|
246
|
+
"""
|
|
247
|
+
sigma = np.abs(np.asarray(sigma))
|
|
248
|
+
tau = np.abs(np.asarray(tau))
|
|
249
|
+
results = np.zeros_like(sigma)
|
|
250
|
+
|
|
251
|
+
envelope_method = (
|
|
252
|
+
method
|
|
253
|
+
if method is not None
|
|
254
|
+
else self.criteria_config.stress_envelope_method
|
|
255
|
+
)
|
|
256
|
+
density = weak_layer.rho
|
|
257
|
+
sigma_c = weak_layer.sigma_c
|
|
258
|
+
tau_c = weak_layer.tau_c
|
|
259
|
+
fn = self.criteria_config.fn
|
|
260
|
+
fm = self.criteria_config.fm
|
|
261
|
+
order_of_magnitude = self.criteria_config.order_of_magnitude
|
|
262
|
+
scaling_factor = self.criteria_config.scaling_factor
|
|
263
|
+
|
|
264
|
+
def mede_common_calculations(sigma, tau, p0, tau_T, p_T):
|
|
265
|
+
results_local = np.zeros_like(sigma)
|
|
266
|
+
in_first_range = (sigma >= (p_T - p0)) & (sigma <= p_T)
|
|
267
|
+
in_second_range = sigma > p_T
|
|
268
|
+
results_local[in_first_range] = (
|
|
269
|
+
-tau[in_first_range] * (p0 / (tau_T * p_T))
|
|
270
|
+
+ sigma[in_first_range] * (1 / p_T)
|
|
271
|
+
+ p0 / p_T
|
|
272
|
+
)
|
|
273
|
+
results_local[in_second_range] = (tau[in_second_range] ** 2) + (
|
|
274
|
+
(tau_T / p0) ** 2
|
|
275
|
+
) * ((sigma[in_second_range] - p_T) ** 2)
|
|
276
|
+
return results_local
|
|
277
|
+
|
|
278
|
+
if envelope_method == "adam_unpublished":
|
|
279
|
+
if scaling_factor > 1:
|
|
280
|
+
order_of_magnitude = 0.7
|
|
281
|
+
scaling_factor = max(scaling_factor, 0.55)
|
|
282
|
+
scaled_sigma_c = sigma_c * (scaling_factor**order_of_magnitude)
|
|
283
|
+
scaled_tau_c = tau_c * (scaling_factor**order_of_magnitude)
|
|
284
|
+
return (sigma / scaled_sigma_c) ** fn + (tau / scaled_tau_c) ** fm
|
|
285
|
+
|
|
286
|
+
if envelope_method == "schottner":
|
|
287
|
+
sigma_y = 2000
|
|
288
|
+
scaled_sigma_c = sigma_y * 13 * (density / RHO_ICE) ** order_of_magnitude
|
|
289
|
+
scaled_tau_c = tau_c * (scaled_sigma_c / sigma_c)
|
|
290
|
+
return (sigma / scaled_sigma_c) ** fn + (tau / scaled_tau_c) ** fm
|
|
291
|
+
|
|
292
|
+
if envelope_method == "mede_s-RG1":
|
|
293
|
+
p0, tau_T, p_T = 7.00, 3.53, 1.49
|
|
294
|
+
results = mede_common_calculations(sigma, tau, p0, tau_T, p_T)
|
|
295
|
+
return results
|
|
296
|
+
|
|
297
|
+
if envelope_method == "mede_s-RG2":
|
|
298
|
+
p0, tau_T, p_T = 2.33, 1.22, 0.19
|
|
299
|
+
results = mede_common_calculations(sigma, tau, p0, tau_T, p_T)
|
|
300
|
+
return results
|
|
301
|
+
|
|
302
|
+
if envelope_method == "mede_s-FCDH":
|
|
303
|
+
p0, tau_T, p_T = 1.45, 0.61, 0.17
|
|
304
|
+
results = mede_common_calculations(sigma, tau, p0, tau_T, p_T)
|
|
305
|
+
return results
|
|
306
|
+
|
|
307
|
+
raise ValueError(f"Invalid envelope type: {envelope_method}")
|
|
308
|
+
|
|
309
|
+
def evaluate_coupled_criterion(
|
|
310
|
+
self,
|
|
311
|
+
system: SystemModel,
|
|
312
|
+
max_iterations: int = 25,
|
|
313
|
+
damping_ERR: float = 0.0,
|
|
314
|
+
tolerance_ERR: float = 0.002,
|
|
315
|
+
tolerance_stress: float = 0.005,
|
|
316
|
+
print_call_stats: bool = False,
|
|
317
|
+
_recursion_depth: int = 0,
|
|
318
|
+
) -> CoupledCriterionResult:
|
|
319
|
+
"""
|
|
320
|
+
Evaluates the coupled criterion for anticrack nucleation, finding the
|
|
321
|
+
critical combination of skier weight and anticrack length.
|
|
322
|
+
|
|
323
|
+
Parameters:
|
|
324
|
+
----------
|
|
325
|
+
system: SystemModel
|
|
326
|
+
The system model.
|
|
327
|
+
max_iterations: int
|
|
328
|
+
Max iterations for the solver. Defaults to 25.
|
|
329
|
+
damping_ERR: float
|
|
330
|
+
damping factor for the ERR criterion. Defaults to 0.0.
|
|
331
|
+
tolerance_ERR: float, optional
|
|
332
|
+
Tolerance for g_delta convergence. Defaults to 0.002.
|
|
333
|
+
tolerance_stress: float, optional
|
|
334
|
+
Tolerance for stress envelope convergence. Defaults to 0.005.
|
|
335
|
+
print_call_stats: bool
|
|
336
|
+
Whether to print the call statistics. Defaults to False.
|
|
337
|
+
_recursion_depth: int
|
|
338
|
+
The depth of the recursion. Defaults to 0.
|
|
339
|
+
|
|
340
|
+
Returns
|
|
341
|
+
-------
|
|
342
|
+
results: CoupledCriterionResult
|
|
343
|
+
An object containing the results of the analysis, including
|
|
344
|
+
critical skier weight, crack length, and convergence details.
|
|
345
|
+
"""
|
|
346
|
+
logger.info("Starting coupled criterion evaluation.")
|
|
347
|
+
L = system.scenario.L
|
|
348
|
+
weak_layer = system.weak_layer
|
|
349
|
+
|
|
350
|
+
logger.info("Finding minimum force...")
|
|
351
|
+
force_finding_start = time.time()
|
|
352
|
+
|
|
353
|
+
force_result = self.find_minimum_force(
|
|
354
|
+
system, tolerance_stress=tolerance_stress, print_call_stats=print_call_stats
|
|
355
|
+
)
|
|
356
|
+
initial_critical_skier_weight = force_result.critical_skier_weight
|
|
357
|
+
max_dist_stress = force_result.max_dist_stress
|
|
358
|
+
min_dist_stress = force_result.min_dist_stress
|
|
359
|
+
logger.info(
|
|
360
|
+
"Minimum force finding took %.4f seconds.",
|
|
361
|
+
time.time() - force_finding_start,
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
analyzer = Analyzer(system, printing_enabled=print_call_stats)
|
|
365
|
+
# --- Failure: in finding the critical skier weight ---
|
|
366
|
+
if not force_result.success:
|
|
367
|
+
logger.warning("No critical skier weight found")
|
|
368
|
+
analyzer.print_call_stats(
|
|
369
|
+
message="evaluate_coupled_criterion Call Statistics"
|
|
370
|
+
)
|
|
371
|
+
return CoupledCriterionResult(
|
|
372
|
+
converged=False,
|
|
373
|
+
message="Failed to find critical skier weight.",
|
|
374
|
+
self_collapse=False,
|
|
375
|
+
pure_stress_criteria=False,
|
|
376
|
+
critical_skier_weight=0,
|
|
377
|
+
initial_critical_skier_weight=0,
|
|
378
|
+
crack_length=0,
|
|
379
|
+
g_delta=0,
|
|
380
|
+
dist_ERR_envelope=1,
|
|
381
|
+
iterations=0,
|
|
382
|
+
history=None,
|
|
383
|
+
final_system=system,
|
|
384
|
+
max_dist_stress=0,
|
|
385
|
+
min_dist_stress=0,
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
# --- Exception: the entire solution is cracked ---
|
|
389
|
+
if min_dist_stress > 1:
|
|
390
|
+
logger.info("The entire solution is cracked.")
|
|
391
|
+
# --- Larger scenario to calculate the incremental ERR ---
|
|
392
|
+
segments = copy.deepcopy(system.scenario.segments)
|
|
393
|
+
for segment in segments:
|
|
394
|
+
segment.has_foundation = False
|
|
395
|
+
# Add 50m of padding to the left and right of the system
|
|
396
|
+
segments.insert(0, Segment(length=50000, has_foundation=True, m=0))
|
|
397
|
+
segments.append(Segment(length=50000, has_foundation=True, m=0))
|
|
398
|
+
system.update_scenario(segments=segments)
|
|
399
|
+
|
|
400
|
+
inc_energy = analyzer.incremental_ERR(unit="J/m^2")
|
|
401
|
+
g_delta = self.fracture_toughness_envelope(
|
|
402
|
+
inc_energy[1], inc_energy[2], system.weak_layer
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
history_data = CoupledCriterionHistory([], [], [], [], [], [])
|
|
406
|
+
analyzer.print_call_stats(
|
|
407
|
+
message="evaluate_coupled_criterion Call Statistics"
|
|
408
|
+
)
|
|
409
|
+
return CoupledCriterionResult(
|
|
410
|
+
converged=True,
|
|
411
|
+
message="System fails under its own weight (self-collapse).",
|
|
412
|
+
self_collapse=True,
|
|
413
|
+
pure_stress_criteria=False,
|
|
414
|
+
critical_skier_weight=0,
|
|
415
|
+
initial_critical_skier_weight=initial_critical_skier_weight,
|
|
416
|
+
crack_length=L,
|
|
417
|
+
g_delta=g_delta,
|
|
418
|
+
dist_ERR_envelope=0,
|
|
419
|
+
iterations=0,
|
|
420
|
+
history=history_data,
|
|
421
|
+
final_system=system,
|
|
422
|
+
max_dist_stress=max_dist_stress,
|
|
423
|
+
min_dist_stress=min_dist_stress,
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
# --- Main loop ---
|
|
427
|
+
crack_length = 1.0
|
|
428
|
+
dist_ERR_envelope = 1000
|
|
429
|
+
g_delta = 0
|
|
430
|
+
history = CoupledCriterionHistory([], [], [], [], [], [])
|
|
431
|
+
iteration_count = 0
|
|
432
|
+
skier_weight = initial_critical_skier_weight * 1.005
|
|
433
|
+
min_skier_weight = 1e-6
|
|
434
|
+
max_skier_weight = 200
|
|
435
|
+
|
|
436
|
+
# Ensure Max Weight surpasses fracture toughness criterion
|
|
437
|
+
max_weight_g_delta = 0
|
|
438
|
+
while max_weight_g_delta < 1:
|
|
439
|
+
max_skier_weight = max_skier_weight * 2
|
|
440
|
+
|
|
441
|
+
segments = [
|
|
442
|
+
Segment(length=L / 2 - crack_length / 2, has_foundation=True, m=0),
|
|
443
|
+
Segment(
|
|
444
|
+
length=crack_length / 2,
|
|
445
|
+
has_foundation=False,
|
|
446
|
+
m=max_skier_weight,
|
|
447
|
+
),
|
|
448
|
+
Segment(length=crack_length / 2, has_foundation=False, m=0),
|
|
449
|
+
Segment(length=L / 2 - crack_length / 2, has_foundation=True, m=0),
|
|
450
|
+
]
|
|
451
|
+
|
|
452
|
+
system.update_scenario(segments=segments)
|
|
453
|
+
|
|
454
|
+
# Calculate fracture toughness criterion
|
|
455
|
+
incr_energy = analyzer.incremental_ERR(unit="J/m^2")
|
|
456
|
+
max_weight_g_delta = self.fracture_toughness_envelope(
|
|
457
|
+
incr_energy[1], incr_energy[2], weak_layer
|
|
458
|
+
)
|
|
459
|
+
dist_ERR_envelope = abs(max_weight_g_delta - 1)
|
|
460
|
+
|
|
461
|
+
logger.info("Max weight to look at: %.2f kg", max_skier_weight)
|
|
462
|
+
segments = [
|
|
463
|
+
Segment(
|
|
464
|
+
length=L / 2 - crack_length / 2,
|
|
465
|
+
has_foundation=True,
|
|
466
|
+
m=0.0,
|
|
467
|
+
),
|
|
468
|
+
Segment(length=crack_length / 2, has_foundation=False, m=skier_weight),
|
|
469
|
+
Segment(length=crack_length / 2, has_foundation=False, m=0),
|
|
470
|
+
Segment(length=L / 2 - crack_length / 2, has_foundation=True, m=0),
|
|
471
|
+
]
|
|
472
|
+
|
|
473
|
+
while (
|
|
474
|
+
abs(dist_ERR_envelope) > tolerance_ERR
|
|
475
|
+
and iteration_count < max_iterations
|
|
476
|
+
and any(s.has_foundation for s in segments)
|
|
477
|
+
):
|
|
478
|
+
iteration_count += 1
|
|
479
|
+
iter_start_time = time.time()
|
|
480
|
+
logger.info(
|
|
481
|
+
"Starting iteration %d of coupled criterion evaluation.",
|
|
482
|
+
iteration_count,
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
system.update_scenario(segments=segments)
|
|
486
|
+
_, z, _ = analyzer.rasterize_solution(mode="uncracked", num=2000)
|
|
487
|
+
|
|
488
|
+
# Calculate stress envelope
|
|
489
|
+
sigma_kPa = system.fq.sig(z, unit="kPa")
|
|
490
|
+
tau_kPa = system.fq.tau(z, unit="kPa")
|
|
491
|
+
stress_env = self.stress_envelope(sigma_kPa, tau_kPa, system.weak_layer)
|
|
492
|
+
max_dist_stress = np.max(stress_env)
|
|
493
|
+
min_dist_stress = np.min(stress_env)
|
|
494
|
+
|
|
495
|
+
# Calculate fracture toughness criterion
|
|
496
|
+
incr_energy = analyzer.incremental_ERR(unit="J/m^2")
|
|
497
|
+
g_delta = self.fracture_toughness_envelope(
|
|
498
|
+
incr_energy[1], incr_energy[2], weak_layer
|
|
499
|
+
)
|
|
500
|
+
dist_ERR_envelope = abs(g_delta - 1)
|
|
501
|
+
|
|
502
|
+
# Update history
|
|
503
|
+
history.skier_weights.append(skier_weight)
|
|
504
|
+
history.crack_lengths.append(crack_length)
|
|
505
|
+
history.incr_energies.append(incr_energy)
|
|
506
|
+
history.g_deltas.append(g_delta)
|
|
507
|
+
history.dist_maxs.append(max_dist_stress)
|
|
508
|
+
history.dist_mins.append(min_dist_stress)
|
|
509
|
+
|
|
510
|
+
# --- Exception: pure stress criterion ---
|
|
511
|
+
# The fracture toughness is superseded for minimum critical skier weight
|
|
512
|
+
if iteration_count == 1 and (g_delta > 1 or dist_ERR_envelope < 0.02):
|
|
513
|
+
analyzer.print_call_stats(
|
|
514
|
+
message="evaluate_coupled_criterion Call Statistics"
|
|
515
|
+
)
|
|
516
|
+
return CoupledCriterionResult(
|
|
517
|
+
converged=True,
|
|
518
|
+
message="Fracture governed by pure stress criterion.",
|
|
519
|
+
self_collapse=False,
|
|
520
|
+
pure_stress_criteria=True,
|
|
521
|
+
critical_skier_weight=skier_weight,
|
|
522
|
+
initial_critical_skier_weight=initial_critical_skier_weight,
|
|
523
|
+
crack_length=crack_length,
|
|
524
|
+
g_delta=g_delta,
|
|
525
|
+
dist_ERR_envelope=dist_ERR_envelope,
|
|
526
|
+
iterations=iteration_count,
|
|
527
|
+
history=history,
|
|
528
|
+
final_system=system,
|
|
529
|
+
max_dist_stress=max_dist_stress,
|
|
530
|
+
min_dist_stress=min_dist_stress,
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
# Update skier weight boundaries
|
|
534
|
+
if g_delta < 1:
|
|
535
|
+
min_skier_weight = skier_weight
|
|
536
|
+
else:
|
|
537
|
+
max_skier_weight = skier_weight
|
|
538
|
+
|
|
539
|
+
# Update skier weight
|
|
540
|
+
new_skier_weight = (min_skier_weight + max_skier_weight) / 2
|
|
541
|
+
|
|
542
|
+
# Apply damping to avoid oscillation around goal
|
|
543
|
+
if np.abs(dist_ERR_envelope) < 0.5 and damping_ERR > 0:
|
|
544
|
+
scaling = (damping_ERR + 1 + (new_skier_weight / skier_weight)) / (
|
|
545
|
+
damping_ERR + 2
|
|
546
|
+
)
|
|
547
|
+
else:
|
|
548
|
+
scaling = 1
|
|
549
|
+
|
|
550
|
+
# Find new anticrack length
|
|
551
|
+
if abs(dist_ERR_envelope) > tolerance_ERR:
|
|
552
|
+
skier_weight = scaling * new_skier_weight
|
|
553
|
+
crack_length, segments = self.find_crack_length_for_weight(
|
|
554
|
+
system, skier_weight
|
|
555
|
+
)
|
|
556
|
+
logger.info("New skier weight: %.2f kg", skier_weight)
|
|
557
|
+
logger.info(
|
|
558
|
+
"Iteration %d took %.4f seconds.",
|
|
559
|
+
iteration_count,
|
|
560
|
+
time.time() - iter_start_time,
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
if iteration_count < max_iterations and any(s.has_foundation for s in segments):
|
|
564
|
+
logger.info("No Exception encountered - Converged successfully.")
|
|
565
|
+
if crack_length > 0:
|
|
566
|
+
analyzer.print_call_stats(
|
|
567
|
+
message="evaluate_coupled_criterion Call Statistics"
|
|
568
|
+
)
|
|
569
|
+
return CoupledCriterionResult(
|
|
570
|
+
converged=True,
|
|
571
|
+
message="No Exception encountered - Converged successfully.",
|
|
572
|
+
self_collapse=False,
|
|
573
|
+
pure_stress_criteria=False,
|
|
574
|
+
critical_skier_weight=skier_weight,
|
|
575
|
+
initial_critical_skier_weight=initial_critical_skier_weight,
|
|
576
|
+
crack_length=crack_length,
|
|
577
|
+
g_delta=g_delta,
|
|
578
|
+
dist_ERR_envelope=dist_ERR_envelope,
|
|
579
|
+
iterations=iteration_count,
|
|
580
|
+
history=history,
|
|
581
|
+
final_system=system,
|
|
582
|
+
max_dist_stress=max_dist_stress,
|
|
583
|
+
min_dist_stress=min_dist_stress,
|
|
584
|
+
)
|
|
585
|
+
if _recursion_depth < 5:
|
|
586
|
+
logger.info("Reached max damping without converging.")
|
|
587
|
+
analyzer.print_call_stats(
|
|
588
|
+
message="evaluate_coupled_criterion Call Statistics"
|
|
589
|
+
)
|
|
590
|
+
return self.evaluate_coupled_criterion(
|
|
591
|
+
system,
|
|
592
|
+
damping_ERR=damping_ERR + 1,
|
|
593
|
+
tolerance_ERR=tolerance_ERR,
|
|
594
|
+
tolerance_stress=tolerance_stress,
|
|
595
|
+
_recursion_depth=_recursion_depth + 1,
|
|
596
|
+
)
|
|
597
|
+
analyzer.print_call_stats(
|
|
598
|
+
message="evaluate_coupled_criterion Call Statistics"
|
|
599
|
+
)
|
|
600
|
+
return CoupledCriterionResult(
|
|
601
|
+
converged=False,
|
|
602
|
+
message="Reached max damping without converging.",
|
|
603
|
+
self_collapse=False,
|
|
604
|
+
pure_stress_criteria=False,
|
|
605
|
+
critical_skier_weight=0,
|
|
606
|
+
initial_critical_skier_weight=initial_critical_skier_weight,
|
|
607
|
+
crack_length=crack_length,
|
|
608
|
+
g_delta=g_delta,
|
|
609
|
+
dist_ERR_envelope=dist_ERR_envelope,
|
|
610
|
+
iterations=iteration_count,
|
|
611
|
+
history=history,
|
|
612
|
+
final_system=system,
|
|
613
|
+
max_dist_stress=max_dist_stress,
|
|
614
|
+
min_dist_stress=min_dist_stress,
|
|
615
|
+
)
|
|
616
|
+
if not any(s.has_foundation for s in segments):
|
|
617
|
+
analyzer.print_call_stats(
|
|
618
|
+
message="evaluate_coupled_criterion Call Statistics"
|
|
619
|
+
)
|
|
620
|
+
return CoupledCriterionResult(
|
|
621
|
+
converged=False,
|
|
622
|
+
message="Reached max iterations without converging.",
|
|
623
|
+
self_collapse=False,
|
|
624
|
+
pure_stress_criteria=False,
|
|
625
|
+
critical_skier_weight=0,
|
|
626
|
+
initial_critical_skier_weight=initial_critical_skier_weight,
|
|
627
|
+
crack_length=0,
|
|
628
|
+
g_delta=0,
|
|
629
|
+
dist_ERR_envelope=1,
|
|
630
|
+
iterations=iteration_count,
|
|
631
|
+
history=history,
|
|
632
|
+
final_system=system,
|
|
633
|
+
max_dist_stress=max_dist_stress,
|
|
634
|
+
min_dist_stress=min_dist_stress,
|
|
635
|
+
)
|
|
636
|
+
analyzer.print_call_stats(message="evaluate_coupled_criterion Call Statistics")
|
|
637
|
+
return self.evaluate_coupled_criterion(
|
|
638
|
+
system,
|
|
639
|
+
damping_ERR=damping_ERR + 1,
|
|
640
|
+
tolerance_ERR=tolerance_ERR,
|
|
641
|
+
tolerance_stress=tolerance_stress,
|
|
642
|
+
_recursion_depth=_recursion_depth + 1,
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
def evaluate_SSERR(
|
|
646
|
+
self,
|
|
647
|
+
system: SystemModel,
|
|
648
|
+
vertical: bool = False,
|
|
649
|
+
print_call_stats: bool = False,
|
|
650
|
+
) -> SSERRResult:
|
|
651
|
+
"""
|
|
652
|
+
Evaluates the Touchdown Distance in the Steady State and the Steady State
|
|
653
|
+
Energy Release Rate.
|
|
654
|
+
|
|
655
|
+
Parameters:
|
|
656
|
+
-----------
|
|
657
|
+
system: SystemModel
|
|
658
|
+
The system model.
|
|
659
|
+
vertical: bool, optional
|
|
660
|
+
Whether to evaluate the system in a vertical configuration.
|
|
661
|
+
Defaults to False.
|
|
662
|
+
print_call_stats: bool, optional
|
|
663
|
+
Whether to print the call statistics. Defaults to False.
|
|
664
|
+
|
|
665
|
+
IMPORTANT: There is a bug in vertical = True, so always slope normal,
|
|
666
|
+
i.e. vertical=False should be used.
|
|
667
|
+
"""
|
|
668
|
+
if vertical:
|
|
669
|
+
warnings.warn(
|
|
670
|
+
"vertical=True mode is currently buggy — results may be invalid. "
|
|
671
|
+
"Please set vertical=False until this is fixed.",
|
|
672
|
+
UserWarning,
|
|
673
|
+
)
|
|
674
|
+
system_copy = copy.deepcopy(system)
|
|
675
|
+
segments = [
|
|
676
|
+
Segment(length=5e3, has_foundation=True, m=0.0),
|
|
677
|
+
Segment(length=5e3, has_foundation=False, m=0.0),
|
|
678
|
+
]
|
|
679
|
+
scenario_config = ScenarioConfig(
|
|
680
|
+
system_type="vpst-" if vertical else "pst-",
|
|
681
|
+
phi=system.scenario.phi,
|
|
682
|
+
cut_length=5e3,
|
|
683
|
+
)
|
|
684
|
+
system_copy.config.touchdown = True
|
|
685
|
+
system_copy.update_scenario(segments=segments, scenario_config=scenario_config)
|
|
686
|
+
touchdown_distance = system_copy.slab_touchdown.touchdown_distance
|
|
687
|
+
analyzer = Analyzer(system_copy, printing_enabled=print_call_stats)
|
|
688
|
+
G, _, _ = analyzer.differential_ERR(unit="J/m^2")
|
|
689
|
+
return SSERRResult(
|
|
690
|
+
converged=True,
|
|
691
|
+
message="SSERR evaluation successful.",
|
|
692
|
+
touchdown_distance=touchdown_distance,
|
|
693
|
+
SSERR=G,
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
def find_minimum_force(
|
|
697
|
+
self,
|
|
698
|
+
system: SystemModel,
|
|
699
|
+
tolerance_stress: float = 0.0005,
|
|
700
|
+
print_call_stats: bool = False,
|
|
701
|
+
) -> FindMinimumForceResult:
|
|
702
|
+
"""
|
|
703
|
+
Finds the minimum skier weight required to surpass the stress failure envelope.
|
|
704
|
+
|
|
705
|
+
This method iteratively adjusts the skier weight until the maximum distance
|
|
706
|
+
to the stress envelope converges to 1, indicating the critical state.
|
|
707
|
+
|
|
708
|
+
Parameters:
|
|
709
|
+
-----------
|
|
710
|
+
system: SystemModel
|
|
711
|
+
The system model.
|
|
712
|
+
tolerance_stress: float, optional
|
|
713
|
+
Tolerance for the stress envelope. Defaults to 0.005.
|
|
714
|
+
print_call_stats: bool, optional
|
|
715
|
+
Whether to print the call statistics. Defaults to False.
|
|
716
|
+
|
|
717
|
+
Returns:
|
|
718
|
+
--------
|
|
719
|
+
results: FindMinimumForceResult
|
|
720
|
+
An object containing the results of the analysis, including
|
|
721
|
+
critical skier weight, and convergence details.
|
|
722
|
+
"""
|
|
723
|
+
logger.info("Start: Find Minimum force to surpass Stress Env.")
|
|
724
|
+
old_segments = copy.deepcopy(system.scenario.segments)
|
|
725
|
+
total_length = system.scenario.L
|
|
726
|
+
analyzer = Analyzer(system, printing_enabled=print_call_stats)
|
|
727
|
+
|
|
728
|
+
# --- Initial uncracked configuration ---
|
|
729
|
+
segments = [
|
|
730
|
+
Segment(length=total_length / 2, has_foundation=True, m=0.0),
|
|
731
|
+
Segment(length=total_length / 2, has_foundation=True, m=0.0),
|
|
732
|
+
]
|
|
733
|
+
system.update_scenario(segments=segments)
|
|
734
|
+
_, z_skier, _ = analyzer.rasterize_solution(mode="uncracked", num=2000)
|
|
735
|
+
sigma_kPa = system.fq.sig(z_skier, unit="kPa")
|
|
736
|
+
tau_kPa = system.fq.tau(z_skier, unit="kPa")
|
|
737
|
+
max_dist_stress = np.max(
|
|
738
|
+
self.stress_envelope(sigma_kPa, tau_kPa, system.weak_layer)
|
|
739
|
+
)
|
|
740
|
+
min_dist_stress = np.min(
|
|
741
|
+
self.stress_envelope(sigma_kPa, tau_kPa, system.weak_layer)
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
# --- Early Exit: entire domain is cracked ---
|
|
745
|
+
if min_dist_stress >= 1:
|
|
746
|
+
analyzer.print_call_stats(
|
|
747
|
+
message="min_dist_stress >= 1 in find_minimum_force Call Statistics"
|
|
748
|
+
)
|
|
749
|
+
return FindMinimumForceResult(
|
|
750
|
+
success=True,
|
|
751
|
+
critical_skier_weight=0.0,
|
|
752
|
+
new_segments=segments,
|
|
753
|
+
old_segments=old_segments,
|
|
754
|
+
iterations=0,
|
|
755
|
+
max_dist_stress=max_dist_stress,
|
|
756
|
+
min_dist_stress=min_dist_stress,
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
def stress_envelope_residual(skier_weight: float, system: SystemModel) -> float:
|
|
760
|
+
logger.info("Eval. Stress Envelope for weight %.2f kg.", skier_weight)
|
|
761
|
+
segments = [
|
|
762
|
+
Segment(length=total_length / 2, has_foundation=True, m=skier_weight),
|
|
763
|
+
Segment(length=total_length / 2, has_foundation=True, m=0.0),
|
|
764
|
+
]
|
|
765
|
+
system.update_scenario(segments=segments)
|
|
766
|
+
_, z_skier, _ = analyzer.rasterize_solution(mode="cracked", num=2000)
|
|
767
|
+
sigma_kPa = system.fq.sig(z_skier, unit="kPa")
|
|
768
|
+
tau_kPa = system.fq.tau(z_skier, unit="kPa")
|
|
769
|
+
max_dist = np.max(
|
|
770
|
+
self.stress_envelope(sigma_kPa, tau_kPa, system.weak_layer)
|
|
771
|
+
)
|
|
772
|
+
return max_dist - 1
|
|
773
|
+
|
|
774
|
+
# Now do root finding with brentq
|
|
775
|
+
def root_fn(weight):
|
|
776
|
+
return stress_envelope_residual(weight, system)
|
|
777
|
+
|
|
778
|
+
# Search interval
|
|
779
|
+
w_min = 0.0
|
|
780
|
+
w_max = 300.0
|
|
781
|
+
while True:
|
|
782
|
+
try:
|
|
783
|
+
critical_weight = brentq(root_fn, w_min, w_max, xtol=tolerance_stress)
|
|
784
|
+
break
|
|
785
|
+
except ValueError as exc:
|
|
786
|
+
w_max = w_max * 2
|
|
787
|
+
if w_max > 10000:
|
|
788
|
+
raise ValueError(
|
|
789
|
+
"No sign change found in [w_min, w_max]. Cannot use brentq."
|
|
790
|
+
) from exc
|
|
791
|
+
|
|
792
|
+
# Final evaluation
|
|
793
|
+
logger.info("Final evaluation for skier weight %.2f kg.", critical_weight)
|
|
794
|
+
system.update_scenario(
|
|
795
|
+
segments=[
|
|
796
|
+
Segment(
|
|
797
|
+
length=total_length / 2, has_foundation=True, m=critical_weight
|
|
798
|
+
),
|
|
799
|
+
Segment(length=total_length / 2, has_foundation=True, m=0.0),
|
|
800
|
+
]
|
|
801
|
+
)
|
|
802
|
+
_, z_skier, _ = analyzer.rasterize_solution(mode="cracked", num=2000)
|
|
803
|
+
sigma_kPa = system.fq.sig(z_skier, unit="kPa")
|
|
804
|
+
tau_kPa = system.fq.tau(z_skier, unit="kPa")
|
|
805
|
+
max_dist_stress = np.max(
|
|
806
|
+
self.stress_envelope(sigma_kPa, tau_kPa, system.weak_layer)
|
|
807
|
+
)
|
|
808
|
+
min_dist_stress = np.min(
|
|
809
|
+
self.stress_envelope(sigma_kPa, tau_kPa, system.weak_layer)
|
|
810
|
+
)
|
|
811
|
+
|
|
812
|
+
analyzer.print_call_stats(message="find_minimum_force Call Statistics")
|
|
813
|
+
return FindMinimumForceResult(
|
|
814
|
+
success=True,
|
|
815
|
+
critical_skier_weight=critical_weight,
|
|
816
|
+
new_segments=copy.deepcopy(system.scenario.segments),
|
|
817
|
+
old_segments=old_segments,
|
|
818
|
+
iterations=None,
|
|
819
|
+
max_dist_stress=max_dist_stress,
|
|
820
|
+
min_dist_stress=min_dist_stress,
|
|
821
|
+
)
|
|
822
|
+
|
|
823
|
+
def find_minimum_crack_length(
|
|
824
|
+
self,
|
|
825
|
+
system: SystemModel,
|
|
826
|
+
search_interval: tuple[float, float] | None = None,
|
|
827
|
+
target: float = 1,
|
|
828
|
+
) -> tuple[float, List[Segment]]:
|
|
829
|
+
"""
|
|
830
|
+
Finds the minimum crack length required to surpass the energy release rate envelope.
|
|
831
|
+
|
|
832
|
+
Parameters:
|
|
833
|
+
-----------
|
|
834
|
+
system: SystemModel
|
|
835
|
+
The system model.
|
|
836
|
+
|
|
837
|
+
Returns:
|
|
838
|
+
--------
|
|
839
|
+
minimum_crack_length: float
|
|
840
|
+
The minimum crack length required to surpass the energy release rate envelope [mm]
|
|
841
|
+
new_segments: List[Segment]
|
|
842
|
+
The updated list of segments
|
|
843
|
+
"""
|
|
844
|
+
old_segments = copy.deepcopy(system.scenario.segments)
|
|
845
|
+
|
|
846
|
+
if search_interval is None:
|
|
847
|
+
a = 0
|
|
848
|
+
b = system.scenario.L / 2
|
|
849
|
+
else:
|
|
850
|
+
a, b = search_interval
|
|
851
|
+
logger.info("Interval for crack length search: %s, %s", a, b)
|
|
852
|
+
logger.info(
|
|
853
|
+
"Calculation of fracture toughness envelope: %s, %s",
|
|
854
|
+
self._fracture_toughness_exceedance(a, system),
|
|
855
|
+
self._fracture_toughness_exceedance(b, system),
|
|
856
|
+
)
|
|
857
|
+
|
|
858
|
+
# Use root_scalar to find the root
|
|
859
|
+
result = root_scalar(
|
|
860
|
+
self._fracture_toughness_exceedance,
|
|
861
|
+
args=(system, target),
|
|
862
|
+
bracket=[a, b], # Interval where the root is expected
|
|
863
|
+
method="brentq", # Brent's method
|
|
864
|
+
)
|
|
865
|
+
|
|
866
|
+
new_segments = system.scenario.segments
|
|
867
|
+
|
|
868
|
+
system.update_scenario(segments=old_segments)
|
|
869
|
+
|
|
870
|
+
if result.converged:
|
|
871
|
+
return result.root, new_segments
|
|
872
|
+
logger.error("Root search did not converge.")
|
|
873
|
+
return 0.0, new_segments
|
|
874
|
+
|
|
875
|
+
def check_crack_self_propagation(
|
|
876
|
+
self,
|
|
877
|
+
system: SystemModel,
|
|
878
|
+
rm_skier_weight: bool = False,
|
|
879
|
+
) -> tuple[float, bool]:
|
|
880
|
+
"""
|
|
881
|
+
Evaluates whether a crack will propagate without any additional load.
|
|
882
|
+
This method determines if a pre-existing crack will propagate without any
|
|
883
|
+
additional load.
|
|
884
|
+
|
|
885
|
+
Parameters:
|
|
886
|
+
----------
|
|
887
|
+
system: SystemModel
|
|
888
|
+
|
|
889
|
+
Returns
|
|
890
|
+
-------
|
|
891
|
+
g_delta_diff: float
|
|
892
|
+
The evaluation of the fracture toughness envelope.
|
|
893
|
+
can_propagate: bool
|
|
894
|
+
True if the criterion is met (g_delta_diff >= 1).
|
|
895
|
+
"""
|
|
896
|
+
logger.info("Checking for self-propagation of pre-existing crack.")
|
|
897
|
+
new_system = copy.deepcopy(system)
|
|
898
|
+
logger.debug("Segments: %s", new_system.scenario.segments)
|
|
899
|
+
|
|
900
|
+
start_time = time.time()
|
|
901
|
+
# No skier weight is applied for self-propagation check
|
|
902
|
+
if rm_skier_weight:
|
|
903
|
+
for seg in new_system.scenario.segments:
|
|
904
|
+
seg.m = 0
|
|
905
|
+
new_system.update_scenario(segments=new_system.scenario.segments)
|
|
906
|
+
|
|
907
|
+
analyzer = Analyzer(new_system)
|
|
908
|
+
diff_energy = analyzer.differential_ERR(unit="J/m^2")
|
|
909
|
+
G_I = diff_energy[1]
|
|
910
|
+
G_II = diff_energy[2]
|
|
911
|
+
|
|
912
|
+
# Evaluate the fracture toughness criterion
|
|
913
|
+
g_delta_diff = self.fracture_toughness_envelope(
|
|
914
|
+
G_I, G_II, new_system.weak_layer
|
|
915
|
+
)
|
|
916
|
+
can_propagate = g_delta_diff >= 1
|
|
917
|
+
logger.info(
|
|
918
|
+
"Self-propagation check finished in %.4f seconds. Result: "
|
|
919
|
+
"g_delta_diff=%.4f, can_propagate=%s",
|
|
920
|
+
time.time() - start_time,
|
|
921
|
+
g_delta_diff,
|
|
922
|
+
can_propagate,
|
|
923
|
+
)
|
|
924
|
+
|
|
925
|
+
return g_delta_diff, bool(can_propagate)
|
|
926
|
+
|
|
927
|
+
def find_crack_length_for_weight(
|
|
928
|
+
self,
|
|
929
|
+
system: SystemModel,
|
|
930
|
+
skier_weight: float,
|
|
931
|
+
) -> tuple[float, List[Segment]]:
|
|
932
|
+
"""
|
|
933
|
+
Finds the resulting anticrack length and updated segment configurations
|
|
934
|
+
for a given skier weight.
|
|
935
|
+
|
|
936
|
+
Parameters:
|
|
937
|
+
-----------
|
|
938
|
+
system: SystemModel
|
|
939
|
+
The system model.
|
|
940
|
+
skier_weight: float
|
|
941
|
+
The weight of the skier [kg]
|
|
942
|
+
|
|
943
|
+
Returns
|
|
944
|
+
-------
|
|
945
|
+
new_crack_length: float
|
|
946
|
+
The total length of the new cracked segments [mm]
|
|
947
|
+
new_segments: List[Segment]
|
|
948
|
+
The updated list of segments
|
|
949
|
+
"""
|
|
950
|
+
logger.info(
|
|
951
|
+
"Finding new anticrack length for skier weight %.2f kg.", skier_weight
|
|
952
|
+
)
|
|
953
|
+
start_time = time.time()
|
|
954
|
+
total_length = system.scenario.L
|
|
955
|
+
weak_layer = system.weak_layer
|
|
956
|
+
|
|
957
|
+
old_segments = copy.deepcopy(system.scenario.segments)
|
|
958
|
+
|
|
959
|
+
initial_segments = [
|
|
960
|
+
Segment(length=total_length / 2, has_foundation=True, m=skier_weight),
|
|
961
|
+
Segment(length=total_length / 2, has_foundation=True, m=0),
|
|
962
|
+
]
|
|
963
|
+
system.update_scenario(segments=initial_segments)
|
|
964
|
+
|
|
965
|
+
analyzer = Analyzer(system)
|
|
966
|
+
_, z, _ = analyzer.rasterize_solution(mode="cracked", num=2000)
|
|
967
|
+
sigma_kPa = system.fq.sig(z, unit="kPa")
|
|
968
|
+
tau_kPa = system.fq.tau(z, unit="kPa")
|
|
969
|
+
min_dist_stress = np.min(self.stress_envelope(sigma_kPa, tau_kPa, weak_layer))
|
|
970
|
+
|
|
971
|
+
# Find all points where the stress envelope is crossed
|
|
972
|
+
crossings_start_time = time.time()
|
|
973
|
+
roots = self._find_stress_envelope_crossings(system, weak_layer)
|
|
974
|
+
logger.info(
|
|
975
|
+
"Finding stress envelope crossings took %.4f seconds.",
|
|
976
|
+
time.time() - crossings_start_time,
|
|
977
|
+
)
|
|
978
|
+
|
|
979
|
+
# --- Standard case: if roots exist ---
|
|
980
|
+
if len(roots) > 0:
|
|
981
|
+
# Reconstruct segments based on the roots
|
|
982
|
+
midpoint_load_application = total_length / 2
|
|
983
|
+
segment_boundaries = sorted(
|
|
984
|
+
list(set([0] + roots + [midpoint_load_application] + [total_length]))
|
|
985
|
+
)
|
|
986
|
+
new_segments = []
|
|
987
|
+
|
|
988
|
+
for i in range(len(segment_boundaries) - 1):
|
|
989
|
+
start = segment_boundaries[i]
|
|
990
|
+
end = segment_boundaries[i + 1]
|
|
991
|
+
midpoint = (start + end) / 2
|
|
992
|
+
|
|
993
|
+
# Check stress at the midpoint of the new potential segment
|
|
994
|
+
# to determine if it's cracked (has_foundation=False)
|
|
995
|
+
mid_sigma, mid_tau = self._calculate_sigma_tau_at_x(midpoint, system)
|
|
996
|
+
stress_check = self.stress_envelope(
|
|
997
|
+
np.array([mid_sigma]), np.array([mid_tau]), weak_layer
|
|
998
|
+
)[0]
|
|
999
|
+
|
|
1000
|
+
has_foundation = stress_check <= 1
|
|
1001
|
+
|
|
1002
|
+
# Re-apply the skier weight to the correct new segment
|
|
1003
|
+
m = skier_weight if i == 1 else 0
|
|
1004
|
+
|
|
1005
|
+
new_segments.append(
|
|
1006
|
+
Segment(length=end - start, has_foundation=has_foundation, m=m)
|
|
1007
|
+
)
|
|
1008
|
+
|
|
1009
|
+
# Consolidate mass onto one segment if it was split
|
|
1010
|
+
mass_segments = [s for s in new_segments if s.m > 0]
|
|
1011
|
+
if len(mass_segments) > 1:
|
|
1012
|
+
for s in mass_segments[1:]:
|
|
1013
|
+
s.m = 0
|
|
1014
|
+
|
|
1015
|
+
new_crack_length = sum(
|
|
1016
|
+
seg.length for seg in new_segments if not seg.has_foundation
|
|
1017
|
+
)
|
|
1018
|
+
|
|
1019
|
+
logger.info(
|
|
1020
|
+
"Finished finding new anticrack length in %.4f seconds. New length: %.2f mm.",
|
|
1021
|
+
time.time() - start_time,
|
|
1022
|
+
new_crack_length,
|
|
1023
|
+
)
|
|
1024
|
+
|
|
1025
|
+
# --- Exception: the entire domain is cracked ---
|
|
1026
|
+
elif min_dist_stress > 1:
|
|
1027
|
+
# The entire domain is cracked
|
|
1028
|
+
new_segments = [
|
|
1029
|
+
Segment(length=total_length / 2, has_foundation=False, m=skier_weight),
|
|
1030
|
+
Segment(length=total_length / 2, has_foundation=False, m=0),
|
|
1031
|
+
]
|
|
1032
|
+
new_crack_length = total_length
|
|
1033
|
+
|
|
1034
|
+
elif not roots:
|
|
1035
|
+
# No part of the slab is cracked
|
|
1036
|
+
new_crack_length = 0
|
|
1037
|
+
new_segments = initial_segments
|
|
1038
|
+
|
|
1039
|
+
system.update_scenario(segments=old_segments)
|
|
1040
|
+
|
|
1041
|
+
return new_crack_length, new_segments
|
|
1042
|
+
|
|
1043
|
+
def _calculate_sigma_tau_at_x(
|
|
1044
|
+
self, x_value: float, system: SystemModel
|
|
1045
|
+
) -> tuple[float, float]:
|
|
1046
|
+
"""Calculate normal and shear stresses at a given horizontal x-coordinate."""
|
|
1047
|
+
# Get the segment index and coordinate within the segment
|
|
1048
|
+
segment_index = system.scenario.get_segment_idx(x_value)
|
|
1049
|
+
|
|
1050
|
+
start_of_segment = (
|
|
1051
|
+
system.scenario.cum_sum_li[segment_index - 1] if segment_index > 0 else 0
|
|
1052
|
+
)
|
|
1053
|
+
coordinate_in_segment = x_value - start_of_segment
|
|
1054
|
+
|
|
1055
|
+
# Get the constants for the segment
|
|
1056
|
+
C = system.unknown_constants[:, [segment_index]]
|
|
1057
|
+
li_segment = system.scenario.li[segment_index]
|
|
1058
|
+
phi = system.scenario.phi
|
|
1059
|
+
has_foundation = system.scenario.ki[segment_index]
|
|
1060
|
+
|
|
1061
|
+
# Calculate the displacement field
|
|
1062
|
+
Z = system.z(
|
|
1063
|
+
coordinate_in_segment, C, li_segment, phi, has_foundation=has_foundation
|
|
1064
|
+
)
|
|
1065
|
+
|
|
1066
|
+
# Calculate the stresses
|
|
1067
|
+
tau = -system.fq.tau(Z, unit="kPa") # Negated to match sign convention
|
|
1068
|
+
sigma = system.fq.sig(Z, unit="kPa")
|
|
1069
|
+
|
|
1070
|
+
return sigma, tau
|
|
1071
|
+
|
|
1072
|
+
def _get_stress_envelope_exceedance(
|
|
1073
|
+
self, x_value: float, system: SystemModel, weak_layer: WeakLayer
|
|
1074
|
+
) -> float:
|
|
1075
|
+
"""
|
|
1076
|
+
Objective function for the root finder.
|
|
1077
|
+
Returns the stress envelope evaluation minus 1.
|
|
1078
|
+
"""
|
|
1079
|
+
sigma, tau = self._calculate_sigma_tau_at_x(x_value, system)
|
|
1080
|
+
return (
|
|
1081
|
+
self.stress_envelope(
|
|
1082
|
+
np.array([sigma]), np.array([tau]), weak_layer=weak_layer
|
|
1083
|
+
)[0]
|
|
1084
|
+
- 1
|
|
1085
|
+
)
|
|
1086
|
+
|
|
1087
|
+
def _find_stress_envelope_crossings(
|
|
1088
|
+
self, system: SystemModel, weak_layer: WeakLayer
|
|
1089
|
+
) -> List[float]:
|
|
1090
|
+
"""
|
|
1091
|
+
Finds the exact x-coordinates where the stress envelope is crossed.
|
|
1092
|
+
"""
|
|
1093
|
+
logger.debug("Finding stress envelope crossings.")
|
|
1094
|
+
start_time = time.time()
|
|
1095
|
+
analyzer = Analyzer(system)
|
|
1096
|
+
x_coords, z, _ = analyzer.rasterize_solution(mode="cracked", num=2000)
|
|
1097
|
+
|
|
1098
|
+
sigma_kPa = system.fq.sig(z, unit="kPa")
|
|
1099
|
+
tau_kPa = system.fq.tau(z, unit="kPa")
|
|
1100
|
+
|
|
1101
|
+
# Calculate the discrete distance to failure
|
|
1102
|
+
dist_to_stress_envelope = (
|
|
1103
|
+
self.stress_envelope(sigma_kPa, tau_kPa, weak_layer=weak_layer) - 1
|
|
1104
|
+
)
|
|
1105
|
+
|
|
1106
|
+
# Find indices where the envelope function transitions
|
|
1107
|
+
transition_indices = np.where(np.diff(np.sign(dist_to_stress_envelope)))[0]
|
|
1108
|
+
|
|
1109
|
+
# Find root candidates from transitions
|
|
1110
|
+
root_candidates = []
|
|
1111
|
+
for idx in transition_indices:
|
|
1112
|
+
x_left = x_coords[idx]
|
|
1113
|
+
x_right = x_coords[idx + 1]
|
|
1114
|
+
root_candidates.append((x_left, x_right))
|
|
1115
|
+
|
|
1116
|
+
# Search for roots within the identified candidates
|
|
1117
|
+
roots = []
|
|
1118
|
+
logger.debug(
|
|
1119
|
+
"Found %d potential crossing regions. Finding exact roots.",
|
|
1120
|
+
len(root_candidates),
|
|
1121
|
+
)
|
|
1122
|
+
roots_start_time = time.time()
|
|
1123
|
+
for x_left, x_right in root_candidates:
|
|
1124
|
+
try:
|
|
1125
|
+
root_result = root_scalar(
|
|
1126
|
+
self._get_stress_envelope_exceedance,
|
|
1127
|
+
args=(system, weak_layer),
|
|
1128
|
+
bracket=[x_left, x_right],
|
|
1129
|
+
method="brentq",
|
|
1130
|
+
)
|
|
1131
|
+
if root_result.converged:
|
|
1132
|
+
roots.append(root_result.root)
|
|
1133
|
+
except ValueError:
|
|
1134
|
+
# This can happen if the signs at the bracket edges are not opposite.
|
|
1135
|
+
# It's safe to ignore in this context.
|
|
1136
|
+
pass
|
|
1137
|
+
logger.debug("Root finding took %.4f seconds.", time.time() - roots_start_time)
|
|
1138
|
+
logger.info(
|
|
1139
|
+
"Found %d stress envelope crossings in %.4f seconds.",
|
|
1140
|
+
len(roots),
|
|
1141
|
+
time.time() - start_time,
|
|
1142
|
+
)
|
|
1143
|
+
return roots
|
|
1144
|
+
|
|
1145
|
+
def _fracture_toughness_exceedance(
|
|
1146
|
+
self, crack_length: float, system: SystemModel, target: float = 1
|
|
1147
|
+
) -> float:
|
|
1148
|
+
"""
|
|
1149
|
+
Objective function to evaluate the fracture toughness function.
|
|
1150
|
+
"""
|
|
1151
|
+
length = system.scenario.L
|
|
1152
|
+
segments = [
|
|
1153
|
+
Segment(length=length / 2 - crack_length / 2, has_foundation=True, m=0),
|
|
1154
|
+
Segment(length=crack_length / 2, has_foundation=False, m=0),
|
|
1155
|
+
Segment(length=crack_length / 2, has_foundation=False, m=0),
|
|
1156
|
+
Segment(length=length / 2 - crack_length / 2, has_foundation=True, m=0),
|
|
1157
|
+
]
|
|
1158
|
+
system.update_scenario(segments=segments)
|
|
1159
|
+
|
|
1160
|
+
analyzer = Analyzer(system)
|
|
1161
|
+
diff_energy = analyzer.differential_ERR(unit="J/m^2")
|
|
1162
|
+
G_I = diff_energy[1]
|
|
1163
|
+
G_II = diff_energy[2]
|
|
1164
|
+
|
|
1165
|
+
# Evaluate the fracture toughness function (boundary is equal to 1)
|
|
1166
|
+
g_delta_diff = self.fracture_toughness_envelope(G_I, G_II, system.weak_layer)
|
|
1167
|
+
|
|
1168
|
+
# Return the difference from the target
|
|
1169
|
+
return g_delta_diff - target
|