Source code for structdyn.mdf.mdf_helpers.visualization

import matplotlib.pyplot as plt
import numpy as np
from matplotlib.animation import FuncAnimation


[docs] class ShearBuildingVisualizer: """ Provides methods for visualizing shear buildings. This class is not intended to be instantiated directly. Instead, it should be accessed through the `plot` property of an `MDF` instance that represents a shear building (e.g., `mdf.plot.structure()`). """ def __init__(self, mdf, story_height=3.0, building_width=5.0): """ Initializes the ShearBuildingVisualizer. Parameters ---------- mdf : MDF The MDF object representing the shear building. story_height : float, optional The height of each story, by default 3.0. building_width : float, optional The width of the building, by default 5.0. """ if not (hasattr(mdf, "masses") and hasattr(mdf, "stiffnesses")): raise TypeError( "The `plot` methods are only available for MDF objects " "created with `from_shear_building`." ) self.mdf = mdf self.n_stories = len(mdf.masses) self.story_height = story_height self.building_width = building_width def _plot_displaced_shape( self, ax, displacements, title="", max_disp_override=None ): """Helper to plot the displaced shape of the building on a given axis.""" ax.clear() displacements = np.insert(displacements, 0, 0) # Add base displacement # Generate points for plotting smooth column curves num_points_curve = 20 t_curve = np.linspace(0, 1, num_points_curve) # Cubic Hermite spline basis for zero-slope start/end points hermite_poly = -2 * t_curve**3 + 3 * t_curve**2 # Plot columns with double curvature for i in range(self.n_stories): y1, y2 = i * self.story_height, (i + 1) * self.story_height x1, x2 = displacements[i], displacements[i + 1] y_curve = np.linspace(y1, y2, num_points_curve) x_curve = x1 + (x2 - x1) * hermite_poly # Plot left and right columns ax.plot(x_curve, y_curve, "b-") ax.plot(x_curve + self.building_width, y_curve, "b-") # Plot floors (as horizontal lines) for i in range(self.n_stories): y = (i + 1) * self.story_height x_start = displacements[i + 1] x_end = x_start + self.building_width ax.plot([x_start, x_end], [y, y], "k-", lw=2) # Plot ground line ax.axhline(0, color="k", lw=2) ax.plot( [-self.building_width, 2 * self.building_width], [0, 0], color="k", linestyle="--", lw=1, ) # Formatting if max_disp_override is not None: max_disp = max_disp_override else: max_disp = np.max(np.abs(displacements)) if max_disp == 0: # Avoid empty plot for zero displacement max_disp = 1.0 ax.set_xlim(-max_disp * 1.5 - 0.5, self.building_width + max_disp * 1.5 + 0.5) ax.set_ylim(-self.story_height * 0.5, (self.n_stories + 1) * self.story_height) ax.set_aspect("equal", "box") ax.set_title(title) ax.set_ylabel("Story") ax.set_yticks( np.arange(self.n_stories + 1) * self.story_height, [f"{i}" for i in range(self.n_stories + 1)], )
[docs] def structure(self): """Plots the undeformed structure of the shear building.""" fig, ax = plt.subplots() displacements = np.zeros(self.n_stories) self._plot_displaced_shape(ax, displacements, title="Shear Building") # ax.set_xlabel("Displacement") plt.show()
[docs] def mode_shape(self, mode_number=[1]): """ Plots one or more specified mode shapes of the shear building. Parameters ---------- mode_number : int or list of int, optional A single mode number or a list of mode numbers to plot (1-indexed). If not provided, defaults to plotting the first mode, i.e., `[1]`. """ if isinstance(mode_number, int): mode_number = [mode_number] if not isinstance(mode_number, list) or not all( isinstance(n, int) for n in mode_number ): raise TypeError("mode_number must be an integer or a list of integers.") if self.mdf.modal.phi is None: self.mdf.modal.modal_analysis() max_mode = self.mdf.modal.phi.shape[1] for mn in mode_number: if not 1 <= mn <= max_mode: raise ValueError( f"Invalid mode number: {mn}. Must be between 1 and {max_mode}." ) num_modes_to_plot = len(mode_number) fig, axes = plt.subplots( 1, num_modes_to_plot, figsize=(5 * num_modes_to_plot, 6), squeeze=False ) axes = axes.flatten() for i, mn in enumerate(mode_number): omega = self.mdf.modal.omega[mn - 1] mode_shape_vector = self.mdf.modal.phi[:, mn - 1] norm_factor = np.max(np.abs(mode_shape_vector)) normalized_shape = ( mode_shape_vector / norm_factor if norm_factor != 0 else mode_shape_vector ) ax = axes[i] self._plot_displaced_shape( ax, normalized_shape, title=f"Mode {mn}", ) text_str = rf"$\omega_{mn}$ = {omega:.2f} rad/s" ax.text( 0.05, 0.95, text_str, transform=ax.transAxes, fontsize=10, verticalalignment="top", bbox=dict(boxstyle="round", facecolor="white", alpha=0.7), ) # ax.set_xlabel("Normalized Displacement") fig.tight_layout(rect=[0, 0.03, 1, 0.95]) plt.show()
[docs] def animate_response( self, response_df, scale_factor=20, ground_motion=None, speed_up=1.0, repeat=True, save_path=None, ): """ Animates the dynamic response of the shear building. Parameters ---------- response_df : pandas.DataFrame The response DataFrame, as returned by a solver like `find_response`. scale_factor : int, optional A factor to scale the displacements for better visualization, by default 20. ground_motion : tuple, optional A tuple `(time, acceleration)` for the ground motion history. If provided, a plot of the ground motion will be shown below the building animation. By default None. speed_up : float, optional The factor by which to speed up the animation, by default 1.0. repeat : bool, optional Whether the animation should repeat when finished, by default True. save_path : str, optional The file path to save the animation (e.g., 'animation.mp4'). If provided, the animation is saved to file instead of being shown. By default None. """ u_cols = [f"u{i + 1}" for i in range(self.n_stories)] displacements = response_df[u_cols].to_numpy() time_vector = response_df["time"].to_numpy() # Create subplots if ground_motion: fig, (ax_build, ax_gm) = plt.subplots( 2, 1, figsize=(8, 8), gridspec_kw={"height_ratios": [3, 1]} ) gm_time, gm_acc = ground_motion ax_gm.plot(gm_time, gm_acc, "k-") ax_gm.set_xlabel("Time (s)") ax_gm.set_ylabel("Ground Acc. (g)") ax_gm.grid(True) (time_marker,) = ax_gm.plot([], [], "r-", lw=2) # The vertical line else: fig, ax_build = plt.subplots(figsize=(8, 6)) # ax_build.set_xlabel( # "Displacement" # ) # Only add xlabel if no ground motion plot # The override should be scaled to match the scaled displacements max_abs_disp = np.max(np.abs(displacements)) * scale_factor def update(frame): frame_index = int(frame) current_time = time_vector[frame_index] # Get original (unscaled) roof displacement for text display roof_disp_unscaled = displacements[frame_index, -1] # Scale displacements for visualization current_displacements_scaled = displacements[frame_index, :] * scale_factor self._plot_displaced_shape( ax_build, current_displacements_scaled, title=f"Deformed shape (SF = {scale_factor})", max_disp_override=max_abs_disp, ) # Add text annotation inside the plot text_str = ( f"Time: {current_time:.2f} s\nRoof Disp: {roof_disp_unscaled:.4f} m" ) ax_build.text( 0.05, 0.95, text_str, transform=ax_build.transAxes, fontsize=10, verticalalignment="top", bbox=dict(boxstyle="round", facecolor="white", alpha=0.7), ) if ground_motion: # Update the time marker on the ground motion plot ylim = ax_gm.get_ylim() time_marker.set_data([current_time, current_time], [ylim[0], ylim[1]]) # --- New robust logic for speed_up --- dt = time_vector[1] - time_vector[0] # Target 50 FPS for a smooth animation target_interval_ms = 20 # Required interval to match the desired speed_up without frame skipping required_interval_ms = dt * 1000 / speed_up if required_interval_ms >= target_interval_ms: # For slow motion or real-time, no need to skip frames frame_step = 1 interval = required_interval_ms else: # For fast motion, skip frames to maintain a smooth playback interval = target_interval_ms # Calculate how many frames to jump over frame_step = int(round(speed_up * interval / (dt * 1000))) # Ensure we are always moving forward frame_step = max(1, frame_step) frames_to_render = np.arange(0, len(time_vector), frame_step) anim = FuncAnimation( fig, update, frames=frames_to_render, interval=interval, repeat=repeat, ) # Adjust layout to prevent text from being trimmed fig.tight_layout(rect=[0, 0.03, 1, 0.95]) if save_path: print(f"Saving animation to {save_path}... This may take a moment.") try: anim.save(save_path, writer="ffmpeg", dpi=150) print(f"Animation successfully saved to {save_path}") except Exception as e: print(f"Error saving animation: {e}") print( "Please ensure FFmpeg is installed and accessible in your system's PATH." ) plt.close(fig) # Close the figure to free up memory else: plt.show() return anim