weac 3.0.0__py3-none-any.whl → 3.0.1__py3-none-any.whl

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