Análisis matricial de armaduras con Python

Capítulo 11

Visualizar los resultados

Una tabla de desplazamientos y fuerzas contiene toda la información, pero no la muestra. Un dibujo de la armadura deformada, o de sus barras coloreadas según trabajen en tracción o en compresión, se entiende de un vistazo y revela errores que en una tabla pasarían inadvertidos: un apoyo que se mueve, una barra que debería estar comprimida y aparece traccionada. En este capítulo profundizamos en Matplotlib y escribimos las funciones que dibujarán los resultados de cualquier armadura.

11.1 Dibujar para entender

Los desplazamientos de una armadura son muy pequeños comparados con sus dimensiones: en el ejemplo A, menos de un milímetro en una armadura de seis metros. Dibujados a escala real, la armadura deformada sería indistinguible de la original. Por eso la deformada se dibuja con los desplazamientos multiplicados por un factor de escala (scale factor) λ\lambda: cada nodo de coordenadas (x,y)(x, y) se dibuja en

xd=x+λ ux,yd=y+λ uy.\begin{aligned} x_d = x + \lambda\,u_x, \qquad y_d = y + \lambda\,u_y. \end{aligned}

Un buen factor hace que el mayor desplazamiento dibujado sea una fracción visible del tamaño de la armadura, por ejemplo el 10 %. Si DD es la mayor dimensión de la armadura y umax⁡u_{\max} el mayor desplazamiento, λ=0.1 D/umax⁡\lambda = 0.1\,D / u_{\max}.

11.2 Matplotlib con más detalle

En el capítulo 3 usamos ax.plot con un texto corto, como "k-o", para indicar el color, el estilo y el marcador. Para gráficos cuidados conviene indicarlos por separado, con argumentos con nombre (keyword arguments): color para el color, linewidth para el grosor, linestyle para el estilo ("-", "--", ":"), marker para el símbolo de los puntos y label para el texto de la leyenda. ax.text escribe un texto en una posición, y sus argumentos ha y va lo alinean horizontal y verticalmente. Primero definimos los colores del libro como constantes, escritas en mayúsculas por convención:

# Colores del libro, distinguibles con daltonismo
TRACCION = "#0072B2"
COMPRESION = "#D55E00"
PRINCIPAL = "#1F4E79"
NEUTRO = "#8C8C8C"
fig, ax = plt.subplots()
ax.plot([0, 6], [0, 0], color=TRACCION, linewidth=3,
        label="Tracción")
ax.plot([0, 3], [0, 4], color=COMPRESION, linewidth=3,
        label="Compresión")
ax.plot([0, 3, 6], [0, 4, 0], color=NEUTRO,
        linestyle="--", marker="o", label="Geometría")
ax.text(3, 0.2, "texto en (3, 0.2)", ha="center")
ax.set_aspect("equal")
ax.legend()
plt.show()

11.3 Una función para dibujar la armadura

Todos los gráficos de este capítulo dibujan las barras de una armadura, a veces en su posición original y a veces en la deformada. Escribimos una sola función, que recibe los ejes ax donde dibujar. Así puede usarse varias veces en un mismo gráfico o en distintos paneles de una figura:

def dibujar_armadura(ax, nodos, barras, color=NEUTRO,
                     estilo="-", etiqueta=None):
    """Dibuja las barras de una armadura en ax."""
    for barra in barras.values():
        i = nodos[barra["nodo_inicial"]]
        j = nodos[barra["nodo_final"]]
        ax.plot([i["x"], j["x"]], [i["y"], j["y"]],
                color=color, linestyle=estilo, marker="o",
                markersize=4, label=etiqueta)
        etiqueta = None  # la leyenda, una sola vez
    ax.set_aspect("equal")

El método values de un diccionario recorre solo sus valores, sin las claves, que aquí no necesitamos. La etiqueta de la leyenda se asigna a la primera barra y después se anula, para que la leyenda no repita el mismo texto una vez por barra.

11.4 La deformada

Para dibujar la deformada no necesitamos una función nueva: basta con calcular las posiciones desplazadas de los nodos y dibujar la armadura con ellas. La función devuelve un diccionario con la misma forma que nodos, de modo que dibujar_armadura lo acepta sin cambios:

def nodos_desplazados(nodos, U, escala):
    """Nodos en su posición deformada, con los
    desplazamientos multiplicados por la escala."""
    nuevos = {}
    for nodo, punto in nodos.items():
        ix, iy = indices(gdl_del_nodo(nodo))
        nuevos[nodo] = {"x": punto["x"] + escala * U[ix],
                        "y": punto["y"] + escala * U[iy]}
    return nuevos

El factor de escala se calcula a partir de las coordenadas extremas de la armadura. Las funciones max y min de Python dan el mayor y el menor elemento de una lista:

