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,790 @@
1
+ """
2
+ This module provides the Analyzer class, which is used to analyze the results of the WEAC model.
3
+ """
4
+
5
+ # Standard library imports
6
+ import logging
7
+ import time
8
+ from collections import defaultdict
9
+ from functools import partial, wraps
10
+ from typing import Literal
11
+
12
+ # Third party imports
13
+ import numpy as np
14
+ from scipy.integrate import cumulative_trapezoid, quad
15
+
16
+ from weac.constants import G_MM_S2
17
+
18
+ # Module imports
19
+ from weac.core.system_model import SystemModel
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ def track_analyzer_call(func):
25
+ """Decorator to track call count and execution time of Analyzer methods."""
26
+
27
+ @wraps(func)
28
+ def wrapper(self, *args, **kwargs):
29
+ """Wrapper that adds tracking functionality."""
30
+
31
+ start_time = time.perf_counter()
32
+ result = func(self, *args, **kwargs)
33
+ duration = time.perf_counter() - start_time
34
+
35
+ func_name = func.__name__
36
+ self.call_stats[func_name]["count"] += 1
37
+ self.call_stats[func_name]["total_time"] += duration
38
+
39
+ logger.debug(
40
+ "Analyzer method '%s' called. Execution time: %.4f seconds.",
41
+ func_name,
42
+ duration,
43
+ )
44
+
45
+ return result
46
+
47
+ return wrapper
48
+
49
+
50
+ class Analyzer:
51
+ """
52
+ Provides methods for the analysis of layered slabs on compliant
53
+ elastic foundations.
54
+ """
55
+
56
+ sm: SystemModel
57
+ printing_enabled: bool = True
58
+
59
+ def __init__(self, system_model: SystemModel, printing_enabled: bool = True):
60
+ self.sm = system_model
61
+ self.call_stats = defaultdict(lambda: {"count": 0, "total_time": 0.0})
62
+ self.printing_enabled = printing_enabled
63
+
64
+ def get_call_stats(self):
65
+ """Returns the call statistics."""
66
+ return self.call_stats
67
+
68
+ def print_call_stats(self, message: str = "Analyzer Call Statistics"):
69
+ """Prints the call statistics in a readable format."""
70
+ if self.printing_enabled:
71
+ print(f"--- {message} ---")
72
+ if not self.call_stats:
73
+ print("No methods have been called.")
74
+ return
75
+
76
+ sorted_stats = sorted(
77
+ self.call_stats.items(),
78
+ key=lambda item: item[1]["total_time"],
79
+ reverse=True,
80
+ )
81
+
82
+ for func_name, stats in sorted_stats:
83
+ count = stats["count"]
84
+ total_time = stats["total_time"]
85
+ avg_time = total_time / count if count > 0 else 0
86
+ print(
87
+ f"- {func_name}: "
88
+ f"called {count} times, "
89
+ f"total time {total_time:.4f}s, "
90
+ f"avg time {avg_time:.4f}s"
91
+ )
92
+ print("---------------------------------")
93
+
94
+ @track_analyzer_call
95
+ def rasterize_solution(
96
+ self,
97
+ mode: Literal["cracked", "uncracked"] = "cracked",
98
+ num: int = 4000,
99
+ ):
100
+ """
101
+ Compute rasterized solution vector.
102
+
103
+ Parameters:
104
+ ---------
105
+ mode : Literal["cracked", "uncracked"]
106
+ Mode of the solution.
107
+ num : int
108
+ Number of grid points.
109
+
110
+ Returns
111
+ -------
112
+ xs : ndarray
113
+ Grid point x-coordinates at which solution vector
114
+ is discretized.
115
+ zs : ndarray
116
+ Matrix with solution vectors as columns at grid
117
+ points xs.
118
+ x_founded : ndarray
119
+ Grid point x-coordinates that lie on a foundation.
120
+ """
121
+ ki = self.sm.scenario.ki
122
+ match mode:
123
+ case "cracked":
124
+ C = self.sm.unknown_constants
125
+ case "uncracked":
126
+ ki = np.full(len(ki), True)
127
+ C = self.sm.uncracked_unknown_constants
128
+ phi = self.sm.scenario.phi
129
+ li = self.sm.scenario.li
130
+ qs = self.sm.scenario.surface_load
131
+
132
+ # Drop zero-length segments
133
+ li = abs(li)
134
+ isnonzero = li > 0
135
+ C, ki, li = C[:, isnonzero], ki[isnonzero], li[isnonzero]
136
+
137
+ # Compute number of plot points per segment (+1 for last segment)
138
+ ni = np.ceil(li / li.sum() * num).astype("int")
139
+ ni[-1] += 1
140
+
141
+ # Provide cumulated length and plot point lists
142
+ lic = np.insert(np.cumsum(li), 0, 0)
143
+ nic = np.insert(np.cumsum(ni), 0, 0)
144
+
145
+ # Initialize arrays
146
+ issupported = np.full(ni.sum(), True)
147
+ xs = np.full(ni.sum(), np.nan)
148
+ zs = np.full([6, xs.size], np.nan)
149
+
150
+ # Loop through segments
151
+ for i, length in enumerate(li):
152
+ # Get local x-coordinates of segment i
153
+ endpoint = i == li.size - 1
154
+ xi = np.linspace(0, length, num=ni[i], endpoint=endpoint)
155
+ # Compute start and end coordinates of segment i
156
+ x0 = lic[i]
157
+ # Assemble global coordinate vector
158
+ xs[nic[i] : nic[i + 1]] = x0 + xi
159
+ # Mask coordinates not on foundation (including endpoints)
160
+ if not ki[i]:
161
+ issupported[nic[i] : nic[i + 1]] = False
162
+ # Compute segment solution
163
+ zi = self.sm.z(xi, C[:, [i]], length, phi, ki[i], qs=qs)
164
+ # Assemble global solution matrix
165
+ zs[:, nic[i] : nic[i + 1]] = zi
166
+
167
+ # Make sure cracktips are included
168
+ transmissionbool = [ki[j] or ki[j + 1] for j, _ in enumerate(ki[:-1])]
169
+ for i, truefalse in enumerate(transmissionbool, start=1):
170
+ issupported[nic[i]] = truefalse
171
+
172
+ # Assemble vector of coordinates on foundation
173
+ xs_supported = np.full(ni.sum(), np.nan)
174
+ xs_supported[issupported] = xs[issupported]
175
+
176
+ return xs, zs, xs_supported
177
+
178
+ @track_analyzer_call
179
+ def get_zmesh(self, dz=2):
180
+ """
181
+ Get z-coordinates of grid points and corresponding elastic properties.
182
+
183
+ Arguments
184
+ ---------
185
+ dz : float, optional
186
+ Element size along z-axis (mm). Default is 2 mm.
187
+
188
+ Returns
189
+ -------
190
+ mesh : ndarray
191
+ Mesh along z-axis. Columns are a list of z-coordinates (mm) of
192
+ grid points along z-axis with at least two grid points (top,
193
+ bottom) per layer, Young's modulus of each grid point, shear
194
+ modulus of each grid point, and Poisson's ratio of each grid
195
+ point.
196
+ """
197
+ # Get z-coordinates of slab layers
198
+ z = np.concatenate([[self.sm.slab.z0], self.sm.slab.zi_bottom])
199
+ # Compute number of grid points per layer
200
+ nlayer = np.ceil((z[1:] - z[:-1]) / dz).astype(np.int32) + 1
201
+ # Calculate grid points as list of z-coordinates (mm)
202
+ zi = np.hstack(
203
+ [
204
+ np.linspace(z[i], z[i + 1], n, endpoint=True)
205
+ for i, n in enumerate(nlayer)
206
+ ]
207
+ )
208
+ # Extract elastic properties for each layer, reversing to match z order
209
+ layer_properties = {
210
+ "E": [layer.E for layer in self.sm.slab.layers],
211
+ "nu": [layer.nu for layer in self.sm.slab.layers],
212
+ "rho": [
213
+ layer.rho * 1e-12 for layer in self.sm.slab.layers
214
+ ], # Convert to t/mm^3
215
+ "tensile_strength": [
216
+ layer.tensile_strength for layer in self.sm.slab.layers
217
+ ],
218
+ }
219
+
220
+ # Repeat properties for each grid point in the layer
221
+ si = {"z": zi}
222
+ for prop, values in layer_properties.items():
223
+ si[prop] = np.repeat(values, nlayer)
224
+
225
+ return si
226
+
227
+ @track_analyzer_call
228
+ def Sxx(self, Z, phi, dz=2, unit="kPa"):
229
+ """
230
+ Compute axial normal stress in slab layers.
231
+
232
+ Arguments
233
+ ----------
234
+ Z : ndarray
235
+ Solution vector [u(x) u'(x) w(x) w'(x) psi(x), psi'(x)]^T
236
+ phi : float
237
+ Inclination (degrees). Counterclockwise positive.
238
+ dz : float, optional
239
+ Element size along z-axis (mm). Default is 2 mm.
240
+ unit : {'kPa', 'MPa'}, optional
241
+ Desired output unit. Default is 'kPa'.
242
+
243
+ Returns
244
+ -------
245
+ ndarray, float
246
+ Axial slab normal stress in specified unit.
247
+ """
248
+ # Unit conversion dict
249
+ convert = {"kPa": 1e3, "MPa": 1}
250
+
251
+ # Get mesh along z-axis
252
+ zmesh = self.get_zmesh(dz=dz)
253
+ zi = zmesh["z"]
254
+ rho = zmesh["rho"]
255
+
256
+ # Get dimensions of stress field (n rows, m columns)
257
+ n = len(zmesh["z"])
258
+ m = Z.shape[1]
259
+
260
+ # Initialize axial normal stress Sxx
261
+ Sxx = np.zeros(shape=[n, m])
262
+
263
+ # Compute axial normal stress Sxx at grid points in MPa
264
+ for i, z in enumerate(zi):
265
+ E = zmesh["E"][i]
266
+ nu = zmesh["nu"][i]
267
+ Sxx[i, :] = E / (1 - nu**2) * self.sm.fq.du_dx(Z, z)
268
+
269
+ # Calculate weight load at grid points and superimpose on stress field
270
+ qt = -rho * G_MM_S2 * np.sin(np.deg2rad(phi))
271
+ # Old Implementation: Changed for numerical stability
272
+ # for i, qi in enumerate(qt[:-1]):
273
+ # Sxx[i, :] += qi * (zi[i + 1] - zi[i])
274
+ # Sxx[-1, :] += qt[-1] * (zi[-1] - zi[-2])
275
+ # New Implementation: Changed for numerical stability
276
+ dz = np.diff(zi)
277
+ Sxx[:-1, :] += qt[:-1, np.newaxis] * dz[:, np.newaxis]
278
+ Sxx[-1, :] += qt[-1] * dz[-1]
279
+
280
+ # Return axial normal stress in specified unit
281
+ return convert[unit] * Sxx
282
+
283
+ @track_analyzer_call
284
+ def Txz(self, Z, phi, dz=2, unit="kPa"):
285
+ """
286
+ Compute shear stress in slab layers.
287
+
288
+ Arguments
289
+ ----------
290
+ Z : ndarray
291
+ Solution vector [u(x) u'(x) w(x) w'(x) psi(x), psi'(x)]^T
292
+ phi : float
293
+ Inclination (degrees). Counterclockwise positive.
294
+ dz : float, optional
295
+ Element size along z-axis (mm). Default is 2 mm.
296
+ unit : {'kPa', 'MPa'}, optional
297
+ Desired output unit. Default is 'kPa'.
298
+
299
+ Returns
300
+ -------
301
+ ndarray
302
+ Shear stress at grid points in the slab in specified unit.
303
+ """
304
+ # Unit conversion dict
305
+ convert = {"kPa": 1e3, "MPa": 1}
306
+ # Get mesh along z-axis
307
+ zmesh = self.get_zmesh(dz=dz)
308
+ zi = zmesh["z"]
309
+ rho = zmesh["rho"]
310
+ qs = self.sm.scenario.surface_load
311
+
312
+ # Get dimensions of stress field (n rows, m columns)
313
+ n = len(zi)
314
+ m = Z.shape[1]
315
+
316
+ # Get second derivatives of centerline displacement u0 and
317
+ # cross-section rotaiton psi of all grid points along the x-axis
318
+ du0_dxdx = self.sm.fq.du0_dxdx(Z, phi, qs=qs)
319
+ dpsi_dxdx = self.sm.fq.dpsi_dxdx(Z, phi, qs=qs)
320
+
321
+ # Initialize first derivative of axial normal stress sxx w.r.t. x
322
+ dsxx_dx = np.zeros(shape=[n, m])
323
+
324
+ # Calculate first derivative of sxx at z-grid points
325
+ for i, z in enumerate(zi):
326
+ E = zmesh["E"][i]
327
+ nu = zmesh["nu"][i]
328
+ dsxx_dx[i, :] = E / (1 - nu**2) * (du0_dxdx + z * dpsi_dxdx)
329
+
330
+ # Calculate weight load at grid points
331
+ qt = -rho * G_MM_S2 * np.sin(np.deg2rad(phi))
332
+
333
+ # Integrate -dsxx_dx along z and add cumulative weight load
334
+ # to obtain shear stress Txz in MPa
335
+ Txz = cumulative_trapezoid(dsxx_dx, zi, axis=0, initial=0)
336
+ Txz += cumulative_trapezoid(qt, zi, initial=0)[:, None]
337
+
338
+ # Return shear stress Txz in specified unit
339
+ return convert[unit] * Txz
340
+
341
+ @track_analyzer_call
342
+ def Szz(self, Z, phi, dz=2, unit="kPa"):
343
+ """
344
+ Compute transverse normal stress in slab layers.
345
+
346
+ Arguments
347
+ ----------
348
+ Z : ndarray
349
+ Solution vector [u(x) u'(x) w(x) w'(x) psi(x), psi'(x)]^T
350
+ phi : float
351
+ Inclination (degrees). Counterclockwise positive.
352
+ dz : float, optional
353
+ Element size along z-axis (mm). Default is 2 mm.
354
+ unit : {'kPa', 'MPa'}, optional
355
+ Desired output unit. Default is 'kPa'.
356
+
357
+ Returns
358
+ -------
359
+ ndarray, float
360
+ Transverse normal stress at grid points in the slab in
361
+ specified unit.
362
+ """
363
+ # Unit conversion dict
364
+ convert = {"kPa": 1e3, "MPa": 1}
365
+
366
+ # Get mesh along z-axis
367
+ zmesh = self.get_zmesh(dz=dz)
368
+ zi = zmesh["z"]
369
+ rho = zmesh["rho"]
370
+ qs = self.sm.scenario.surface_load
371
+ # Get dimensions of stress field (n rows, m columns)
372
+ n = len(zi)
373
+ m = Z.shape[1]
374
+
375
+ # Get third derivatives of centerline displacement u0 and
376
+ # cross-section rotaiton psi of all grid points along the x-axis
377
+ du0_dxdxdx = self.sm.fq.du0_dxdxdx(Z, phi, qs=qs)
378
+ dpsi_dxdxdx = self.sm.fq.dpsi_dxdxdx(Z, phi, qs=qs)
379
+
380
+ # Initialize second derivative of axial normal stress sxx w.r.t. x
381
+ dsxx_dxdx = np.zeros(shape=[n, m])
382
+
383
+ # Calculate second derivative of sxx at z-grid points
384
+ for i, z in enumerate(zi):
385
+ E = zmesh["E"][i]
386
+ nu = zmesh["nu"][i]
387
+ dsxx_dxdx[i, :] = E / (1 - nu**2) * (du0_dxdxdx + z * dpsi_dxdxdx)
388
+
389
+ # Calculate weight load at grid points
390
+ qn = rho * G_MM_S2 * np.cos(np.deg2rad(phi))
391
+
392
+ # Integrate dsxx_dxdx twice along z to obtain transverse
393
+ # normal stress Szz in MPa
394
+ integrand = cumulative_trapezoid(dsxx_dxdx, zi, axis=0, initial=0)
395
+ Szz = cumulative_trapezoid(integrand, zi, axis=0, initial=0)
396
+ Szz += cumulative_trapezoid(-qn, zi, initial=0)[:, None]
397
+
398
+ # Return shear stress txz in specified unit
399
+ return convert[unit] * Szz
400
+
401
+ @track_analyzer_call
402
+ def principal_stress_slab(
403
+ self,
404
+ Z,
405
+ phi: float,
406
+ dz: float = 2,
407
+ unit: Literal["kPa", "MPa"] = "kPa",
408
+ val: Literal["min", "max"] = "max",
409
+ normalize: bool = False,
410
+ ):
411
+ """
412
+ Compute maximum or minimum principal stress in slab layers.
413
+
414
+ Arguments
415
+ ---------
416
+ Z : ndarray
417
+ Solution vector [u(x) u'(x) w(x) w'(x) psi(x), psi'(x)]^T
418
+ phi : float
419
+ Inclination (degrees). Counterclockwise positive.
420
+ dz : float, optional
421
+ Element size along z-axis (mm). Default is 2 mm.
422
+ unit : {'kPa', 'MPa'}, optional
423
+ Desired output unit. Default is 'kPa'.
424
+ val : str, optional
425
+ Maximum 'max' or minimum 'min' principal stress. Default is 'max'.
426
+ normalize : bool
427
+ Toggle layerwise normalization to strength.
428
+
429
+ Returns
430
+ -------
431
+ ndarray
432
+ Maximum or minimum principal stress in specified unit.
433
+
434
+ Raises
435
+ ------
436
+ ValueError
437
+ If specified principal stress component is neither 'max' nor
438
+ 'min', or if normalization of compressive principal stress
439
+ is requested.
440
+ """
441
+ # Raise error if specified component is not available
442
+ if val not in ["min", "max"]:
443
+ raise ValueError(f"Component {val} not defined.")
444
+
445
+ # Multiplier selection dict
446
+ m = {"max": 1, "min": -1}
447
+
448
+ # Get axial normal stresses, shear stresses, transverse normal stresses
449
+ Sxx = self.Sxx(Z=Z, phi=phi, dz=dz, unit=unit)
450
+ Txz = self.Txz(Z=Z, phi=phi, dz=dz, unit=unit)
451
+ Szz = self.Szz(Z=Z, phi=phi, dz=dz, unit=unit)
452
+
453
+ # Calculate principal stress
454
+ Ps = (Sxx + Szz) / 2 + m[val] * np.sqrt((Sxx - Szz) ** 2 + 4 * Txz**2) / 2
455
+
456
+ # Raise error if normalization of compressive stresses is attempted
457
+ if normalize and val == "min":
458
+ raise ValueError("Can only normalize tensile stresses.")
459
+
460
+ # Normalize tensile stresses to tensile strength
461
+ if normalize and val == "max":
462
+ zmesh = self.get_zmesh(dz=dz)
463
+ tensile_strength = zmesh["tensile_strength"]
464
+ # Normalize maximum principal stress to layers' tensile strength
465
+ normalized_Ps = Ps / tensile_strength[:, None]
466
+ return normalized_Ps
467
+
468
+ # Return absolute principal stresses
469
+ return Ps
470
+
471
+ @track_analyzer_call
472
+ def principal_stress_weaklayer(
473
+ self,
474
+ Z,
475
+ sc: float = 2.6,
476
+ unit: Literal["kPa", "MPa"] = "kPa",
477
+ val: Literal["min", "max"] = "min",
478
+ normalize: bool = False,
479
+ ):
480
+ """
481
+ Compute maximum or minimum principal stress in the weak layer.
482
+
483
+ Arguments
484
+ ---------
485
+ Z : ndarray
486
+ Solution vector [u(x) u'(x) w(x) w'(x) psi(x), psi'(x)]^T
487
+ sc : float
488
+ Weak-layer compressive strength. Default is 2.6 kPa.
489
+ unit : {'kPa', 'MPa'}, optional
490
+ Desired output unit. Default is 'kPa'.
491
+ val : str, optional
492
+ Maximum 'max' or minimum 'min' principal stress. Default is 'min'.
493
+ normalize : bool
494
+ Toggle layerwise normalization to strength.
495
+
496
+ Returns
497
+ -------
498
+ ndarray
499
+ Maximum or minimum principal stress in specified unit.
500
+
501
+ Raises
502
+ ------
503
+ ValueError
504
+ If specified principal stress component is neither 'max' nor
505
+ 'min', or if normalization of tensile principal stress
506
+ is requested.
507
+ """
508
+ # Raise error if specified component is not available
509
+ if val not in ["min", "max"]:
510
+ raise ValueError(f"Component {val} not defined.")
511
+
512
+ # Multiplier selection dict
513
+ m = {"max": 1, "min": -1}
514
+
515
+ # Get weak-layer normal and shear stresses
516
+ sig = self.sm.fq.sig(Z, unit=unit)
517
+ tau = self.sm.fq.tau(Z, unit=unit)
518
+
519
+ # Calculate principal stress
520
+ ps = sig / 2 + m[val] * np.sqrt(sig**2 + 4 * tau**2) / 2
521
+
522
+ # Raise error if normalization of tensile stresses is attempted
523
+ if normalize and val == "max":
524
+ raise ValueError("Can only normalize compressive stresses.")
525
+
526
+ # Normalize compressive stresses to compressive strength
527
+ if normalize and val == "min":
528
+ return ps / sc
529
+
530
+ # Return absolute principal stresses
531
+ return ps
532
+
533
+ @track_analyzer_call
534
+ def incremental_ERR(
535
+ self, tolerance: float = 1e-6, unit: Literal["kJ/m^2", "J/m^2"] = "kJ/m^2"
536
+ ) -> np.ndarray:
537
+ """
538
+ Compute incremental energy release rate (ERR) of all cracks.
539
+
540
+ Returns
541
+ -------
542
+ ndarray
543
+ List of total, mode I, and mode II energy release rates.
544
+ """
545
+ li = self.sm.scenario.li
546
+ ki = self.sm.scenario.ki
547
+ k0 = np.ones_like(ki, dtype=bool)
548
+ C_uncracked = self.sm.uncracked_unknown_constants
549
+ C_cracked = self.sm.unknown_constants
550
+ phi = self.sm.scenario.phi
551
+ qs = self.sm.scenario.surface_load
552
+
553
+ # Reduce inputs to segments with crack advance
554
+ iscrack = k0 & ~ki
555
+ C_uncracked, C_cracked, li = (
556
+ C_uncracked[:, iscrack],
557
+ C_cracked[:, iscrack],
558
+ li[iscrack],
559
+ )
560
+
561
+ # Compute total crack lenght and initialize outputs
562
+ da = li.sum() if li.sum() > 0 else np.nan
563
+ Ginc1, Ginc2 = 0, 0
564
+
565
+ # Loop through segments with crack advance
566
+ for j, length in enumerate(li):
567
+ # Uncracked (0) and cracked (1) solutions at integration points
568
+ z_uncracked = partial(
569
+ self.sm.z,
570
+ C=C_uncracked[:, [j]],
571
+ length=length,
572
+ phi=phi,
573
+ has_foundation=True,
574
+ qs=qs,
575
+ )
576
+ z_cracked = partial(
577
+ self.sm.z,
578
+ C=C_cracked[:, [j]],
579
+ length=length,
580
+ phi=phi,
581
+ has_foundation=False,
582
+ qs=qs,
583
+ )
584
+
585
+ # Mode I (1) and II (2) integrands at integration points
586
+ intGI = partial(
587
+ self._integrand_GI, z_uncracked=z_uncracked, z_cracked=z_cracked
588
+ )
589
+ intGII = partial(
590
+ self._integrand_GII, z_uncracked=z_uncracked, z_cracked=z_cracked
591
+ )
592
+
593
+ # Segment contributions to total crack opening integral
594
+ Ginc1 += quad(intGI, 0, length, epsabs=tolerance, epsrel=tolerance)[0] / (
595
+ 2 * da
596
+ )
597
+ Ginc2 += quad(intGII, 0, length, epsabs=tolerance, epsrel=tolerance)[0] / (
598
+ 2 * da
599
+ )
600
+
601
+ convert = {"kJ/m^2": 1, "J/m^2": 1e3}
602
+ return np.array([Ginc1 + Ginc2, Ginc1, Ginc2]).flatten() * convert[unit]
603
+
604
+ @track_analyzer_call
605
+ def differential_ERR(
606
+ self, unit: Literal["kJ/m^2", "J/m^2"] = "kJ/m^2"
607
+ ) -> np.ndarray:
608
+ """
609
+ Compute differential energy release rate of all crack tips.
610
+
611
+ Returns
612
+ -------
613
+ ndarray
614
+ List of total, mode I, and mode II energy release rates.
615
+ """
616
+ li = self.sm.scenario.li
617
+ ki = self.sm.scenario.ki
618
+ C = self.sm.unknown_constants
619
+ phi = self.sm.scenario.phi
620
+ qs = self.sm.scenario.surface_load
621
+
622
+ # Get number and indices of segment transitions
623
+ ntr = len(li) - 1
624
+ itr = np.arange(ntr)
625
+
626
+ # Identify supported-free and free-supported transitions as crack tips
627
+ iscracktip = [ki[j] != ki[j + 1] for j in range(ntr)]
628
+
629
+ # Transition indices of crack tips and total number of crack tips
630
+ ict = itr[iscracktip]
631
+ nct = len(ict)
632
+
633
+ # Initialize energy release rate array
634
+ Gdif = np.zeros([3, nct])
635
+
636
+ # Compute energy relase rate of all crack tips
637
+ for j, idx in enumerate(ict):
638
+ # Solution at crack tip
639
+ z = self.sm.z(
640
+ li[idx], C[:, [idx]], li[idx], phi, has_foundation=ki[idx], qs=qs
641
+ )
642
+ # Mode I and II differential energy release rates
643
+ Gdif[1:, j] = np.concatenate(
644
+ (self.sm.fq.Gi(z, unit=unit), self.sm.fq.Gii(z, unit=unit))
645
+ )
646
+
647
+ # Sum mode I and II contributions
648
+ Gdif[0, :] = Gdif[1, :] + Gdif[2, :]
649
+
650
+ # Adjust contributions for center cracks
651
+ if nct > 1:
652
+ avgmask = np.full(nct, True) # Initialize mask
653
+ avgmask[[0, -1]] = ki[[0, -1]] # Do not weight edge cracks
654
+ Gdif[:, avgmask] *= 0.5 # Weigth with half crack length
655
+
656
+ # Return total differential energy release rate of all crack tips
657
+ return Gdif.sum(axis=1)
658
+
659
+ def _integrand_GI(
660
+ self, x: float | np.ndarray, z_uncracked, z_cracked
661
+ ) -> float | np.ndarray:
662
+ """
663
+ Mode I integrand for energy release rate calculation.
664
+ """
665
+ sig_uncracked = self.sm.fq.sig(z_uncracked(x))
666
+ eps_cracked = self.sm.fq.eps(z_cracked(x))
667
+ return sig_uncracked * eps_cracked * self.sm.weak_layer.h
668
+
669
+ def _integrand_GII(
670
+ self, x: float | np.ndarray, z_uncracked, z_cracked
671
+ ) -> float | np.ndarray:
672
+ """
673
+ Mode II integrand for energy release rate calculation.
674
+ """
675
+ tau_uncracked = self.sm.fq.tau(z_uncracked(x))
676
+ gamma_cracked = self.sm.fq.gamma(z_cracked(x))
677
+ return tau_uncracked * gamma_cracked * self.sm.weak_layer.h
678
+
679
+ @track_analyzer_call
680
+ def total_potential(self):
681
+ """
682
+ Returns total differential potential.
683
+ Currently only implemented for PST systems.
684
+
685
+ Returns
686
+ -------
687
+ Pi : float
688
+ Total differential potential (Nmm).
689
+ """
690
+ Pi_int = self._internal_potential()
691
+ Pi_ext = self._external_potential()
692
+
693
+ return Pi_int + Pi_ext
694
+
695
+ def _external_potential(self):
696
+ """
697
+ Compute total external potential (pst only).
698
+
699
+ Returns
700
+ -------
701
+ Pi_ext : float
702
+ Total external potential [Nmm].
703
+ """
704
+ if self.sm.scenario.system_type not in ["pst-", "-pst"]:
705
+ logger.error("Input error: Only pst-setup implemented at the moment.")
706
+ raise NotImplementedError("Only pst-setup implemented at the moment.")
707
+
708
+ # Rasterize solution
709
+ xq, zq, xb = self.rasterize_solution(mode="cracked", num=2000)
710
+ _ = xq, xb
711
+ # Compute displacements where weight loads are applied
712
+ w0 = self.sm.fq.w(zq)
713
+ us = self.sm.fq.u(zq, h0=self.sm.slab.z_cog)
714
+ # Get weight loads
715
+ qn = self.sm.scenario.qn
716
+ qt = self.sm.scenario.qt
717
+ # use +/- and us[0]/us[-1] according to system and phi
718
+ # compute total external potential
719
+ Pi_ext = (
720
+ -qn * (self.sm.scenario.li[0] + self.sm.scenario.li[1]) * np.average(w0)
721
+ - qn
722
+ * (self.sm.scenario.L - (self.sm.scenario.li[0] + self.sm.scenario.li[1]))
723
+ * self.sm.scenario.crack_h
724
+ )
725
+ # Ensure
726
+ ub = us[0] if self.sm.scenario.system_type in ["-pst"] else us[-1]
727
+ Pi_ext += (
728
+ -qt * (self.sm.scenario.li[0] + self.sm.scenario.li[1]) * np.average(us)
729
+ - qt
730
+ * (self.sm.scenario.L - (self.sm.scenario.li[0] + self.sm.scenario.li[1]))
731
+ * ub
732
+ )
733
+
734
+ return Pi_ext
735
+
736
+ def _internal_potential(self):
737
+ """
738
+ Compute total internal potential (pst only).
739
+
740
+ Returns
741
+ -------
742
+ Pi_int : float
743
+ Total internal potential [Nmm].
744
+ """
745
+ if self.sm.scenario.system_type not in ["pst-", "-pst"]:
746
+ logger.error("Input error: Only pst-setup implemented at the moment.")
747
+ raise NotImplementedError("Only pst-setup implemented at the moment.")
748
+
749
+ # Extract system parameters
750
+ L = self.sm.scenario.L
751
+ system_type = self.sm.scenario.system_type
752
+ A11 = self.sm.eigensystem.A11
753
+ B11 = self.sm.eigensystem.B11
754
+ D11 = self.sm.eigensystem.D11
755
+ kA55 = self.sm.eigensystem.kA55
756
+ kn = self.sm.weak_layer.kn
757
+ kt = self.sm.weak_layer.kt
758
+
759
+ # Rasterize solution
760
+ xq, zq, xb = self.rasterize_solution(mode="cracked", num=2000)
761
+
762
+ # Compute section forces
763
+ N, M, V = self.sm.fq.N(zq), self.sm.fq.M(zq), self.sm.fq.V(zq)
764
+
765
+ # Drop parts of the solution that are not a foundation
766
+ zweak = zq[:, ~np.isnan(xb)]
767
+ xweak = xb[~np.isnan(xb)]
768
+
769
+ # Compute weak layer displacements
770
+ wweak = self.sm.fq.w(zweak)
771
+ uweak = self.sm.fq.u(zweak, h0=self.sm.slab.H / 2)
772
+
773
+ # Compute stored energy of the slab (monte-carlo integration)
774
+ n = len(xq)
775
+ nweak = len(xweak)
776
+ # energy share from moment, shear force, wl normal and tangential springs
777
+ Pi_int = (
778
+ L / 2 / n / A11 * np.sum([Ni**2 for Ni in N])
779
+ + L / 2 / n / (D11 - B11**2 / A11) * np.sum([Mi**2 for Mi in M])
780
+ + L / 2 / n / kA55 * np.sum([Vi**2 for Vi in V])
781
+ + L * kn / 2 / nweak * np.sum([wi**2 for wi in wweak])
782
+ + L * kt / 2 / nweak * np.sum([ui**2 for ui in uweak])
783
+ )
784
+ # energy share from substitute rotation spring
785
+ if system_type in ["pst-"]:
786
+ Pi_int += 1 / 2 * M[-1] * (self.sm.fq.psi(zq)[-1]) ** 2
787
+ elif system_type in ["-pst"]:
788
+ Pi_int += 1 / 2 * M[0] * (self.sm.fq.psi(zq)[0]) ** 2
789
+
790
+ return Pi_int