weac 3.0.0__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.
@@ -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