def escala_automatica(nodos, U, fraccion=0.1):
    """Escala para que el mayor desplazamiento dibujado
    sea una fracción del tamaño de la armadura."""
    xs = []
    ys = []
    for punto in nodos.values():
        xs.append(punto["x"])
        ys.append(punto["y"])
    tamano = max(max(xs) - min(xs), max(ys) - min(ys))
    return fraccion * tamano / np.abs(U).max()

Dibujamos la armadura original con línea de trazos y, encima, la deformada:

escala = escala_automatica(nodos, U)
fig, ax = plt.subplots()
dibujar_armadura(ax, nodos, barras, estilo="--",
                 etiqueta="Original")
deformados = nodos_desplazados(nodos, U, escala)
dibujar_armadura(ax, deformados, barras, color=PRINCIPAL,
                 etiqueta="Deformada")
ax.set_title(f"Ejemplo A, deformada ×{escala:.0f}")
ax.legend()
plt.show()

Verificación. El dibujo debe respetar los apoyos y coincidir con los desplazamientos del capítulo 9. El nodo 1, articulado, no se mueve. El nodo 2 se desplaza solo en horizontal, hacia la derecha, porque el rodillo impide el movimiento vertical. El nodo 3 se mueve hacia la derecha y hacia abajo, como indican sus desplazamientos (uxu_x = 0.563 mm, uyu_y = −0.650 mm). La barra 1 se alarga, en tracción, y las barras inclinadas se acortan, en compresión.

11.5 El diagrama de fuerzas axiales

El diagrama de fuerzas axiales colorea cada barra según su estado y escribe su fuerza. Reutilizamos clasificar_fuerza del capítulo 10 para elegir el color. Los valores se escriben al 40 % de cada barra, desde su nodo inicial, y no en su punto medio: así, como vimos en el ejercicio 4.4, no se tapan los valores de dos diagonales que se cruzan.

def dibujar_fuerzas(ax, nodos, barras, N, decimales=1,
                    tamano=None):
    """Barras en colores según su fuerza axial, con su
    valor en kN al 40 % de cada barra. tamano: tamaño
    de letra de los valores."""
    for numero, barra in barras.items():
        estado = clasificar_fuerza(N[numero])
        if estado == "tracción":
            color = TRACCION
        elif estado == "compresión":
            color = COMPRESION
        else:
            color = NEUTRO
        i = nodos[barra["nodo_inicial"]]
        j = nodos[barra["nodo_final"]]
        ax.plot([i["x"], j["x"]], [i["y"], j["y"]],
                color=color, linewidth=3)
        # Al 40 % y no en el punto medio: así no se tapan
        # los valores de dos diagonales que se cruzan
        xm = i["x"] + 0.4 * (j["x"] - i["x"])
        ym = i["y"] + 0.4 * (j["y"] - i["y"])
        valor = round(N[numero], decimales) + 0.0
        fondo = {"facecolor": "white", "linewidth": 0}
        ax.text(xm, ym, f"{valor:.{decimales}f}",
                color=color, ha="center", va="center",
                bbox=fondo, fontsize=tamano)
    # Leyenda con líneas vacías: solo muestran los colores
    ax.plot([], [], color=TRACCION, linewidth=3,
            label="Tracción")
    ax.plot([], [], color=COMPRESION, linewidth=3,
            label="Compresión")
    # Leyenda debajo del dibujo, para no tapar las barras
    ax.legend(loc="upper center", bbox_to_anchor=(0.5, 0),
              ncol=2, frameon=False)
    ax.set_aspect("equal")
    ax.axis("off")

Para la leyenda dibujamos dos líneas vacías, ax.plot([], [], ...), que no aparecen en el gráfico pero sí en la leyenda con su color. La leyenda va debajo del dibujo y se quitan los ejes, porque en un esquema de la armadura las coordenadas no aportan información. El argumento bbox recibe un diccionario con las opciones del recuadro blanco que hay detrás de cada valor.

fig, ax = plt.subplots()
dibujar_fuerzas(ax, nodos, barras, N)
ax.set_title("Fuerzas axiales del ejemplo A (kN)")
plt.show()

Problema resuelto 11.1 · Un informe gráfico

Preparar una figura con dos paneles para el ejemplo A: la deformada a la izquierda y el diagrama de fuerzas axiales a la derecha.

Solución

plt.subplots(1, 2) crea una figura con una fila y dos columnas de ejes, que recibimos en dos variables. El argumento figsize fija el tamaño de la figura en pulgadas, y tight_layout ajusta los márgenes para que nada se superponga. Cada función dibuja en los ejes que le indicamos:

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(4.5, 2.2))

escala = escala_automatica(nodos, U)
dibujar_armadura(ax1, nodos, barras, estilo="--")
dibujar_armadura(ax1, nodos_desplazados(nodos, U, escala),
                 barras, color=PRINCIPAL)
ax1.set_title(f"Deformada (×{escala:.0f})")

dibujar_fuerzas(ax2, nodos, barras, N)
ax2.set_title("Fuerzas axiales (kN)")

