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