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,1925 @@
1
+ """
2
+ This module provides plotting functions for visualizing the results of the WEAC model.
3
+ """
4
+
5
+ # Standard library imports
6
+ import colorsys
7
+ import logging
8
+ import os
9
+ from typing import Literal
10
+
11
+ # Third party imports
12
+ import matplotlib.colors as mc
13
+ import matplotlib.pyplot as plt
14
+ import numpy as np
15
+ from matplotlib.figure import Figure
16
+ from matplotlib.patches import Patch, Polygon, Rectangle
17
+ from scipy.optimize import brentq
18
+
19
+ from weac.analysis.analyzer import Analyzer
20
+ from weac.analysis.criteria_evaluator import (
21
+ CoupledCriterionResult,
22
+ CriteriaEvaluator,
23
+ FindMinimumForceResult,
24
+ )
25
+
26
+ # Module imports
27
+ from weac.components.layer import WeakLayer
28
+ from weac.core.scenario import Scenario
29
+ from weac.core.slab import Slab
30
+ from weac.core.system_model import SystemModel
31
+ from weac.utils.misc import isnotebook
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ LABELSTYLE = {
36
+ "backgroundcolor": "w",
37
+ "horizontalalignment": "center",
38
+ "verticalalignment": "center",
39
+ }
40
+
41
+ COLORS = np.array(
42
+ [ # TUD color palette
43
+ ["#DCDCDC", "#B5B5B5", "#898989", "#535353"], # gray
44
+ ["#5D85C3", "#005AA9", "#004E8A", "#243572"], # blue
45
+ ["#009CDA", "#0083CC", "#00689D", "#004E73"], # ocean
46
+ ["#50B695", "#009D81", "#008877", "#00715E"], # teal
47
+ ["#AFCC50", "#99C000", "#7FAB16", "#6A8B22"], # green
48
+ ["#DDDF48", "#C9D400", "#B1BD00", "#99A604"], # lime
49
+ ["#FFE05C", "#FDCA00", "#D7AC00", "#AE8E00"], # yellow
50
+ ["#F8BA3C", "#F5A300", "#D28700", "#BE6F00"], # sand
51
+ ["#EE7A34", "#EC6500", "#CC4C03", "#A94913"], # orange
52
+ ["#E9503E", "#E6001A", "#B90F22", "#961C26"], # red
53
+ ["#C9308E", "#A60084", "#951169", "#732054"], # magenta
54
+ ["#804597", "#721085", "#611C73", "#4C226A"], # purple
55
+ ]
56
+ )
57
+
58
+
59
+ def _outline(grid):
60
+ """Extract _outline values of a 2D array (matrix, grid)."""
61
+ top = grid[0, :-1]
62
+ right = grid[:-1, -1]
63
+ bot = grid[-1, :0:-1]
64
+ left = grid[::-1, 0]
65
+
66
+ return np.hstack([top, right, bot, left])
67
+
68
+
69
+ def _significant_digits(decimal: float) -> int:
70
+ """Return the number of significant digits for a given decimal."""
71
+ if decimal == 0:
72
+ return 1
73
+ try:
74
+ sig_digits = -int(np.floor(np.log10(decimal)))
75
+ except ValueError:
76
+ sig_digits = 3
77
+ return sig_digits
78
+
79
+
80
+ def _tight_central_distribution(limit, samples=100, tightness=1.5):
81
+ """
82
+ Provide values within a given interval distributed tightly around 0.
83
+
84
+ Parameters
85
+ ----------
86
+ limit : float
87
+ Maximum and minimum of value range.
88
+ samples : int, optional
89
+ Number of values. Default is 100.
90
+ tightness : int, optional
91
+ Degree of value densification at center. 1.0 corresponds
92
+ to equal spacing. Default is 1.5.
93
+
94
+ Returns
95
+ -------
96
+ ndarray
97
+ Array of values more tightly spaced around 0.
98
+ """
99
+ stop = limit ** (1 / tightness)
100
+ levels = np.linspace(0, stop, num=int(samples / 2), endpoint=True) ** tightness
101
+ return np.unique(np.hstack([-levels[::-1], levels]))
102
+
103
+
104
+ def _adjust_lightness(color, amount=0.5):
105
+ """
106
+ Adjust color lightness.
107
+
108
+ Arguments
109
+ ----------
110
+ color : str or tuple
111
+ Matplotlib colorname, hex string, or RGB value tuple.
112
+ amount : float, optional
113
+ Amount of lightening: >1 lightens, <1 darkens. Default is 0.5.
114
+
115
+ Returns
116
+ -------
117
+ tuple
118
+ RGB color tuple.
119
+ """
120
+ try:
121
+ c = mc.cnames[color]
122
+ except KeyError:
123
+ c = color
124
+ c = colorsys.rgb_to_hls(*mc.to_rgb(c))
125
+ return colorsys.hls_to_rgb(c[0], max(0, min(1, amount * c[1])), c[2])
126
+
127
+
128
+ class MidpointNormalize(mc.Normalize):
129
+ """Colormap normalization to a specified midpoint. Default is 0."""
130
+
131
+ def __init__(self, vmin, vmax, midpoint=0, clip=False):
132
+ """Initialize normalization."""
133
+ self.midpoint = midpoint
134
+ mc.Normalize.__init__(self, vmin, vmax, clip)
135
+
136
+ def __call__(self, value, clip=None):
137
+ """Apply normalization."""
138
+ x, y = [self.vmin, self.midpoint, self.vmax], [0, 0.5, 1]
139
+ return np.ma.masked_array(np.interp(value, x, y))
140
+
141
+
142
+ class Plotter:
143
+ """
144
+ Modern plotting class for WEAC simulations with support for multiple system comparisons.
145
+
146
+ This class provides comprehensive visualization capabilities for weak layer anticrack
147
+ nucleation simulations, including single system analysis and multi-system comparisons.
148
+
149
+ Features:
150
+ - Single and multi-system plotting
151
+ - System override functionality for selective plotting
152
+ - Comprehensive dashboard creation
153
+ - Modern matplotlib styling
154
+ - Jupyter notebook integration
155
+ - Automatic plot directory management
156
+ """
157
+
158
+ def __init__(
159
+ self,
160
+ plot_dir: str = "plots",
161
+ ):
162
+ """
163
+ Initialize the plotter.
164
+
165
+ Parameters
166
+ ----------
167
+ system : SystemModel, optional
168
+ Single system model for analysis
169
+ systems : List[SystemModel], optional
170
+ List of system models for comparison
171
+ labels : List[str], optional
172
+ Labels for each system in plots
173
+ colors : List[str], optional
174
+ Colors for each system in plots
175
+ plot_dir : str, default "plots"
176
+ Directory to save plots
177
+ """
178
+ self.labels = LABELSTYLE
179
+ self.colors = COLORS
180
+
181
+ # Set up plot directory
182
+ self.plot_dir = plot_dir
183
+ os.makedirs(self.plot_dir, exist_ok=True)
184
+
185
+ # Set up matplotlib style
186
+ self._setup_matplotlib_style()
187
+
188
+ # Cache analyzers for performance
189
+ self._analyzers = {}
190
+
191
+ def _setup_matplotlib_style(self):
192
+ """Set up modern matplotlib styling."""
193
+ plt.style.use("default")
194
+ plt.rcParams.update(
195
+ {
196
+ "figure.figsize": (12, 8),
197
+ "figure.dpi": 100,
198
+ "savefig.dpi": 300,
199
+ "savefig.bbox": "tight",
200
+ "font.size": 11,
201
+ "axes.titlesize": 14,
202
+ "axes.labelsize": 12,
203
+ "xtick.labelsize": 10,
204
+ "ytick.labelsize": 10,
205
+ "legend.fontsize": 10,
206
+ "lines.linewidth": 2,
207
+ "axes.grid": True,
208
+ "grid.alpha": 0.3,
209
+ "axes.axisbelow": True,
210
+ }
211
+ )
212
+
213
+ def _get_analyzer(self, system: SystemModel) -> Analyzer:
214
+ """Get cached analyzer for a system."""
215
+ system_id = id(system)
216
+ if system_id not in self._analyzers:
217
+ self._analyzers[system_id] = Analyzer(system_model=system)
218
+ return self._analyzers[system_id]
219
+
220
+ def _get_systems_to_plot(
221
+ self,
222
+ system_model: SystemModel | None = None,
223
+ system_models: list[SystemModel] | None = None,
224
+ ) -> list[SystemModel]:
225
+ """Determine which systems to plot based on override parameters."""
226
+ if system_model is not None and system_models is not None:
227
+ raise ValueError(
228
+ "Provide either 'system_model' or 'system_models', not both"
229
+ )
230
+ if isinstance(system_model, SystemModel):
231
+ return [system_model]
232
+ if isinstance(system_models, list):
233
+ return system_models
234
+ raise ValueError(
235
+ "Must provide either 'system_model' or 'system_models' as a "
236
+ "SystemModel or list of SystemModels"
237
+ )
238
+
239
+ def _save_figure(self, filename: str, fig: Figure | None = None):
240
+ """Save figure with proper formatting."""
241
+ if fig is None:
242
+ fig = plt.gcf()
243
+
244
+ filepath = os.path.join(self.plot_dir, f"{filename}.png")
245
+ fig.savefig(filepath, dpi=300, bbox_inches="tight", facecolor="white")
246
+
247
+ if not isnotebook():
248
+ plt.close(fig)
249
+
250
+ def plot_slab_profile(
251
+ self,
252
+ weak_layers: list[WeakLayer] | WeakLayer,
253
+ slabs: list[Slab] | Slab,
254
+ filename: str = "slab_profile",
255
+ labels: list[str] | str | None = None,
256
+ colors: list[str] | None = None,
257
+ ):
258
+ """
259
+ Plot slab layer profiles for comparison.
260
+
261
+ Parameters
262
+ ----------
263
+ weak_layers : list[WeakLayer] | WeakLayer
264
+ The weak layer or layers to plot.
265
+ slabs : list[Slab] | Slab
266
+ The slab or slabs to plot.
267
+ filename : str, optional
268
+ Filename for saving plot
269
+ labels : list of str, optional
270
+ Labels for each system.
271
+ colors : list of str, optional
272
+ Colors for each system.
273
+
274
+ Returns
275
+ -------
276
+ matplotlib.figure.Figure
277
+ The generated plot figure.
278
+ """
279
+ if isinstance(weak_layers, WeakLayer):
280
+ weak_layers = [weak_layers]
281
+ if isinstance(slabs, Slab):
282
+ slabs = [slabs]
283
+
284
+ if len(weak_layers) != len(slabs):
285
+ raise ValueError("Number of weak layers must match number of slabs")
286
+
287
+ if labels is None:
288
+ labels = [f"System {i + 1}" for i in range(len(weak_layers))]
289
+ elif isinstance(labels, str):
290
+ labels = [labels] * len(slabs)
291
+ elif len(labels) != len(slabs):
292
+ raise ValueError("Number of labels must match number of slabs")
293
+
294
+ if colors is None:
295
+ plot_colors = [self.colors[i, 0] for i in range(len(slabs))]
296
+ else:
297
+ plot_colors = colors
298
+
299
+ # Plot Setup
300
+ plt.rcdefaults()
301
+ plt.rc("font", family="serif", size=8)
302
+ plt.rc("mathtext", fontset="cm")
303
+
304
+ fig = plt.figure(figsize=(8 / 3, 4))
305
+ ax1 = fig.gca()
306
+
307
+ # Plot 1: Layer thickness and density
308
+ max_height = 0
309
+ for i, slab in enumerate(slabs):
310
+ total_height = slab.H + weak_layers[i].h
311
+ max_height = max(max_height, total_height)
312
+
313
+ for i, (weak_layer, slab, label, color) in enumerate(
314
+ zip(weak_layers, slabs, labels, plot_colors)
315
+ ):
316
+ # Plot weak layer
317
+ wl_y = [-weak_layer.h, 0]
318
+ wl_x = [weak_layer.rho, weak_layer.rho]
319
+ ax1.fill_betweenx(wl_y, 0, wl_x, color="red", alpha=0.8, hatch="///")
320
+
321
+ # Plot slab layers
322
+ x_coords = []
323
+ y_coords = []
324
+ current_height = 0
325
+
326
+ # As slab.layers is top-down
327
+ for layer in reversed(slab.layers):
328
+ x_coords.extend([layer.rho, layer.rho])
329
+ y_coords.extend([current_height, current_height + layer.h])
330
+ current_height += layer.h
331
+
332
+ ax1.fill_betweenx(
333
+ y_coords, 0, x_coords, color=color, alpha=0.7, label=label
334
+ )
335
+
336
+ # Set axis labels
337
+ ax1.set_xlabel(r"$\longleftarrow$ Density $\rho$ (kg/m$^3$)")
338
+ ax1.set_ylabel(r"Height above weak layer (mm) $\longrightarrow$")
339
+
340
+ ax1.set_title("Slab Density Profile")
341
+
342
+ handles, slab_labels = ax1.get_legend_handles_labels()
343
+ weak_layer_patch = Patch(
344
+ facecolor="red", alpha=0.8, hatch="///", label="Weak Layer"
345
+ )
346
+ ax1.legend(
347
+ handles=[weak_layer_patch] + handles, labels=["Weak Layer"] + slab_labels
348
+ )
349
+
350
+ ax1.grid(True, alpha=0.3)
351
+ ax1.set_xlim(500, 0)
352
+ ax1.set_ylim(-min(weak_layer.h for weak_layer in weak_layers), max_height)
353
+
354
+ if filename:
355
+ self._save_figure(filename, fig)
356
+
357
+ return fig
358
+
359
+ def plot_rotated_slab_profile(
360
+ self,
361
+ weak_layer: WeakLayer,
362
+ slab: Slab,
363
+ angle: float = 0,
364
+ weight: float = 0,
365
+ slab_width: float = 200,
366
+ filename: str = "rotated_slab_profile",
367
+ title: str = "Rotated Slab Profile",
368
+ ):
369
+ """
370
+ Plot a rectangular slab profile with layers stacked vertically, colored by density,
371
+ and rotated by the specified angle.
372
+
373
+ Parameters
374
+ ----------
375
+ weak_layer : WeakLayer
376
+ The weak layer to plot at the bottom.
377
+ slab : Slab
378
+ The slab with layers to plot.
379
+ angle : float, optional
380
+ Rotation angle in degrees. Default is 0.
381
+ slab_width : float, optional
382
+ Width of the slab rectangle in mm. Default is 200.
383
+ filename : str, optional
384
+ Filename for saving plot. Default is "rotated_slab_profile".
385
+ title : str, optional
386
+ Plot title. Default is "Rotated Slab Profile".
387
+
388
+ Returns
389
+ -------
390
+ matplotlib.figure.Figure
391
+ The generated plot figure.
392
+ """
393
+ # Plot Setup
394
+ plt.rcdefaults()
395
+ plt.rc("font", family="serif", size=10)
396
+ plt.rc("mathtext", fontset="cm")
397
+
398
+ fig = plt.figure(figsize=(8, 6), dpi=300)
399
+ ax = fig.gca()
400
+
401
+ # Calculate total height
402
+ total_height = slab.H + weak_layer.h
403
+
404
+ # Create density-based colormap
405
+ all_densities = [weak_layer.rho] + [layer.rho for layer in slab.layers]
406
+ min_density = min(all_densities)
407
+ max_density = max(all_densities)
408
+
409
+ # Normalize densities for color mapping
410
+ norm = mc.Normalize(vmin=min_density, vmax=max_density)
411
+ cmap = plt.get_cmap("viridis") # You can change this to any colormap
412
+
413
+ # Function to create sloped layer (parallelogram)
414
+ def create_sloped_layer(x, y, width, height, angle_rad):
415
+ """Create a layer that follows the slope angle"""
416
+ # Calculate horizontal offset for the slope
417
+ slope_offset = width * np.sin(angle_rad)
418
+
419
+ # Create parallelogram corners
420
+ # Bottom edge is horizontal, top edge is shifted by slope_offset
421
+ corners = np.array(
422
+ [
423
+ [x, y], # Bottom left
424
+ [x + width, y + slope_offset], # Bottom right
425
+ [x + width, y + height + slope_offset], # Top right (shifted)
426
+ [x, y + height], # Top left (shifted)
427
+ ]
428
+ )
429
+
430
+ return corners
431
+
432
+ # Convert angle to radians
433
+ angle_rad = np.radians(angle)
434
+
435
+ # Start from bottom (weak layer)
436
+ current_y = 0
437
+
438
+ # Plot weak layer
439
+ wl_corners = create_sloped_layer(
440
+ 0, current_y, slab_width, weak_layer.h, angle_rad
441
+ )
442
+ wl_color = cmap(norm(weak_layer.rho))
443
+ wl_patch = Polygon(
444
+ wl_corners,
445
+ facecolor=wl_color,
446
+ edgecolor="black",
447
+ linewidth=1,
448
+ alpha=0.8,
449
+ hatch="///",
450
+ )
451
+ ax.add_patch(wl_patch)
452
+
453
+ # Add density label for weak layer
454
+ wl_center = np.mean(wl_corners, axis=0)
455
+ ax.text(
456
+ wl_center[0],
457
+ wl_center[1],
458
+ f"{weak_layer.rho:.0f}\nkg/m³",
459
+ ha="center",
460
+ va="center",
461
+ fontsize=8,
462
+ fontweight="bold",
463
+ )
464
+
465
+ current_y += weak_layer.h
466
+
467
+ # Plot slab layers (from bottom to top)
468
+ top_layer_corners = None
469
+ for _i, layer in enumerate(reversed(slab.layers)):
470
+ layer_corners = create_sloped_layer(
471
+ 0, current_y, slab_width, layer.h, angle_rad
472
+ )
473
+ layer_color = cmap(norm(layer.rho))
474
+ layer_patch = Polygon(
475
+ layer_corners,
476
+ facecolor=layer_color,
477
+ edgecolor="black",
478
+ linewidth=1,
479
+ alpha=0.8,
480
+ )
481
+ ax.add_patch(layer_patch)
482
+
483
+ # Add density label for slab layer
484
+ layer_center = np.mean(layer_corners, axis=0)
485
+ ax.text(
486
+ layer_center[0],
487
+ layer_center[1],
488
+ f"{layer.rho:.0f}\nkg/m³",
489
+ ha="center",
490
+ va="center",
491
+ fontsize=8,
492
+ fontweight="bold",
493
+ )
494
+
495
+ current_y += layer.h
496
+ # Keep track of the top layer corners for arrow placement
497
+ top_layer_corners = layer_corners
498
+
499
+ # Add weight arrow if weight > 0 and we have layers
500
+ if weight > 0 and top_layer_corners is not None:
501
+ # Calculate midpoint of top edge of highest layer
502
+ # Top edge is between points 2 and 3 (top right and top left)
503
+ top_left = top_layer_corners[3]
504
+ top_right = top_layer_corners[2]
505
+ arrow_start_x = (top_left[0] + top_right[0]) / 2
506
+ arrow_start_y = (top_left[1] + top_right[1]) / 2
507
+
508
+ # Scale arrow based on weight (0-400 maps to 0-100, above 400 = 100)
509
+ max_arrow_height = 100
510
+ arrow_height = min(weight * max_arrow_height / 400, max_arrow_height)
511
+ arrow_width = arrow_height * 0.3 # Arrow width proportional to height
512
+
513
+ # Create arrow pointing downward
514
+ arrow_tip_x = arrow_start_x
515
+ arrow_tip_y = arrow_start_y
516
+
517
+ # Arrow shaft (rectangular part)
518
+ shaft_width = arrow_width * 0.3
519
+ shaft_left = arrow_start_x - shaft_width / 2
520
+ shaft_right = arrow_start_x + shaft_width / 2
521
+ shaft_top = arrow_start_y + arrow_height
522
+ shaft_bottom = arrow_tip_y + arrow_width * 0.4
523
+
524
+ # Arrow head (triangular part)
525
+ head_left = arrow_start_x - arrow_width / 2
526
+ head_right = arrow_start_x + arrow_width / 2
527
+ head_top = shaft_bottom
528
+
529
+ # Draw arrow shaft
530
+ shaft_corners = np.array(
531
+ [
532
+ [shaft_left, shaft_top],
533
+ [shaft_right, shaft_top],
534
+ [shaft_right, shaft_bottom],
535
+ [shaft_left, shaft_bottom],
536
+ ]
537
+ )
538
+ shaft_patch = Polygon(
539
+ shaft_corners,
540
+ facecolor="red",
541
+ edgecolor="darkred",
542
+ linewidth=2,
543
+ alpha=0.8,
544
+ )
545
+ ax.add_patch(shaft_patch)
546
+
547
+ # Draw arrow head
548
+ head_corners = np.array(
549
+ [
550
+ [head_left, head_top],
551
+ [head_right, head_top],
552
+ [arrow_tip_x, arrow_tip_y],
553
+ ]
554
+ )
555
+ head_patch = Polygon(
556
+ head_corners,
557
+ facecolor="red",
558
+ edgecolor="darkred",
559
+ linewidth=2,
560
+ alpha=0.8,
561
+ )
562
+ ax.add_patch(head_patch)
563
+
564
+ # Add weight label
565
+ ax.text(
566
+ arrow_start_x + arrow_width * 0.7,
567
+ arrow_start_y - arrow_height / 2,
568
+ f"{weight:.0f} kg",
569
+ ha="left",
570
+ va="center",
571
+ fontsize=10,
572
+ fontweight="bold",
573
+ color="darkred",
574
+ bbox={
575
+ "boxstyle": "round,pad=0.3",
576
+ "facecolor": "white",
577
+ "alpha": 0.8,
578
+ },
579
+ )
580
+
581
+ # Calculate plot limits to accommodate rotated rectangle
582
+ margin = max(slab_width, total_height) * 0.2
583
+
584
+ # Find the bounds of all rotated rectangles
585
+ all_corners = []
586
+ current_y = 0
587
+
588
+ # Weak layer corners
589
+ wl_corners = create_sloped_layer(
590
+ 0, current_y, slab_width, weak_layer.h, angle_rad
591
+ )
592
+ all_corners.extend(wl_corners)
593
+ current_y += weak_layer.h
594
+
595
+ # Slab layer corners
596
+ for layer in reversed(slab.layers):
597
+ layer_corners = create_sloped_layer(
598
+ 0, current_y, slab_width, layer.h, angle_rad
599
+ )
600
+ all_corners.extend(layer_corners)
601
+ current_y += layer.h
602
+
603
+ all_corners = np.array(all_corners)
604
+ min_x, max_x = all_corners[:, 0].min(), all_corners[:, 0].max()
605
+ min_y, max_y = all_corners[:, 1].min(), all_corners[:, 1].max()
606
+
607
+ # Set axis limits with margin
608
+ ax.set_xlim(min_x - margin, max_x + margin)
609
+ ax.set_ylim(min_y - margin, max_y + margin)
610
+
611
+ # Set labels and title
612
+ ax.set_xlabel("Width (mm)")
613
+ ax.set_ylabel("Height (mm)")
614
+ ax.set_title(f"{title}\nSlope Angle: {angle}°")
615
+
616
+ # Add colorbar
617
+ sm = plt.cm.ScalarMappable(cmap=cmap, norm=norm)
618
+ sm.set_array([])
619
+ cbar = plt.colorbar(sm, ax=ax)
620
+ cbar.set_label("Density (kg/m³)")
621
+
622
+ # Add legend
623
+ weak_layer_patch = Patch(
624
+ facecolor=cmap(norm(weak_layer.rho)),
625
+ hatch="///",
626
+ edgecolor="black",
627
+ label="Weak Layer",
628
+ )
629
+ slab_patch = Patch(facecolor="gray", edgecolor="black", label="Slab Layers")
630
+ ax.legend(handles=[weak_layer_patch, slab_patch], loc="upper right")
631
+
632
+ # Equal aspect ratio and grid
633
+ ax.set_aspect("equal")
634
+ ax.grid(True, alpha=0.3)
635
+
636
+ # Remove axis ticks for cleaner look
637
+ ax.tick_params(axis="both", which="major", labelsize=8)
638
+
639
+ plt.tight_layout()
640
+
641
+ if filename:
642
+ self._save_figure(filename, fig)
643
+
644
+ return fig
645
+
646
+ def plot_section_forces(
647
+ self,
648
+ system_model: SystemModel | None = None,
649
+ system_models: list[SystemModel] | None = None,
650
+ filename: str = "section_forces",
651
+ labels: list[str] | None = None,
652
+ colors: list[str] | None = None,
653
+ ):
654
+ """
655
+ Plot section forces (N, M, V) for comparison.
656
+
657
+ Parameters
658
+ ----------
659
+ system_model : SystemModel, optional
660
+ Single system to plot (overrides default)
661
+ system_models : list[SystemModel], optional
662
+ Multiple systems to plot (overrides default)
663
+ filename : str, optional
664
+ Filename for saving plot
665
+ labels : list of str, optional
666
+ Labels for each system.
667
+ colors : list of str, optional
668
+ Colors for each system.
669
+ """
670
+ systems_to_plot = self._get_systems_to_plot(system_model, system_models)
671
+
672
+ if labels is None:
673
+ labels = [f"System {i + 1}" for i in range(len(systems_to_plot))]
674
+ if colors is None:
675
+ plot_colors = [self.colors[i, 0] for i in range(len(systems_to_plot))]
676
+ else:
677
+ plot_colors = colors
678
+
679
+ fig, axes = plt.subplots(3, 1, figsize=(14, 12))
680
+
681
+ for i, system in enumerate(systems_to_plot):
682
+ analyzer = self._get_analyzer(system)
683
+ x, z, _ = analyzer.rasterize_solution()
684
+ fq = system.fq
685
+
686
+ # Convert x to meters for plotting
687
+ x_m = x / 1000
688
+
689
+ # Plot axial force N
690
+ N = fq.N(z)
691
+ axes[0].plot(x_m, N, color=plot_colors[i], label=labels[i], linewidth=2)
692
+
693
+ # Plot bending moment M
694
+ M = fq.M(z)
695
+ axes[1].plot(x_m, M, color=plot_colors[i], label=labels[i], linewidth=2)
696
+
697
+ # Plot shear force V
698
+ V = fq.V(z)
699
+ axes[2].plot(x_m, V, color=plot_colors[i], label=labels[i], linewidth=2)
700
+
701
+ # Formatting
702
+ axes[0].set_ylabel("N (N)")
703
+ axes[0].set_title("Axial Force")
704
+ axes[0].legend()
705
+ axes[0].grid(True, alpha=0.3)
706
+
707
+ axes[1].set_ylabel("M (Nmm)")
708
+ axes[1].set_title("Bending Moment")
709
+ axes[1].legend()
710
+ axes[1].grid(True, alpha=0.3)
711
+
712
+ axes[2].set_xlabel("Distance (m)")
713
+ axes[2].set_ylabel("V (N)")
714
+ axes[2].set_title("Shear Force")
715
+ axes[2].legend()
716
+ axes[2].grid(True, alpha=0.3)
717
+
718
+ plt.tight_layout()
719
+
720
+ if filename:
721
+ self._save_figure(filename, fig)
722
+
723
+ return fig
724
+
725
+ def plot_energy_release_rates(
726
+ self,
727
+ system_model: SystemModel | None = None,
728
+ system_models: list[SystemModel] | None = None,
729
+ filename: str = "ERR",
730
+ labels: list[str] | None = None,
731
+ colors: list[str] | None = None,
732
+ ):
733
+ """
734
+ Plot energy release rates (G_I, G_II) for comparison.
735
+
736
+ Parameters
737
+ ----------
738
+ system_model : SystemModel, optional
739
+ Single system to plot (overrides default)
740
+ system_models : list[SystemModel], optional
741
+ Multiple systems to plot (overrides default)
742
+ filename : str, optional
743
+ Filename for saving plot
744
+ labels : list of str, optional
745
+ Labels for each system.
746
+ colors : list of str, optional
747
+ Colors for each system.
748
+ """
749
+ systems_to_plot = self._get_systems_to_plot(system_model, system_models)
750
+
751
+ if labels is None:
752
+ labels = [f"System {i + 1}" for i in range(len(systems_to_plot))]
753
+ if colors is None:
754
+ plot_colors = [self.colors[i, 0] for i in range(len(systems_to_plot))]
755
+ else:
756
+ plot_colors = colors
757
+
758
+ fig, axes = plt.subplots(2, 1, figsize=(14, 10))
759
+
760
+ for i, system in enumerate(systems_to_plot):
761
+ analyzer = self._get_analyzer(system)
762
+ x, z, _ = analyzer.rasterize_solution()
763
+ fq = system.fq
764
+
765
+ # Convert x to meters for plotting
766
+ x_m = x / 1000
767
+
768
+ # Plot Mode I energy release rate
769
+ G_I = fq.Gi(z, unit="kJ/m^2")
770
+ axes[0].plot(x_m, G_I, color=plot_colors[i], label=labels[i], linewidth=2)
771
+
772
+ # Plot Mode II energy release rate
773
+ G_II = fq.Gii(z, unit="kJ/m^2")
774
+ axes[1].plot(x_m, G_II, color=plot_colors[i], label=labels[i], linewidth=2)
775
+
776
+ # Formatting
777
+ axes[0].set_ylabel("G_I (kJ/m²)")
778
+ axes[0].set_title("Mode I Energy Release Rate")
779
+ axes[0].legend()
780
+ axes[0].grid(True, alpha=0.3)
781
+
782
+ axes[1].set_xlabel("Distance (m)")
783
+ axes[1].set_ylabel("G_II (kJ/m²)")
784
+ axes[1].set_title("Mode II Energy Release Rate")
785
+ axes[1].legend()
786
+ axes[1].grid(True, alpha=0.3)
787
+
788
+ plt.tight_layout()
789
+
790
+ if filename:
791
+ self._save_figure(filename, fig)
792
+
793
+ return fig
794
+
795
+ def plot_deformed(
796
+ self,
797
+ xsl: np.ndarray,
798
+ xwl: np.ndarray,
799
+ z: np.ndarray,
800
+ analyzer: Analyzer,
801
+ dz: int = 2,
802
+ scale: int = 100,
803
+ window: float = np.inf,
804
+ pad: int = 2,
805
+ levels: int = 300,
806
+ aspect: int = 2,
807
+ field: Literal["w", "u", "principal", "Sxx", "Txz", "Szz"] = "w",
808
+ normalize: bool = True,
809
+ filename: str = "deformed_slab",
810
+ ) -> Figure:
811
+ """
812
+ Plot deformed slab with field contours.
813
+
814
+ Parameters
815
+ ----------
816
+ xsl : np.ndarray
817
+ Slab x-coordinates.
818
+ xwl : np.ndarray
819
+ Weak layer x-coordinates.
820
+ z : np.ndarray
821
+ Solution vector.
822
+ analyzer : Analyzer
823
+ Analyzer instance.
824
+ dz : int, optional
825
+ Element size along z-axis (mm). Default is 2 mm.
826
+ scale : int, optional
827
+ Deformation scale factor. Default is 100.
828
+ window : float, optional
829
+ Plot window width. Default is inf.
830
+ pad : int, optional
831
+ Padding around plot. Default is 2.
832
+ levels : int, optional
833
+ Number of contour levels. Default is 300.
834
+ aspect : int, optional
835
+ Aspect ratio. Default is 2.
836
+ field : str, optional
837
+ Field to plot ('w', 'u', 'principal', 'Sxx', 'Txz', 'Szz'). Default is 'w'.
838
+ normalize : bool, optional
839
+ Toggle normalization. Default is True.
840
+ filename : str, optional
841
+ Filename for saving plot. Default is "deformed_slab".
842
+
843
+ Returns
844
+ -------
845
+ matplotlib.figure.Figure
846
+ The generated plot figure.
847
+ """
848
+ fig = plt.figure(figsize=(10, 8))
849
+ ax = fig.add_subplot(111)
850
+
851
+ zi = analyzer.get_zmesh(dz=dz)["z"]
852
+ H = analyzer.sm.slab.H
853
+ phi = analyzer.sm.scenario.phi
854
+ system_type = analyzer.sm.scenario.system_type
855
+ fq = analyzer.sm.fq
856
+
857
+ # Compute slab displacements on grid (cm)
858
+ Usl = np.vstack([fq.u(z, h0=h0, unit="cm") for h0 in zi])
859
+ Wsl = np.vstack([fq.w(z, unit="cm") for _ in zi])
860
+ Sigmawl = np.where(np.isfinite(xwl), fq.sig(z, unit="kPa"), np.nan)
861
+ Tauwl = np.where(np.isfinite(xwl), fq.tau(z, unit="kPa"), np.nan)
862
+
863
+ # Put coordinate origin at horizontal center
864
+ if system_type in ["skier", "skiers"]:
865
+ xsl = xsl - max(xsl) / 2
866
+ xwl = xwl - max(xwl) / 2
867
+
868
+ # Compute slab grid coordinates with vertical origin at top surface (cm)
869
+ Xsl, Zsl = np.meshgrid(1e-1 * (xsl), 1e-1 * (zi + H / 2))
870
+
871
+ # Get x-coordinate of maximum deflection w (cm) and derive plot limits
872
+ xfocus = xsl[np.max(np.argmax(Wsl, axis=1))] / 10
873
+ xmax = np.min([np.max([Xsl, Xsl + scale * Usl]) + pad, xfocus + window / 2])
874
+ xmin = np.max([np.min([Xsl, Xsl + scale * Usl]) - pad, xfocus - window / 2])
875
+
876
+ # Scale shown weak-layer thickness with to max deflection and add padding
877
+ if analyzer.sm.config.touchdown:
878
+ zmax = (
879
+ np.max(Zsl)
880
+ + (analyzer.sm.weak_layer.h * 1e-1 * scale)
881
+ - (analyzer.sm.scenario.crack_h * 1e-1 * scale)
882
+ )
883
+ zmax = min(zmax, np.max(Zsl + scale * Wsl))
884
+ else:
885
+ zmax = np.max(Zsl + scale * Wsl) + pad
886
+ zmin = np.min(Zsl) - pad
887
+
888
+ # Compute weak-layer grid coordinates (cm)
889
+ Xwl, Zwl = np.meshgrid(1e-1 * xwl, [1e-1 * (zi[-1] + H / 2), zmax])
890
+
891
+ # Assemble weak-layer displacement field (top and bottom)
892
+ Uwl = np.vstack([Usl[-1, :], np.zeros(xwl.shape[0])])
893
+ Wwl = np.vstack([Wsl[-1, :], np.zeros(xwl.shape[0])])
894
+
895
+ # Compute stress or displacement fields
896
+ match field:
897
+ # Horizontal displacements (um)
898
+ case "u":
899
+ slab = 1e4 * Usl
900
+ weak = 1e4 * Usl[-1, :]
901
+ label = r"$u$ ($\mu$m)"
902
+ # Vertical deflection (um)
903
+ case "w":
904
+ slab = 1e4 * Wsl
905
+ weak = 1e4 * Wsl[-1, :]
906
+ label = r"$w$ ($\mu$m)"
907
+ # Axial normal stresses (kPa)
908
+ case "Sxx":
909
+ slab = analyzer.Sxx(z, phi, dz=dz, unit="kPa")
910
+ weak = np.zeros(xwl.shape[0])
911
+ label = r"$\sigma_{xx}$ (kPa)"
912
+ # Shear stresses (kPa)
913
+ case "Txz":
914
+ slab = analyzer.Txz(z, phi, dz=dz, unit="kPa")
915
+ weak = Tauwl
916
+ label = r"$\tau_{xz}$ (kPa)"
917
+ # Transverse normal stresses (kPa)
918
+ case "Szz":
919
+ slab = analyzer.Szz(z, phi, dz=dz, unit="kPa")
920
+ weak = Sigmawl
921
+ label = r"$\sigma_{zz}$ (kPa)"
922
+ # Principal stresses
923
+ case "principal":
924
+ slab = analyzer.principal_stress_slab(
925
+ z, phi, dz=dz, val="max", unit="kPa", normalize=normalize
926
+ )
927
+ weak = analyzer.principal_stress_weaklayer(
928
+ z, val="min", unit="kPa", normalize=normalize
929
+ )
930
+ if normalize:
931
+ label = (
932
+ r"$\sigma_\mathrm{I}/\sigma_+$ (slab), "
933
+ r"$\sigma_\mathrm{I\!I\!I}/\sigma_-$ (weak layer)"
934
+ )
935
+ else:
936
+ label = (
937
+ r"$\sigma_\mathrm{I}$ (kPa, slab), "
938
+ r"$\sigma_\mathrm{I\!I\!I}$ (kPa, weak layer)"
939
+ )
940
+ case _:
941
+ raise ValueError(
942
+ f"Invalid input '{field}' for field. Valid options are "
943
+ "'u', 'w', 'Sxx', 'Txz', 'Szz', or 'principal'"
944
+ )
945
+
946
+ # Complement label
947
+ label += r" $\longrightarrow$"
948
+
949
+ # Assemble weak-layer output on grid
950
+ weak = np.vstack([weak, weak])
951
+
952
+ # Normalize colormap
953
+ absmax = np.nanmax(np.abs([slab.min(), slab.max(), weak.min(), weak.max()]))
954
+ clim = np.round(absmax, _significant_digits(absmax))
955
+ levels = np.linspace(-clim, clim, num=levels + 1, endpoint=True)
956
+ # nanmax = np.nanmax([slab.max(), weak.max()])
957
+ # nanmin = np.nanmin([slab.min(), weak.min()])
958
+ # norm = MidpointNormalize(vmin=nanmin, vmax=nanmax)
959
+
960
+ # Plot baseline
961
+ ax.axhline(zmax, color="k", linewidth=1)
962
+
963
+ # Plot outlines of the undeformed and deformed slab
964
+ ax.plot(_outline(Xsl), _outline(Zsl), "k--", alpha=0.3, linewidth=1)
965
+ ax.plot(
966
+ _outline(Xsl + scale * Usl), _outline(Zsl + scale * Wsl), "k", linewidth=1
967
+ )
968
+
969
+ # Plot deformed weak-layer _outline
970
+ if system_type in ["-pst", "pst-", "-vpst", "vpst-"]:
971
+ nanmask = np.isfinite(xwl)
972
+ ax.plot(
973
+ _outline(Xwl[:, nanmask] + scale * Uwl[:, nanmask]),
974
+ _outline(Zwl[:, nanmask] + scale * Wwl[:, nanmask]),
975
+ "k",
976
+ linewidth=1,
977
+ )
978
+
979
+ cmap = plt.get_cmap("RdBu_r")
980
+ cmap.set_over(_adjust_lightness(cmap(1.0), 0.9))
981
+ cmap.set_under(_adjust_lightness(cmap(0.0), 0.9))
982
+
983
+ # Plot fields
984
+ ax.contourf(
985
+ Xsl + scale * Usl,
986
+ Zsl + scale * Wsl,
987
+ slab,
988
+ levels=levels,
989
+ cmap=cmap,
990
+ extend="both",
991
+ )
992
+ ax.contourf(
993
+ Xwl + scale * Uwl,
994
+ Zwl + scale * Wwl,
995
+ weak,
996
+ levels=levels,
997
+ cmap=cmap,
998
+ extend="both",
999
+ )
1000
+
1001
+ # Plot setup
1002
+ ax.axis("scaled")
1003
+ ax.set_xlim([xmin, xmax])
1004
+ ax.set_ylim([zmin, zmax])
1005
+ ax.set_aspect(aspect)
1006
+ ax.invert_yaxis()
1007
+ ax.use_sticky_edges = False
1008
+
1009
+ # Plot labels
1010
+ ax.set_xlabel(r"lateral position $x$ (cm) $\longrightarrow$")
1011
+ ax.set_ylabel("depth below surface\n" + r"$\longleftarrow $ $d$ (cm)")
1012
+ ax.set_title(rf"${scale}\!\times\!$ scaled deformations (cm)", size=10)
1013
+
1014
+ # Show colorbar
1015
+ ticks = np.linspace(levels[0], levels[-1], num=11, endpoint=True)
1016
+ fig.colorbar(
1017
+ ax.contourf(
1018
+ Xsl + scale * Usl,
1019
+ Zsl + scale * Wsl,
1020
+ slab,
1021
+ levels=levels,
1022
+ cmap=cmap,
1023
+ extend="both",
1024
+ ),
1025
+ orientation="horizontal",
1026
+ ticks=ticks,
1027
+ label=label,
1028
+ aspect=35,
1029
+ )
1030
+
1031
+ # Save figure
1032
+ self._save_figure(filename, fig)
1033
+
1034
+ return fig
1035
+
1036
+ def plot_stress_envelope(
1037
+ self,
1038
+ system_model: SystemModel,
1039
+ criteria_evaluator: CriteriaEvaluator,
1040
+ all_envelopes: bool = False,
1041
+ filename: str | None = None,
1042
+ ):
1043
+ """
1044
+ Plot stress envelope in τ-σ space.
1045
+
1046
+ Parameters
1047
+ ----------
1048
+ system_model : SystemModel
1049
+ System to plot
1050
+ criteria_evaluator : CriteriaEvaluator
1051
+ Criteria evaluator to use for the stress envelope
1052
+ all_envelopes : bool, optional
1053
+ Whether to plot all four quadrants of the envelope
1054
+ filename : str, optional
1055
+ Filename for saving plot
1056
+ """
1057
+ analyzer = self._get_analyzer(system_model)
1058
+ _, z, _ = analyzer.rasterize_solution(num=10000)
1059
+ fq = system_model.fq
1060
+
1061
+ # Calculate stresses
1062
+ sigma = np.abs(fq.sig(z, unit="kPa"))
1063
+ tau = fq.tau(z, unit="kPa")
1064
+
1065
+ fig, ax = plt.subplots(figsize=(4, 8 / 3))
1066
+
1067
+ # Plot stress path
1068
+ ax.plot(sigma, tau, "b-", linewidth=2, label="Stress Path")
1069
+ ax.scatter(
1070
+ sigma[0], tau[0], color="green", s=10, marker="o", label="Start", zorder=5
1071
+ )
1072
+ ax.scatter(
1073
+ sigma[-1], tau[-1], color="red", s=10, marker="s", label="End", zorder=5
1074
+ )
1075
+
1076
+ # --- Programmatic Envelope Calculation ---
1077
+ weak_layer = system_model.weak_layer
1078
+
1079
+ # Define a function to find the root for a given tau
1080
+ def find_sigma_for_tau(tau_val, sigma_c, method: str | None = None):
1081
+ # Target function to find the root of: envelope(sigma, tau) - 1 = 0
1082
+ def envelope_root_func(sigma_val):
1083
+ return (
1084
+ criteria_evaluator.stress_envelope(
1085
+ sigma_val, tau_val, weak_layer, method=method
1086
+ )
1087
+ - 1
1088
+ )
1089
+
1090
+ try:
1091
+ search_upper_bound = sigma_c * 1.1
1092
+ sigma_root = brentq(
1093
+ envelope_root_func,
1094
+ a=0,
1095
+ b=search_upper_bound,
1096
+ xtol=1e-6,
1097
+ rtol=1e-6,
1098
+ )
1099
+ return sigma_root
1100
+ except ValueError:
1101
+ return np.nan
1102
+
1103
+ # Calculate the corresponding sigma for each tau
1104
+ if all_envelopes:
1105
+ methods = [
1106
+ "mede_s-RG1",
1107
+ "mede_s-RG2",
1108
+ "mede_s-FCDH",
1109
+ "schottner",
1110
+ "adam_unpublished",
1111
+ ]
1112
+ else:
1113
+ methods = [criteria_evaluator.criteria_config.stress_envelope_method]
1114
+
1115
+ colors = self.colors
1116
+ colors = np.array(colors)
1117
+ colors = np.tile(colors, (len(methods), 1))
1118
+
1119
+ max_sigma = 0
1120
+ max_tau = 0
1121
+ for i, method in enumerate(methods):
1122
+ # Calculate tau_c for the given method to define tau_range
1123
+ config = criteria_evaluator.criteria_config
1124
+ density = weak_layer.rho
1125
+ tau_c = 0.0 # fallback
1126
+ sigma_c = 0.0
1127
+ if method == "adam_unpublished":
1128
+ scaling_factor = config.scaling_factor
1129
+ order_of_magnitude = config.order_of_magnitude
1130
+ if scaling_factor > 1:
1131
+ order_of_magnitude = 0.7
1132
+ scaling_factor = max(scaling_factor, 0.55)
1133
+
1134
+ tau_c = 5.09 * (scaling_factor**order_of_magnitude)
1135
+ sigma_c = 6.16 * (scaling_factor**order_of_magnitude)
1136
+ elif method == "schottner":
1137
+ rho_ice = 916.7
1138
+ sigma_y = 2000
1139
+ sigma_c_adam = 6.16
1140
+ tau_c_adam = 5.09
1141
+ order_of_magnitude = config.order_of_magnitude
1142
+ sigma_c = sigma_y * 13 * (density / rho_ice) ** order_of_magnitude
1143
+ tau_c = tau_c_adam * (sigma_c / sigma_c_adam)
1144
+ sigma_c = sigma_y * 13 * (density / rho_ice) ** order_of_magnitude
1145
+ elif method == "mede_s-RG1":
1146
+ tau_c = 3.53 # This is tau_T from Mede's paper
1147
+ sigma_c = 7.00
1148
+ elif method == "mede_s-RG2":
1149
+ tau_c = 1.22 # This is tau_T from Mede's paper
1150
+ sigma_c = 2.33
1151
+ elif method == "mede_s-FCDH":
1152
+ tau_c = 0.61 # This is tau_T from Mede's paper
1153
+ sigma_c = 1.49
1154
+
1155
+ tau_range = np.linspace(0, tau_c, 100)
1156
+ sigma_envelope = np.array(
1157
+ [find_sigma_for_tau(t, sigma_c, method) for t in tau_range]
1158
+ )
1159
+
1160
+ # Remove nan values where no root was found
1161
+ valid_points = ~np.isnan(sigma_envelope)
1162
+ valid_tau_range = tau_range[valid_points]
1163
+ sigma_envelope = sigma_envelope[valid_points]
1164
+
1165
+ max_sigma = max(max_sigma, np.max(sigma_envelope))
1166
+ max_tau = max(max_tau, np.max(np.abs(valid_tau_range)))
1167
+ ax.plot(
1168
+ sigma_envelope,
1169
+ valid_tau_range,
1170
+ "--",
1171
+ linewidth=2,
1172
+ label=method,
1173
+ color=colors[i, 0],
1174
+ )
1175
+ ax.plot(
1176
+ -sigma_envelope, valid_tau_range, "--", linewidth=2, color=colors[i, 0]
1177
+ )
1178
+ ax.plot(
1179
+ -sigma_envelope,
1180
+ -valid_tau_range,
1181
+ "--",
1182
+ linewidth=2,
1183
+ color=colors[i, 0],
1184
+ )
1185
+ ax.plot(
1186
+ sigma_envelope, -valid_tau_range, "--", linewidth=2, color=colors[i, 0]
1187
+ )
1188
+ ax.scatter(0, tau_c, color="black", s=10, marker="o")
1189
+ ax.text(0, tau_c, r"$\tau_c$", color="black", ha="center", va="bottom")
1190
+ ax.scatter(sigma_c, 0, color="black", s=10, marker="o")
1191
+ ax.text(sigma_c, 0, r"$\sigma_c$", color="black", ha="left", va="center")
1192
+
1193
+ # Formatting
1194
+ ax.set_xlabel("Compressive Strength σ (kPa)")
1195
+ ax.set_ylabel("Shear Strength τ (kPa)")
1196
+ ax.set_title("Weak Layer Stress Envelope")
1197
+ ax.legend()
1198
+ ax.grid(True, alpha=0.3)
1199
+ ax.axhline(y=0, color="k", linewidth=0.5)
1200
+ ax.axvline(x=0, color="k", linewidth=0.5)
1201
+
1202
+ max_tau = max(max_tau, float(np.max(np.abs(tau))))
1203
+ max_sigma = max(max_sigma, float(np.max(np.abs(sigma))))
1204
+ ax.set_xlim(0, max_sigma * 1.1)
1205
+ ax.set_ylim(-max_tau * 1.1, max_tau * 1.1)
1206
+
1207
+ plt.tight_layout()
1208
+
1209
+ if filename:
1210
+ self._save_figure(filename, fig)
1211
+
1212
+ return fig
1213
+
1214
+ def plot_err_envelope(
1215
+ self,
1216
+ system_model: SystemModel,
1217
+ criteria_evaluator: CriteriaEvaluator,
1218
+ filename: str = "err_envelope",
1219
+ ) -> Figure:
1220
+ """Plot the ERR envelope."""
1221
+ analyzer = self._get_analyzer(system_model)
1222
+
1223
+ incr_energy = analyzer.incremental_ERR(unit="J/m^2")
1224
+ G_I = incr_energy[1]
1225
+ G_II = incr_energy[2]
1226
+
1227
+ fig, ax = plt.subplots(figsize=(4, 8 / 3))
1228
+
1229
+ # Plot stress path
1230
+ ax.scatter(
1231
+ np.abs(G_I),
1232
+ np.abs(G_II),
1233
+ color="blue",
1234
+ s=50,
1235
+ marker="o",
1236
+ label="Incremental ERR",
1237
+ zorder=5,
1238
+ )
1239
+
1240
+ G_Ic = system_model.weak_layer.G_Ic
1241
+ G_IIc = system_model.weak_layer.G_IIc
1242
+ ax.scatter(0, G_IIc, color="black", s=100, marker="o", zorder=5)
1243
+ ax.text(
1244
+ 0.01,
1245
+ G_IIc + 0.02,
1246
+ r"$G_{IIc}$",
1247
+ color="black",
1248
+ ha="left",
1249
+ va="center",
1250
+ )
1251
+ ax.scatter(G_Ic, 0, color="black", s=100, marker="o", zorder=5)
1252
+ ax.text(
1253
+ G_Ic + 0.01,
1254
+ 0.01,
1255
+ r"$G_{Ic}$",
1256
+ color="black",
1257
+ )
1258
+
1259
+ # --- Programmatic Envelope Calculation ---
1260
+ weak_layer = system_model.weak_layer
1261
+
1262
+ # Define a function to find the root for a given G_II
1263
+ def find_GI_for_GII(GII_val):
1264
+ # Target function to find the root of: envelope(sigma, tau) - 1 = 0
1265
+ def envelope_root_func(GI_val):
1266
+ return (
1267
+ criteria_evaluator.fracture_toughness_envelope(
1268
+ GI_val,
1269
+ GII_val,
1270
+ weak_layer,
1271
+ )
1272
+ - 1
1273
+ )
1274
+
1275
+ try:
1276
+ GI_root = brentq(envelope_root_func, a=0, b=50, xtol=1e-6, rtol=1e-6)
1277
+ return GI_root
1278
+ except ValueError:
1279
+ return np.nan
1280
+
1281
+ # Generate a range of G values in the positive quadrant
1282
+ GII_max = system_model.weak_layer.G_IIc * 1.1
1283
+ GII_range = np.linspace(0, GII_max, 100)
1284
+
1285
+ GI_envelope = np.array([find_GI_for_GII(t) for t in GII_range])
1286
+
1287
+ # Remove nan values where no root was found
1288
+ valid_points = ~np.isnan(GI_envelope)
1289
+ valid_GII_range = GII_range[valid_points]
1290
+ GI_envelope = GI_envelope[valid_points]
1291
+
1292
+ ax.plot(
1293
+ GI_envelope,
1294
+ valid_GII_range,
1295
+ "--",
1296
+ linewidth=2,
1297
+ label="Fracture Toughness Envelope",
1298
+ color="red",
1299
+ )
1300
+
1301
+ # Formatting
1302
+ ax.set_xlabel("GI (J/m²)")
1303
+ ax.set_ylabel("GII (J/m²)")
1304
+ ax.set_title("Fracture Toughness Envelope")
1305
+ ax.legend()
1306
+ ax.grid(True, alpha=0.3)
1307
+ ax.axhline(y=0, color="k", linewidth=0.5)
1308
+ ax.axvline(x=0, color="k", linewidth=0.5)
1309
+ ax.set_xlim(0, max(np.abs(GI_envelope)) * 1.1)
1310
+ ax.set_ylim(0, max(np.abs(valid_GII_range)) * 1.1)
1311
+
1312
+ plt.tight_layout()
1313
+
1314
+ self._save_figure(filename, fig)
1315
+
1316
+ return fig
1317
+
1318
+ def plot_analysis(
1319
+ self,
1320
+ system: SystemModel,
1321
+ criteria_evaluator: CriteriaEvaluator,
1322
+ min_force_result: FindMinimumForceResult,
1323
+ min_crack_length: float,
1324
+ coupled_criterion_result: CoupledCriterionResult,
1325
+ dz: int = 2,
1326
+ deformation_scale: float = 100.0,
1327
+ window: int = np.inf,
1328
+ levels: int = 300,
1329
+ filename: str = "analysis",
1330
+ ) -> Figure:
1331
+ """
1332
+ Plot deformed slab with field contours.
1333
+
1334
+ Parameters
1335
+ ----------
1336
+ field : str, default 'w'
1337
+ Field to plot ('w', 'u', 'principal', 'sigma', 'tau')
1338
+ system_model : SystemModel, optional
1339
+ System to plot (uses first system if not specified)
1340
+ filename : str, optional
1341
+ Filename for saving plot
1342
+ """
1343
+ fig = plt.figure(figsize=(12, 10))
1344
+ ax = fig.add_subplot(111)
1345
+
1346
+ logger.debug("System Segments: %s", system.scenario.segments)
1347
+ analyzer = Analyzer(system)
1348
+ xsl, z, xwl = analyzer.rasterize_solution(mode="cracked", num=200)
1349
+
1350
+ zi = analyzer.get_zmesh(dz=dz)["z"]
1351
+ H = analyzer.sm.slab.H
1352
+ h = system.weak_layer.h
1353
+ system_type = analyzer.sm.scenario.system_type
1354
+ fq = analyzer.sm.fq
1355
+
1356
+ # Generate a window size which fits the plots
1357
+ window = min(window, np.max(xwl) - np.min(xwl), 10000)
1358
+
1359
+ # Calculate scaling factors for proper aspect ratio and relative heights
1360
+ # 7:1 aspect ratio: vertical extent = window / 7
1361
+ total_vertical_extent = window / 7.0
1362
+
1363
+ # Slab should appear 2x taller than weak layer
1364
+ # So slab gets 2/3 of vertical space, weak layer gets 1/3
1365
+ slab_display_height = (2 / 3) * total_vertical_extent
1366
+ weak_layer_display_height = (1 / 3) * total_vertical_extent
1367
+
1368
+ # Calculate separate scaling factors for coordinates
1369
+ slab_z_scale = slab_display_height / H
1370
+ weak_layer_z_scale = weak_layer_display_height / h
1371
+
1372
+ # Deformation scaling (separate from coordinate scaling)
1373
+ scale = deformation_scale
1374
+
1375
+ # Compute slab displacements on grid (cm)
1376
+ Usl = np.vstack([fq.u(z, h0=h0, unit="cm") for h0 in zi])
1377
+ Wsl = np.vstack([fq.w(z, unit="cm") for _ in zi])
1378
+ Sigmawl = np.where(np.isfinite(xwl), fq.sig(z, unit="kPa"), np.nan)
1379
+ Tauwl = np.where(np.isfinite(xwl), fq.tau(z, unit="kPa"), np.nan)
1380
+
1381
+ # Put coordinate origin at horizontal center
1382
+ if system_type in ["skier", "skiers"]:
1383
+ xsl = xsl - max(xsl) / 2
1384
+ xwl = xwl - max(xwl) / 2
1385
+
1386
+ # Compute slab grid coordinates with vertical origin at top surface (cm)
1387
+ Xsl, Zsl = np.meshgrid(1e-1 * (xsl), 1e-1 * slab_z_scale * (zi - H / 2))
1388
+
1389
+ # Get x-coordinate of maximum deflection w (cm) and derive plot limits
1390
+ xmax = np.min([np.max([Xsl, Xsl + scale * Usl]), 1e-1 * window / 2])
1391
+ xmin = np.max([np.min([Xsl, Xsl + scale * Usl]), -1e-1 * window / 2])
1392
+
1393
+ # Compute weak-layer grid coordinates (cm)
1394
+ # Position weak layer below the slab
1395
+ Xwl, Zwl = np.meshgrid(
1396
+ 1e-1 * xwl,
1397
+ [
1398
+ 0, # Top of weak layer (at bottom of slab)
1399
+ 1e-1 * weak_layer_z_scale * h, # Bottom of weak layer
1400
+ ],
1401
+ )
1402
+
1403
+ # Assemble weak-layer displacement field (top and bottom)
1404
+ Uwl = np.vstack([Usl[-1, :], np.zeros(xwl.shape[0])])
1405
+ Wwl = np.vstack([Wsl[-1, :], np.zeros(xwl.shape[0])])
1406
+
1407
+ stress_envelope = criteria_evaluator.stress_envelope(
1408
+ Sigmawl, Tauwl, system.weak_layer
1409
+ )
1410
+ stress_envelope[np.isnan(stress_envelope)] = np.nanmax(stress_envelope)
1411
+
1412
+ # Assemble weak-layer output on grid
1413
+ weak = np.vstack([stress_envelope, stress_envelope])
1414
+
1415
+ # Normalize colormap
1416
+ levels = np.linspace(0, 1, num=levels + 1, endpoint=True)
1417
+
1418
+ # Plot outlines of the undeformed and deformed slab
1419
+ ax.plot(
1420
+ _outline(Xsl),
1421
+ _outline(Zsl),
1422
+ linestyle="--",
1423
+ color="yellow",
1424
+ alpha=0.3,
1425
+ linewidth=1,
1426
+ )
1427
+ ax.plot(
1428
+ _outline(Xsl + scale * Usl),
1429
+ _outline(Zsl + scale * Wsl),
1430
+ color="blue",
1431
+ linewidth=1,
1432
+ )
1433
+
1434
+ # Plot deformed weak-layer _outline
1435
+ nanmask = np.isfinite(xwl)
1436
+ ax.plot(
1437
+ _outline(Xwl[:, nanmask] + scale * Uwl[:, nanmask]),
1438
+ _outline(Zwl[:, nanmask] + scale * Wwl[:, nanmask]),
1439
+ "k",
1440
+ linewidth=1,
1441
+ )
1442
+
1443
+ cmap = plt.get_cmap("RdBu_r")
1444
+ cmap.set_over(_adjust_lightness(cmap(1.0), 0.9))
1445
+ cmap.set_under(_adjust_lightness(cmap(0.0), 0.9))
1446
+
1447
+ ax.contourf(
1448
+ Xwl + scale * Uwl,
1449
+ Zwl + scale * Wwl,
1450
+ weak,
1451
+ levels=levels,
1452
+ cmap=cmap,
1453
+ extend="both",
1454
+ )
1455
+
1456
+ # Plot setup
1457
+ ax.axis("scaled")
1458
+ ax.set_xlim([xmin, xmax])
1459
+ ax.invert_yaxis()
1460
+ ax.use_sticky_edges = False
1461
+
1462
+ # Set up custom y-axis ticks to show real scaled heights
1463
+ # Calculate the actual extent of the plot
1464
+ slab_top = 1e-1 * slab_z_scale * (zi[0] - H / 2) # Top of slab
1465
+ slab_bottom = 1e-1 * slab_z_scale * (zi[-1] - H / 2) # Bottom of slab
1466
+ weak_layer_bottom = 1e-1 * weak_layer_z_scale * h # Bottom of weak layer
1467
+
1468
+ # Create tick positions and labels
1469
+ y_ticks = []
1470
+ y_labels = []
1471
+
1472
+ # Slab ticks (show actual slab heights in mm)
1473
+ num_slab_ticks = 5
1474
+ slab_tick_positions = np.linspace(slab_bottom, slab_top, num_slab_ticks)
1475
+ slab_height_ticks = np.linspace(
1476
+ 0, -H, num_slab_ticks
1477
+ ) # Actual slab heights in mm
1478
+
1479
+ for pos, height in zip(slab_tick_positions, slab_height_ticks):
1480
+ y_ticks.append(pos)
1481
+ y_labels.append(f"{height:.0f}")
1482
+
1483
+ # Weak layer ticks (show actual weak layer heights in mm)
1484
+ num_wl_ticks = 3
1485
+ wl_tick_positions = np.linspace(0, weak_layer_bottom, num_wl_ticks)
1486
+ wl_height_ticks = np.linspace(
1487
+ 0, h, num_wl_ticks
1488
+ ) # Actual weak layer heights in mm
1489
+
1490
+ for pos, height in zip(wl_tick_positions, wl_height_ticks):
1491
+ y_ticks.append(pos)
1492
+ y_labels.append(f"{height:.0f}")
1493
+
1494
+ # Set the custom ticks
1495
+ ax.set_yticks(y_ticks)
1496
+ ax.set_yticklabels(y_labels)
1497
+
1498
+ # Add grid lines for better readability
1499
+ ax.grid(True, alpha=0.3)
1500
+
1501
+ # Add horizontal line to separate slab and weak layer
1502
+ ax.axhline(y=slab_bottom, color="black", linewidth=1, alpha=0.5, linestyle="--")
1503
+
1504
+ # === ADD ANALYSIS ANNOTATIONS ===
1505
+
1506
+ # 1. Vertical lines for min_crack_length (centered at x=0)
1507
+ min_crack_length_cm = min_crack_length / 10 # Convert mm to cm
1508
+ ax.plot(
1509
+ [-min_crack_length_cm / 2, -min_crack_length_cm / 2],
1510
+ [0, weak_layer_bottom],
1511
+ color="orange",
1512
+ linewidth=1,
1513
+ alpha=0.7,
1514
+ label=f"Crack Propagation: ±{min_crack_length / 2:.0f}mm",
1515
+ )
1516
+ ax.plot(
1517
+ [min_crack_length_cm / 2, min_crack_length_cm / 2],
1518
+ [0, weak_layer_bottom],
1519
+ color="orange",
1520
+ linewidth=1,
1521
+ alpha=0.7,
1522
+ )
1523
+
1524
+ base_square_size = (1e-1 * window) / 25 # Base size for scaling
1525
+ segment_position = 0 # Track cumulative position
1526
+ square_spacing = 2.0 # Space above slab for squares
1527
+
1528
+ # Collect weight information for legend
1529
+ weight_legend_items = []
1530
+
1531
+ for segment in system.scenario.segments:
1532
+ segment_position += segment.length
1533
+ if segment.m > 0: # If there's a weight at this segment
1534
+ # Convert position to cm and center at x=0
1535
+ square_x = (segment_position / 10) - (1e-1 * max(xsl))
1536
+ square_y = slab_top - square_spacing # Position above slab
1537
+
1538
+ # Calculate square side length based on cube root of weight (volume scaling)
1539
+ actual_side_length = base_square_size * (segment.m / 100) ** (1 / 3)
1540
+
1541
+ # Draw actual skier weight square (filled, blue)
1542
+ actual_square = Rectangle(
1543
+ (square_x - actual_side_length / 2, square_y - actual_side_length),
1544
+ actual_side_length,
1545
+ actual_side_length,
1546
+ facecolor="blue",
1547
+ alpha=0.7,
1548
+ edgecolor="blue",
1549
+ linewidth=1,
1550
+ )
1551
+ ax.add_patch(actual_square)
1552
+
1553
+ # Add to weight legend
1554
+ weight_legend_items.append(
1555
+ (f"Actual: {segment.m:.0f} kg", "blue", True)
1556
+ )
1557
+
1558
+ # Draw critical weight square (outline only, green)
1559
+ critical_weight = min_force_result.critical_skier_weight
1560
+ critical_side_length = base_square_size * (critical_weight / 100) ** (
1561
+ 1 / 3
1562
+ )
1563
+ critical_square = Rectangle(
1564
+ (
1565
+ square_x - critical_side_length / 2,
1566
+ square_y - critical_side_length,
1567
+ ),
1568
+ critical_side_length,
1569
+ critical_side_length,
1570
+ facecolor="none",
1571
+ alpha=0.7,
1572
+ edgecolor="green",
1573
+ linewidth=1,
1574
+ )
1575
+ ax.add_patch(critical_square)
1576
+
1577
+ # Add to weight legend (only once)
1578
+ if not any("Critical" in item[0] for item in weight_legend_items):
1579
+ weight_legend_items.append(
1580
+ (f"Critical: {critical_weight:.0f} kg", "green", False)
1581
+ )
1582
+
1583
+ # 3. Coupled criterion result square (centered at x=0)
1584
+ coupled_weight = coupled_criterion_result.critical_skier_weight
1585
+ coupled_side_length = base_square_size * (coupled_weight / 100) ** (1 / 3)
1586
+ coupled_square = Rectangle(
1587
+ (-coupled_side_length / 2, slab_top - square_spacing - coupled_side_length),
1588
+ coupled_side_length,
1589
+ coupled_side_length,
1590
+ facecolor="none",
1591
+ alpha=0.7,
1592
+ edgecolor="red",
1593
+ linewidth=1,
1594
+ )
1595
+ ax.add_patch(coupled_square)
1596
+
1597
+ # Add to weight legend
1598
+ weight_legend_items.append((f"Coupled: {coupled_weight:.0f} kg", "red", False))
1599
+
1600
+ # 4. Vertical line for coupled criterion result (spans weak layer only)
1601
+ cc_crack_length = coupled_criterion_result.crack_length / 10
1602
+ ax.plot(
1603
+ [cc_crack_length / 2, cc_crack_length / 2],
1604
+ [0, weak_layer_bottom],
1605
+ color="red",
1606
+ linewidth=1,
1607
+ alpha=0.7,
1608
+ )
1609
+ ax.plot(
1610
+ [-cc_crack_length / 2, -cc_crack_length / 2],
1611
+ [0, weak_layer_bottom],
1612
+ color="red",
1613
+ linewidth=1,
1614
+ alpha=0.7,
1615
+ label=f"Crack Nucleation: ±{coupled_criterion_result.crack_length / 2:.0f}mm",
1616
+ )
1617
+
1618
+ # Calculate and set proper y-axis limits to include squares
1619
+ # Find the maximum extent of squares and text above the slab
1620
+ max_weight = max(
1621
+ [segment.m for segment in system.scenario.segments if segment.m > 0]
1622
+ + [
1623
+ min_force_result.critical_skier_weight,
1624
+ coupled_criterion_result.critical_skier_weight,
1625
+ ]
1626
+ )
1627
+ max_square_size = base_square_size * (max_weight / 100) ** (1 / 3)
1628
+
1629
+ # Calculate plot limits for inverted y-axis
1630
+ # Top of plot (smallest y-value): above the squares and text
1631
+ plot_top = slab_top - 3 * max_square_size - 5 # Include text space
1632
+
1633
+ # Bottom of plot (largest y-value): below weak layer
1634
+ plot_bottom = weak_layer_bottom + 1.0
1635
+
1636
+ # Set y-limits [bottom, top] for inverted axis
1637
+ ax.set_ylim([plot_bottom, plot_top])
1638
+
1639
+ weight_legend_handles = []
1640
+ weight_legend_labels = []
1641
+
1642
+ for label, color, filled in weight_legend_items:
1643
+ if filled:
1644
+ # Filled square for actual weights
1645
+ patch = Patch(facecolor=color, edgecolor=color, alpha=0.7)
1646
+ else:
1647
+ # Outline only square for critical/coupled weights
1648
+ patch = Patch(facecolor="none", edgecolor=color, alpha=0.7, linewidth=1)
1649
+
1650
+ weight_legend_handles.append(patch)
1651
+ weight_legend_labels.append(label)
1652
+
1653
+ # Plot labels
1654
+ ax.set_xlabel(r"lateral position $x$ (cm) $\longrightarrow$")
1655
+ ax.set_ylabel("Layer Height (mm)\n" + r"$\longleftarrow $ Slab | Weak Layer")
1656
+
1657
+ # Add primary legend for annotations (crack lengths)
1658
+ legend1 = ax.legend(loc="upper right", fontsize=8)
1659
+
1660
+ # Add the first legend back (matplotlib only shows the last legend by default)
1661
+ ax.add_artist(legend1)
1662
+
1663
+ # Show colorbar
1664
+ ticks = np.linspace(levels[0], levels[-1], num=11, endpoint=True)
1665
+ fig.colorbar(
1666
+ ax.contourf(
1667
+ Xwl + scale * Uwl,
1668
+ Zwl + scale * Wwl,
1669
+ weak,
1670
+ levels=levels,
1671
+ cmap=cmap,
1672
+ extend="both",
1673
+ ),
1674
+ orientation="horizontal",
1675
+ ticks=ticks,
1676
+ label="Stress Criterion: Failure > 1",
1677
+ aspect=35,
1678
+ )
1679
+
1680
+ # Save figure
1681
+ self._save_figure(filename, fig)
1682
+
1683
+ return fig
1684
+
1685
+ # === PLOT WRAPPERS ===========================================================
1686
+
1687
+ def plot_displacements(
1688
+ self,
1689
+ analyzer: Analyzer,
1690
+ x: np.ndarray,
1691
+ z: np.ndarray,
1692
+ filename: str = "displacements",
1693
+ ) -> Figure:
1694
+ """Wrap for displacements plot."""
1695
+ data = [
1696
+ [x / 10, analyzer.sm.fq.u(z, unit="mm"), r"$u_0\ (\mathrm{mm})$"],
1697
+ [x / 10, -analyzer.sm.fq.w(z, unit="mm"), r"$-w\ (\mathrm{mm})$"],
1698
+ [x / 10, analyzer.sm.fq.psi(z, unit="deg"), r"$\psi\ (^\circ)$ "],
1699
+ ]
1700
+ self._plot_data(
1701
+ scenario=analyzer.sm.scenario,
1702
+ ax1label=r"Displacements",
1703
+ ax1data=data,
1704
+ filename=filename,
1705
+ )
1706
+
1707
+ def plot_stresses(
1708
+ self,
1709
+ analyzer: Analyzer,
1710
+ x: np.ndarray,
1711
+ z: np.ndarray,
1712
+ filename: str = "stresses",
1713
+ ) -> Figure:
1714
+ """Wrap stress plot."""
1715
+ data = [
1716
+ [x / 10, analyzer.sm.fq.tau(z, unit="kPa"), r"$\tau$"],
1717
+ [x / 10, analyzer.sm.fq.sig(z, unit="kPa"), r"$\sigma$"],
1718
+ ]
1719
+ self._plot_data(
1720
+ scenario=analyzer.sm.scenario,
1721
+ ax1label=r"Stress (kPa)",
1722
+ ax1data=data,
1723
+ filename=filename,
1724
+ )
1725
+
1726
+ def plot_stress_criteria(
1727
+ self, analyzer: Analyzer, x: np.ndarray, stress: np.ndarray
1728
+ ) -> Figure:
1729
+ """Wrap plot of stress and energy criteria."""
1730
+ data = [[x / 10, stress, r"$\sigma/\sigma_\mathrm{c}$"]]
1731
+ self._plot_data(
1732
+ scenario=analyzer.sm.scenario,
1733
+ ax1label=r"Criteria",
1734
+ ax1data=data,
1735
+ filename="crit",
1736
+ )
1737
+
1738
+ def plot_ERR_comp(
1739
+ self,
1740
+ analyzer: Analyzer,
1741
+ da: np.ndarray,
1742
+ Gdif: np.ndarray,
1743
+ Ginc: np.ndarray,
1744
+ mode: int = 0,
1745
+ ) -> Figure:
1746
+ """Wrap energy release rate plot."""
1747
+ data = [
1748
+ [da / 10, 1e3 * Gdif[mode, :], r"$\mathcal{G}$"],
1749
+ [da / 10, 1e3 * Ginc[mode, :], r"$\bar{\mathcal{G}}$"],
1750
+ ]
1751
+ self._plot_data(
1752
+ scenario=analyzer.sm.scenario,
1753
+ xlabel=r"Crack length $\Delta a$ (cm)",
1754
+ ax1label=r"Energy release rate (J/m$^2$)",
1755
+ ax1data=data,
1756
+ filename="err",
1757
+ vlines=False,
1758
+ )
1759
+
1760
+ def plot_ERR_modes(
1761
+ self, analyzer: Analyzer, da: np.ndarray, G: np.ndarray, kind: str = "inc"
1762
+ ) -> Figure:
1763
+ """Wrap energy release rate plot."""
1764
+ label = r"$\bar{\mathcal{G}}$" if kind == "inc" else r"$\mathcal{G}$"
1765
+ data = [
1766
+ [da / 10, 1e3 * G[2, :], label + r"$_\mathrm{I\!I}$"],
1767
+ [da / 10, 1e3 * G[1, :], label + r"$_\mathrm{I}$"],
1768
+ [da / 10, 1e3 * G[0, :], label + r"$_\mathrm{I+I\!I}$"],
1769
+ ]
1770
+ self._plot_data(
1771
+ scenario=analyzer.sm.scenario,
1772
+ xlabel=r"Crack length $a$ (cm)",
1773
+ ax1label=r"Energy release rate (J/m$^2$)",
1774
+ ax1data=data,
1775
+ filename="modes",
1776
+ vlines=False,
1777
+ )
1778
+
1779
+ def plot_fea_disp(
1780
+ self, analyzer: Analyzer, x: np.ndarray, z: np.ndarray, fea: np.ndarray
1781
+ ) -> Figure:
1782
+ """Wrap displacements plot."""
1783
+ data = [
1784
+ [fea[:, 0] / 10, -np.flipud(fea[:, 1]), r"FEA $u_0$"],
1785
+ [fea[:, 0] / 10, np.flipud(fea[:, 2]), r"FEA $w_0$"],
1786
+ # [fea[:, 0]/10, -np.flipud(fea[:, 3]), r'FEA $u(z=-h/2)$'],
1787
+ # [fea[:, 0]/10, np.flipud(fea[:, 4]), r'FEA $w(z=-h/2)$'],
1788
+ [fea[:, 0] / 10, np.flipud(np.rad2deg(fea[:, 5])), r"FEA $\psi$"],
1789
+ [x / 10, analyzer.sm.fq.u(z, z0=0), r"$u_0$"],
1790
+ [x / 10, -analyzer.sm.fq.w(z), r"$-w$"],
1791
+ [x / 10, np.rad2deg(analyzer.sm.fq.psi(z)), r"$\psi$"],
1792
+ ]
1793
+ self._plot_data(
1794
+ scenario=analyzer.sm.scenario,
1795
+ ax1label=r"Displacements (mm)",
1796
+ ax1data=data,
1797
+ filename="fea_disp",
1798
+ labelpos=-50,
1799
+ )
1800
+
1801
+ def plot_fea_stress(
1802
+ self, analyzer: Analyzer, xb: np.ndarray, zb: np.ndarray, fea: np.ndarray
1803
+ ) -> Figure:
1804
+ """Wrap stress plot."""
1805
+ data = [
1806
+ [fea[:, 0] / 10, 1e3 * np.flipud(fea[:, 2]), r"FEA $\sigma_2$"],
1807
+ [fea[:, 0] / 10, 1e3 * np.flipud(fea[:, 3]), r"FEA $\tau_{12}$"],
1808
+ [xb / 10, analyzer.sm.fq.tau(zb, unit="kPa"), r"$\tau$"],
1809
+ [xb / 10, analyzer.sm.fq.sig(zb, unit="kPa"), r"$\sigma$"],
1810
+ ]
1811
+ self._plot_data(
1812
+ scenario=analyzer.sm.scenario,
1813
+ ax1label=r"Stress (kPa)",
1814
+ ax1data=data,
1815
+ filename="fea_stress",
1816
+ labelpos=-50,
1817
+ )
1818
+
1819
+ # === BASE PLOT FUNCTION ======================================================
1820
+
1821
+ def _plot_data(
1822
+ self,
1823
+ scenario: Scenario,
1824
+ filename: str,
1825
+ ax1data,
1826
+ ax1label,
1827
+ ax2data=None,
1828
+ ax2label=None,
1829
+ labelpos=None,
1830
+ vlines=True,
1831
+ xlabel=r"Horizontal position $x$ (cm)",
1832
+ ) -> Figure:
1833
+ """Plot data. Base function."""
1834
+ # Figure setup
1835
+ plt.rcdefaults()
1836
+ plt.rc("font", family="serif", size=10)
1837
+ plt.rc("mathtext", fontset="cm")
1838
+
1839
+ # Create figure
1840
+ fig = plt.figure(figsize=(4, 8 / 3))
1841
+ ax1 = fig.gca()
1842
+
1843
+ # Axis limits
1844
+ ax1.autoscale(axis="x", tight=True)
1845
+
1846
+ # Set axis labels
1847
+ ax1.set_xlabel(xlabel + r" $\longrightarrow$")
1848
+ ax1.set_ylabel(ax1label + r" $\longrightarrow$")
1849
+
1850
+ # Plot x-axis
1851
+ ax1.axhline(0, linewidth=0.5, color="gray")
1852
+
1853
+ ki = scenario.ki
1854
+ li = scenario.li
1855
+ mi = scenario.mi
1856
+
1857
+ # Plot vertical separators
1858
+ if vlines:
1859
+ ax1.axvline(0, linewidth=0.5, color="gray")
1860
+ for i, f in enumerate(ki):
1861
+ if not f:
1862
+ ax1.axvspan(
1863
+ sum(li[:i]) / 10,
1864
+ sum(li[: i + 1]) / 10,
1865
+ facecolor="gray",
1866
+ alpha=0.05,
1867
+ zorder=100,
1868
+ )
1869
+ for i, m in enumerate(mi, start=1):
1870
+ if m > 0:
1871
+ ax1.axvline(sum(li[:i]) / 10, linewidth=0.5, color="gray")
1872
+ else:
1873
+ ax1.autoscale(axis="y", tight=True)
1874
+
1875
+ # Calculate labelposition
1876
+ if not labelpos:
1877
+ x = ax1data[0][0]
1878
+ labelpos = int(0.95 * len(x[~np.isnan(x)]))
1879
+
1880
+ # Fill left y-axis
1881
+ i = 0
1882
+ for x, y, label in ax1data:
1883
+ i += 1
1884
+ if label == "" or "FEA" in label:
1885
+ # line, = ax1.plot(x, y, 'k:', linewidth=1)
1886
+ ax1.plot(x, y, linewidth=3, color="white")
1887
+ (line,) = ax1.plot(x, y, ":", linewidth=1) # , color='black'
1888
+ thislabelpos = -2
1889
+ x, y = x[~np.isnan(x)], y[~np.isnan(x)]
1890
+ xtx = (x[thislabelpos - 1] + x[thislabelpos]) / 2
1891
+ ytx = (y[thislabelpos - 1] + y[thislabelpos]) / 2
1892
+ ax1.text(xtx, ytx, label, color=line.get_color(), **LABELSTYLE)
1893
+ else:
1894
+ # Plot line
1895
+ ax1.plot(x, y, linewidth=3, color="white")
1896
+ (line,) = ax1.plot(x, y, linewidth=1)
1897
+ # Line label
1898
+ x, y = x[~np.isnan(x)], y[~np.isnan(x)]
1899
+ if len(x) > 0:
1900
+ xtx = (x[labelpos - 10 * i - 1] + x[labelpos - 10 * i]) / 2
1901
+ ytx = (y[labelpos - 10 * i - 1] + y[labelpos - 10 * i]) / 2
1902
+ ax1.text(xtx, ytx, label, color=line.get_color(), **LABELSTYLE)
1903
+
1904
+ # Fill right y-axis
1905
+ if ax2data:
1906
+ # Create right y-axis
1907
+ ax2 = ax1.twinx()
1908
+ # Set axis label
1909
+ ax2.set_ylabel(ax2label + r" $\longrightarrow$")
1910
+ # Fill
1911
+ for x, y, label in ax2data:
1912
+ # Plot line
1913
+ ax2.plot(x, y, linewidth=3, color="white")
1914
+ (line,) = ax2.plot(x, y, linewidth=1, color=COLORS[8, 0])
1915
+ # Line label
1916
+ x, y = x[~np.isnan(x)], y[~np.isnan(x)]
1917
+ xtx = (x[labelpos - 1] + x[labelpos]) / 2
1918
+ ytx = (y[labelpos - 1] + y[labelpos]) / 2
1919
+ ax2.text(xtx, ytx, label, color=line.get_color(), **LABELSTYLE)
1920
+
1921
+ # Save figure
1922
+ if filename:
1923
+ self._save_figure(filename, fig)
1924
+
1925
+ return fig