for ax in (ax1, ax2):
    ax.axis("off")  # sin ejes: es un esquema
fig.tight_layout()
plt.show()

Verificación. Ambos paneles cuentan la misma historia: la barra que se alarga en la deformada, la 1, es la única en tracción, y las que se acortan están comprimidas.

Repaso y resumen del capítulo

Ejercicios

Ejercicio 11.1

Dibujar la deformada del ejemplo A con factores de escala de 100 y de 2000, en dos paneles. ¿Cuál es más útil y por qué?

Ver la solución
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(4.5, 2.2))
for ax, escala in ((ax1, 100), (ax2, 2000)):
    dibujar_armadura(ax, nodos, barras, estilo="--")
    deformados = nodos_desplazados(nodos, U, escala)
    dibujar_armadura(ax, deformados, barras,
                     color=PRINCIPAL)
    ax.set_title(f"Escala ×{escala}")
    ax.axis("off")
fig.tight_layout()
plt.show()

Con un factor de 100, la deformada casi se confunde con la armadura original y no permite ver el movimiento. Con 2000, el movimiento es evidente, aunque la armadura parece muy distorsionada. Un factor intermedio, como el automático, muestra el movimiento sin exagerarlo.

Ejercicio 11.2

Agregar al diagrama de fuerzas del ejemplo A un triángulo debajo de cada nodo apoyado. El argumento marker="^" de ax.plot dibuja un triángulo, y markersize, su tamaño.

Ver la solución
fig, ax = plt.subplots()
dibujar_fuerzas(ax, nodos, barras, N)
for nodo in apoyos:
    punto = nodos[nodo]
    ax.plot(punto["x"], punto["y"] - 0.25, marker="^",
            markersize=14, color="black")
ax.set_ylim(-0.8, 4.5)
ax.set_title("Fuerzas axiales y apoyos (kN)")
plt.show()

Los triángulos se dibujan un poco por debajo de cada nodo, y ax.set_ylim amplía el rango vertical para que entren en el gráfico.

Ejercicio 11.3

Preparar el informe gráfico del ejemplo B: su deformada y su diagrama de fuerzas en dos paneles.

Ver la solución

Calculamos primero los resultados del ejemplo B con las funciones de los capítulos anteriores:

geometria_b = calcular_geometria(nodos_b, barras_b)
rigideces_b = calcular_rigideces(barras_b, geometria_b)
K_b = ensamblar_rigidez(nodos_b, barras_b, rigideces_b)
F_b = ensamblar_cargas(nodos_b, cargas_b)
libres_b, restringidos_b = clasificar_gdl(nodos_b,
                                          apoyos_b)
K_ll_b, K_lr_b, _, _ = particionar(K_b, libres_b,
                                   restringidos_b)
U_b = np.zeros(2 * len(nodos_b))
U_b[indices(libres_b)] = resolver_desplazamientos(
    K_ll_b, K_lr_b, F_b[indices(libres_b)],
    np.zeros(len(restringidos_b)))
N_b = fuerzas_axiales(barras_b, geometria_b, U_b)

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(4.5, 2.8))
escala = escala_automatica(nodos_b, U_b)
dibujar_armadura(ax1, nodos_b, barras_b, estilo="--")
deformados = nodos_desplazados(nodos_b, U_b, escala)
dibujar_armadura(ax1, deformados, barras_b,
                 color=PRINCIPAL)
ax1.set_title(f"Deformada (×{escala:.0f})")
dibujar_fuerzas(ax2, nodos_b, barras_b, N_b)
ax2.set_title("Fuerzas axiales (kN)")
for ax in (ax1, ax2):
    ax.axis("off")
fig.tight_layout()
plt.show()

La deformada muestra el cuadro desplazándose hacia la derecha, empujado por la carga horizontal: la diagonal 6 se acorta y la 5 se alarga, como indican sus colores.

Ejercicio 11.4

Dibujar el ejemplo A con el grosor de cada barra proporcional al valor absoluto de su fuerza axial, de modo que la barra más cargada se vea más gruesa.

Ver la solución
fig, ax = plt.subplots()
mayor = 0.0
for fuerza in N.values():
    mayor = max(mayor, abs(fuerza))
for numero, barra in barras.items():
    i = nodos[barra["nodo_inicial"]]
    j = nodos[barra["nodo_final"]]
    if N[numero] > 0:
        color = TRACCION
    else:
        color = COMPRESION
    grosor = 1 + 7 * abs(N[numero]) / mayor
    ax.plot([i["x"], j["x"]], [i["y"], j["y"]],
            color=color, linewidth=grosor,
            solid_capstyle="round")
ax.set_aspect("equal")
ax.axis("off")
ax.set_title("Grosor proporcional a |N|")
plt.show()

El grosor va de 1 a 8 puntos, proporcional a ∣N∣|N| respecto de la mayor fuerza. El argumento solid_capstyle="round" redondea los extremos de las líneas gruesas.