Browse Source

funciones de barrido de ipe

dacowars 3 weeks ago
parent
commit
5487e7155a

BIN
__pycache__/app_designer.cpython-313.pyc


BIN
__pycache__/parametric_sweep.cpython-313.pyc


+ 441 - 7
app_designer.py

@@ -1,11 +1,13 @@
 import tkinter as tk
 import tkinter as tk
-from tkinter import ttk, messagebox, simpledialog
+from tkinter import ttk, messagebox, simpledialog, filedialog
 import json
 import json
 import numpy as np
 import numpy as np
 import math
 import math
 import matplotlib.pyplot as plt
 import matplotlib.pyplot as plt
 from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
 from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
+import pandas as pd
 import os
 import os
+from parametric_sweep import ParametricSweep
 
 
 # ==================== CONSTANTES DE MATERIAL ====================
 # ==================== CONSTANTES DE MATERIAL ====================
 DENS = 7850  # kg/m3
 DENS = 7850  # kg/m3
@@ -322,6 +324,12 @@ class SectionDesignerApp(tk.Tk):
         self.current_points = None
         self.current_points = None
         self.current_properties = None
         self.current_properties = None
 
 
+        # Variables para barrido paramétrico
+        self.sweep_result = None  # DataFrame de resultados
+        self.sweep_pareto = None  # DataFrame con Pareto front
+        self.sweep_figure = None  # Figura con gráficas
+        self.sweep_canvas = None  # Canvas para mostrar gráficas
+
         self.create_widgets()
         self.create_widgets()
 
 
     def load_json(self):
     def load_json(self):
@@ -362,7 +370,7 @@ class SectionDesignerApp(tk.Tk):
         ttk.Label(left_panel, text="Tipo de sección:", font=("Arial", 10, "bold")).pack(anchor=tk.W, pady=(10, 5))
         ttk.Label(left_panel, text="Tipo de sección:", font=("Arial", 10, "bold")).pack(anchor=tk.W, pady=(10, 5))
         self.combo_tipo = ttk.Combobox(
         self.combo_tipo = ttk.Combobox(
             left_panel,
             left_panel,
-            values=["IPE", "Personalizada", "Puente nuevo"],
+            values=["IPE", "Personalizada", "Puente nuevo", "Barrido Paramétrico"],
             state='readonly',
             state='readonly',
             width=20
             width=20
         )
         )
@@ -428,7 +436,7 @@ class SectionDesignerApp(tk.Tk):
         # H (Altura exterior)
         # H (Altura exterior)
         ttk.Label(self.frame_puente_nuevo, text = "h (altura exterior, m):").pack(anchor=tk.W, pady=(10, 0))
         ttk.Label(self.frame_puente_nuevo, text = "h (altura exterior, m):").pack(anchor=tk.W, pady=(10, 0))
         self.entry_h_puente = ttk.Spinbox(self.frame_puente_nuevo, from_=0.0, to=10000.0, increment=0.01, width=15, command=self.on_parameter_change_puente_nuevo)
         self.entry_h_puente = ttk.Spinbox(self.frame_puente_nuevo, from_=0.0, to=10000.0, increment=0.01, width=15, command=self.on_parameter_change_puente_nuevo)
-        self.entry_h_puente.pack(anchor=tk.W, fill=tk.X)   
+        self.entry_h_puente.pack(anchor=tk.W, fill=tk.X)
         self.entry_h_puente.set("0.300")
         self.entry_h_puente.set("0.300")
         self.entry_h_puente.bind('<FocusOut>', self.on_parameter_change_puente_nuevo)
         self.entry_h_puente.bind('<FocusOut>', self.on_parameter_change_puente_nuevo)
         self.entry_h_puente.bind('<Return>', self.on_parameter_change_puente_nuevo)
         self.entry_h_puente.bind('<Return>', self.on_parameter_change_puente_nuevo)
@@ -484,11 +492,15 @@ class SectionDesignerApp(tk.Tk):
         # theta (ángulo refuerzo)
         # theta (ángulo refuerzo)
         ttk.Label(self.frame_puente_nuevo, text="theta (ángulo refuerzo, grados):").pack(anchor=tk.W, pady=(10, 0))
         ttk.Label(self.frame_puente_nuevo, text="theta (ángulo refuerzo, grados):").pack(anchor=tk.W, pady=(10, 0))
         self.entry_theta_puente = ttk.Spinbox(self.frame_puente_nuevo, from_=0.0, to=360.0, increment=1.0, width=15, command=self.on_parameter_change_puente_nuevo)
         self.entry_theta_puente = ttk.Spinbox(self.frame_puente_nuevo, from_=0.0, to=360.0, increment=1.0, width=15, command=self.on_parameter_change_puente_nuevo)
-        self.entry_theta_puente.pack(anchor=tk.W, fill=tk.X)   
+        self.entry_theta_puente.pack(anchor=tk.W, fill=tk.X)
         self.entry_theta_puente.set("45")
         self.entry_theta_puente.set("45")
         self.entry_theta_puente.bind('<FocusOut>', self.on_parameter_change_puente_nuevo)
         self.entry_theta_puente.bind('<FocusOut>', self.on_parameter_change_puente_nuevo)
         self.entry_theta_puente.bind('<Return>', self.on_parameter_change_puente_nuevo)
         self.entry_theta_puente.bind('<Return>', self.on_parameter_change_puente_nuevo)
 
 
+        # Frame para barrido paramétrico
+        self.frame_sweep_tab = ttk.Frame(left_panel)
+        self.create_sweep_tab_widgets()
+
         # Separador
         # Separador
         ttk.Separator(left_panel, orient=tk.HORIZONTAL).pack(fill=tk.X, pady=10)
         ttk.Separator(left_panel, orient=tk.HORIZONTAL).pack(fill=tk.X, pady=10)
 
 
@@ -518,26 +530,136 @@ class SectionDesignerApp(tk.Tk):
 
 
         self.frame_puente_nuevo.pack_forget()
         self.frame_puente_nuevo.pack_forget()
 
 
+    def create_sweep_tab_widgets(self):
+        """Crea los widgets del panel de barrido paramétrico"""
+        self.frame_sweep_tab.pack(fill=tk.BOTH, expand=True, pady=(0, 10))
+
+        # Título
+        ttk.Label(self.frame_sweep_tab, text="Barrido Paramétrico", font=("Arial", 10, "bold")).pack(anchor=tk.W, pady=(10, 5))
+
+        # Sección actual
+        ttk.Label(self.frame_sweep_tab, text="Sección: IPE", font=("Arial", 9)).pack(anchor=tk.W, pady=(5, 10))
+
+        # Frame para parámetros fijos
+        frame_fixed = ttk.LabelFrame(self.frame_sweep_tab, text="Parámetros Fijos", padding=5)
+        frame_fixed.pack(fill=tk.X, pady=(0, 10))
+
+        # Variables para toggle fijo/barrido
+        self.sweep_toggle_vars = {}
+        self.sweep_fixed_entries = {}
+        self.sweep_range_entries = {}
+
+        params_ipe = ['H', 'b', 'tf', 'tw']
+        for param in params_ipe:
+            container = ttk.Frame(frame_fixed)
+            container.pack(fill=tk.X, pady=2)
+
+            var = tk.BooleanVar(value=True)
+            self.sweep_toggle_vars[param] = var
+            chk = ttk.Checkbutton(container, text=f"Fijo: {param}", variable=var,
+                                 command=lambda p=param: self.on_sweep_toggle(p))
+            chk.pack(side=tk.LEFT, fill=tk.X, expand=True)
+
+            entry = ttk.Spinbox(container, from_=0.0, to=1000.0, increment=0.001, width=10)
+            entry.pack(side=tk.LEFT, padx=5)
+            self.sweep_fixed_entries[param] = entry
+
+        # Cargar valores actuales
+        self.sweep_fixed_entries['H'].set("0.300")
+        self.sweep_fixed_entries['b'].set("0.150")
+        self.sweep_fixed_entries['tf'].set("0.0107")
+        self.sweep_fixed_entries['tw'].set("0.0063")
+
+        # Frame para parámetros a barrer
+        frame_sweep = ttk.LabelFrame(self.frame_sweep_tab, text="Parámetros a Barrer", padding=5)
+        frame_sweep.pack(fill=tk.X, pady=(0, 10))
+
+        self.sweep_range_labels = {}
+        self.sweep_range_entries = {}
+
+        for param in params_ipe:
+            container = ttk.Frame(frame_sweep)
+            container.pack(fill=tk.X, pady=2)
+
+            lbl = ttk.Label(container, text=f"{param}:", width=5)
+            lbl.pack(side=tk.LEFT)
+            self.sweep_range_labels[param] = lbl
+
+            ttk.Label(container, text="min:").pack(side=tk.LEFT, padx=(10, 2))
+            min_entry = ttk.Spinbox(container, from_=0.0, to=1000.0, increment=0.001, width=8)
+            min_entry.pack(side=tk.LEFT, padx=2)
+
+            ttk.Label(container, text="max:").pack(side=tk.LEFT, padx=(10, 2))
+            max_entry = ttk.Spinbox(container, from_=0.0, to=1000.0, increment=0.001, width=8)
+            max_entry.pack(side=tk.LEFT, padx=2)
+
+            ttk.Label(container, text="pasos:").pack(side=tk.LEFT, padx=(10, 2))
+            steps_entry = ttk.Spinbox(container, from_=2, to=20, increment=1, width=8)
+            steps_entry.pack(side=tk.LEFT, padx=2)
+            steps_entry.set(5)
+
+            self.sweep_range_entries[param] = {
+                'min': min_entry,
+                'max': max_entry,
+                'steps': steps_entry
+            }
+
+        # Botones de control
+        button_frame = ttk.Frame(self.frame_sweep_tab)
+        button_frame.pack(fill=tk.X, pady=(10, 0))
+
+        ttk.Button(button_frame, text="Ejecutar Barrido", command=self.execute_parametric_sweep).pack(side=tk.LEFT, padx=2)
+        ttk.Button(button_frame, text="Exportar Excel", command=self.export_sweep_results).pack(side=tk.LEFT, padx=2)
+        ttk.Button(button_frame, text="Limpiar", command=self.clear_sweep_results).pack(side=tk.LEFT, padx=2)
+
+        # Ocultar frame de barrido inicialmente
+        self.frame_sweep_tab.pack_forget()
+
+    def on_sweep_toggle(self, param):
+        """Alterna parámetro entre fijo y barrido"""
+        is_fixed = self.sweep_toggle_vars[param].get()
+        # Los rangos se mostrarán solo si no está fijo
+        # Esta lógica se puede mejorar si es necesario
+
     def on_section_type_changed(self, event):
     def on_section_type_changed(self, event):
-        """Cambia entre IPE y Personalizada"""
+        """Cambia entre IPE, Personalizada, Puente nuevo y Barrido"""
         section_type = self.combo_tipo.get()
         section_type = self.combo_tipo.get()
 
 
         if section_type == "IPE":
         if section_type == "IPE":
             self.frame_personalizada.pack_forget()
             self.frame_personalizada.pack_forget()
             self.frame_puente_nuevo.pack_forget()
             self.frame_puente_nuevo.pack_forget()
+            self.frame_sweep_tab.pack_forget()
             self.frame_ipe.pack(fill=tk.BOTH, expand=False, pady=(0, 10))
             self.frame_ipe.pack(fill=tk.BOTH, expand=False, pady=(0, 10))
             self.on_parameter_change_ipe(None)
             self.on_parameter_change_ipe(None)
+        elif section_type == "Barrido Paramétrico":
+            self.frame_ipe.pack_forget()
+            self.frame_personalizada.pack_forget()
+            self.frame_puente_nuevo.pack_forget()
+            self.frame_sweep_tab.pack(fill=tk.BOTH, expand=True, pady=(0, 10))
+            # Precargar valores actuales como fijos
+            if self.current_properties:
+                try:
+                    self.sweep_fixed_entries['H'].delete(0, tk.END)
+                    self.sweep_fixed_entries['H'].insert(0, f"{float(self.entry_H.get()):.6f}")
+                    self.sweep_fixed_entries['b'].delete(0, tk.END)
+                    self.sweep_fixed_entries['b'].insert(0, f"{float(self.entry_b.get()):.6f}")
+                    self.sweep_fixed_entries['tf'].delete(0, tk.END)
+                    self.sweep_fixed_entries['tf'].insert(0, f"{float(self.entry_tf.get()):.6f}")
+                    self.sweep_fixed_entries['tw'].delete(0, tk.END)
+                    self.sweep_fixed_entries['tw'].insert(0, f"{float(self.entry_tw.get()):.6f}")
+                except:
+                    pass
         elif section_type == "Puente nuevo":
         elif section_type == "Puente nuevo":
             self.frame_ipe.pack_forget()
             self.frame_ipe.pack_forget()
             self.frame_personalizada.pack_forget()
             self.frame_personalizada.pack_forget()
+            self.frame_sweep_tab.pack_forget()
             self.frame_puente_nuevo.pack(fill=tk.BOTH, expand=True, pady=(0, 10))
             self.frame_puente_nuevo.pack(fill=tk.BOTH, expand=True, pady=(0, 10))
             self.on_parameter_change_puente_nuevo(None)
             self.on_parameter_change_puente_nuevo(None)
-            # Aquí podrías cargar valores por defecto o actuales para el puente nuevo
         else:  # Personalizada
         else:  # Personalizada
             self.frame_ipe.pack_forget()
             self.frame_ipe.pack_forget()
             self.frame_puente_nuevo.pack_forget()
             self.frame_puente_nuevo.pack_forget()
+            self.frame_sweep_tab.pack_forget()
             self.frame_personalizada.pack(fill=tk.BOTH, expand=True)
             self.frame_personalizada.pack(fill=tk.BOTH, expand=True)
-            # Mostrar los puntos actuales en el texto
             if self.current_points is not None:
             if self.current_points is not None:
                 import json
                 import json
                 puntos_json = json.dumps(self.current_points.tolist(), indent=2)
                 puntos_json = json.dumps(self.current_points.tolist(), indent=2)
@@ -900,6 +1022,318 @@ MECÁNICAS:
         messagebox.showinfo("Éxito", f"Sección '{new_name}' guardada correctamente")
         messagebox.showinfo("Éxito", f"Sección '{new_name}' guardada correctamente")
 
 
 
 
+    # ==================== MÉTODOS DE BARRIDO PARAMÉTRICO ====================
+
+    def execute_parametric_sweep(self):
+        """Ejecuta el barrido paramétrico"""
+        try:
+            # Recopilar configuración
+            fixed_params = {}
+            sweep_configs = {}
+
+            for param in ['H', 'b', 'tf', 'tw']:
+                is_fixed = self.sweep_toggle_vars[param].get()
+
+                if is_fixed:
+                    value = float(self.sweep_fixed_entries[param].get())
+                    fixed_params[param] = value
+                else:
+                    min_val = float(self.sweep_range_entries[param]['min'].get())
+                    max_val = float(self.sweep_range_entries[param]['max'].get())
+                    steps = int(self.sweep_range_entries[param]['steps'].get())
+
+                    if min_val >= max_val:
+                        messagebox.showerror("Error", f"Rango inválido para {param}: min >= max")
+                        return
+
+                    sweep_configs[param] = {
+                        'min': min_val,
+                        'max': max_val,
+                        'steps': steps
+                    }
+
+            if not sweep_configs:
+                messagebox.showerror("Error", "Debe barrer al menos un parámetro")
+                return
+
+            if not fixed_params:
+                messagebox.showwarning("Advertencia", "No hay parámetros fijos")
+
+            # Ejecutar barrido
+            messagebox.showinfo("Info", "Ejecutando barrido... Esto puede tomar unos segundos")
+
+            sweep = ParametricSweep("ipe", fixed_params, sweep_configs)
+            self.sweep_result = sweep.execute_sweep()
+            self.sweep_pareto = sweep.find_pareto_front()
+
+            # Calcular combinaciones teóricas vs válidas
+            total_combos = 1
+            for config in sweep_configs.values():
+                total_combos *= config['steps']
+
+            valid_combos = len(self.sweep_result)
+            invalid_combos = total_combos - valid_combos
+
+            msg = f"✓ Barrido completado:\n"
+            msg += f"  Combinaciones teóricas: {total_combos}\n"
+            msg += f"  Combinaciones válidas: {valid_combos}\n"
+            if invalid_combos > 0:
+                msg += f"  Rechazadas (geometría): {invalid_combos}\n"
+            msg += f"\n✓ Soluciones ordenadas: {len(self.sweep_pareto)}"
+
+            messagebox.showinfo("Éxito", msg)
+
+            # Mostrar resultados
+            self.show_sweep_results()
+
+        except Exception as e:
+            messagebox.showerror("Error", f"Error en barrido: {str(e)}")
+
+    def show_sweep_results(self):
+        """Muestra los resultados del barrido ordenados por eficiencia"""
+        if self.sweep_result is None or self.sweep_pareto is None:
+            messagebox.showerror("Error", "Ejecute primero el barrido")
+            return
+
+        try:
+            # Crear figura con dos subplots
+            if self.sweep_figure is not None:
+                plt.close(self.sweep_figure)
+
+            self.sweep_figure = plt.Figure(figsize=(14, 6), dpi=100)
+
+            # Subplot 1: Visualización de soluciones
+            ax1 = self.sweep_figure.add_subplot(121)
+
+            # Obtener parámetros barridos
+            sweep_params = [p for p in ['H', 'b', 'tf', 'tw'] if p in self.sweep_result.columns and self.sweep_result[p].nunique() > 1]
+
+            scatter = None  # Variable para almacenar scatter si existe
+            if len(sweep_params) >= 2:
+                # Scatter plot: Peso vs Eficiencia
+                scatter = ax1.scatter(self.sweep_result['peso'],
+                                     self.sweep_result['eficiencia'],
+                                     c=self.sweep_result['ixg'],
+                                     cmap='viridis', s=100, alpha=0.6, edgecolors='black')
+
+                # Marcar el más eficiente con un símbolo especial (diamante dorado)
+                most_efficient = self.sweep_pareto.iloc[0]
+                ax1.scatter(most_efficient['peso'], most_efficient['eficiencia'],
+                           marker='D', s=400, c='gold', edgecolors='orange',
+                           linewidth=2.5, label='Óptimo', zorder=6)
+
+                self.sweep_figure.colorbar(scatter, ax=ax1, label='Ix (cm⁴)')
+                ax1.set_xlabel('Peso (kg/m)', fontweight='bold', fontsize=11)
+                ax1.set_ylabel('Eficiencia = (Ix+Iy)/Peso*1000', fontweight='bold', fontsize=11)
+                ax1.set_title('Soluciones: Peso vs Eficiencia\n(Dorado = Óptimo)', fontweight='bold')
+                ax1.grid(True, alpha=0.3)
+                ax1.legend(loc='best')
+
+            elif len(sweep_params) == 1:
+                # Plot simple si solo 1 parámetro
+                ax1.plot(self.sweep_result[sweep_params[0]], self.sweep_result['eficiencia'], 'b.-', label='Todas', linewidth=2, markersize=8)
+                top5 = self.sweep_pareto.head(5)
+                ax1.plot(top5[sweep_params[0]], top5['eficiencia'], 'r*', markersize=20, label='Top 5', zorder=5)
+
+                # Marcar el más eficiente
+                most_efficient = self.sweep_pareto.iloc[0]
+                ax1.plot(most_efficient[sweep_params[0]], most_efficient['eficiencia'], 'D',
+                        color='gold', markersize=5, markeredgecolor='orange', markeredgewidth=2,
+                        label='Más Eficiente', zorder=6)
+
+                ax1.set_xlabel(f'{sweep_params[0]} (m)', fontweight='bold', fontsize=11)
+                ax1.set_ylabel('Eficiencia', fontweight='bold', fontsize=11)
+                ax1.set_title(f'Eficiencia vs {sweep_params[0]}', fontweight='bold')
+                ax1.legend()
+                ax1.grid(True, alpha=0.3)
+
+            # Subplot 2: Tabla top 10
+            ax2 = self.sweep_figure.add_subplot(122)
+            ax2.axis('tight')
+            ax2.axis('off')
+
+            # Preparar datos para tabla
+            cols_to_show = [c for c in ['H', 'b', 'tf', 'tw', 'peso', 'ixg', 'iyg', 'eficiencia'] if c in self.sweep_pareto.columns]
+            top_pareto = self.sweep_pareto.head(10)[cols_to_show].copy()
+
+            # Formatear números
+            table_data = []
+            for rank, (_, row) in enumerate(top_pareto.iterrows(), 1):
+                formatted_row = [str(rank)]  # Ranking
+                for col in cols_to_show:
+                    val = row[col]
+                    if col in ['H', 'b', 'tf', 'tw']:
+                        formatted_row.append(f"{val:.4f}")
+                    else:
+                        formatted_row.append(f"{val:.2f}")
+                table_data.append(formatted_row)
+
+            col_labels = ['#'] + list(cols_to_show)
+            table = ax2.table(cellText=table_data, colLabels=col_labels,
+                            cellLoc='center', loc='center', bbox=[0, 0, 1, 1])
+            table.auto_set_font_size(False)
+            table.set_fontsize(8)
+            table.scale(1, 1.8)
+
+            # Colorear header
+            for i in range(len(col_labels)):
+                table[(0, i)].set_facecolor('#40466e')
+                table[(0, i)].set_text_props(weight='bold', color='white')
+
+            # Colorear filas alternadas
+            for i in range(1, len(table_data) + 1):
+                color = '#f0f0f0' if i % 2 == 0 else 'white'
+                for j in range(len(col_labels)):
+                    table[(i, j)].set_facecolor(color)
+
+            ax2.set_title('Top 10 Soluciones\n(Ordenadas por Eficiencia)', fontweight='bold', fontsize=11, pad=20)
+
+            self.sweep_figure.tight_layout()
+
+            # Mostrar en canvas
+            if self.sweep_canvas is not None:
+                self.sweep_canvas.get_tk_widget().destroy()
+
+            right_panel = self.canvas.get_tk_widget().master
+            self.sweep_canvas = FigureCanvasTkAgg(self.sweep_figure, master=right_panel)
+            self.sweep_canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True)
+            self.sweep_canvas.draw()
+
+            # Agregar interactividad con hover DESPUÉS de crear el canvas
+            if scatter is not None:
+                self._setup_scatter_hover(ax1, scatter)
+
+        except Exception as e:
+            messagebox.showerror("Error", f"Error mostrando resultados: {str(e)}")
+            import traceback
+            traceback.print_exc()
+
+    def _setup_scatter_hover(self, ax, scatter):
+        """Configura interactividad con hover para mostrar valores de puntos"""
+        # Almacenar referencias a los datos
+        self.scatter_ax = ax
+        self.scatter_data = self.sweep_result.copy()
+        self.hover_annotation = None
+
+        def on_hover(event):
+            """Muestra información cuando el mouse pasa sobre puntos del scatter"""
+            if event.inaxes != self.scatter_ax or len(self.scatter_data) == 0:
+                if self.hover_annotation is not None:
+                    self.hover_annotation.remove()
+                    self.hover_annotation = None
+                self.sweep_canvas.draw_idle()
+                return
+
+            # Calcular distancias a todos los puntos
+            x, y = event.xdata, event.ydata
+            if x is None or y is None:
+                return
+
+            # Distancias en coordenadas de display para mejor detección
+            try:
+                peso_data = self.scatter_data['peso'].values
+                eficiencia_data = self.scatter_data['eficiencia'].values
+
+                # Convertir coordenadas de datos a display
+                x_display, y_display = self.scatter_ax.transData.transform((x, y))
+                puntos_display = self.scatter_ax.transData.transform(
+                    np.c_[peso_data, eficiencia_data]
+                )
+
+                # Calcular distancia en píxeles
+                dist = np.sqrt((puntos_display[:, 0] - x_display)**2 +
+                             (puntos_display[:, 1] - y_display)**2)
+
+                # Umbral de 20 píxeles para detectar hover
+                idx_cercano = np.argmin(dist)
+                if dist[idx_cercano] < 20:
+                    # Obtener datos del punto
+                    row = self.scatter_data.iloc[idx_cercano]
+
+                    # Construir texto con información
+                    texto = f"Peso: {row['peso']:.2f} kg/m\n"
+                    texto += f"Eficiencia: {row['eficiencia']:.2f}\n"
+
+                    # Agregar parámetros que varían
+                    for param in ['H', 'b', 'tf', 'tw']:
+                        if param in self.scatter_data.columns and self.scatter_data[param].nunique() > 1:
+                            texto += f"{param}: {row[param]:.4f}\n"
+
+                    texto += f"Ix: {row['ixg']:.0f} cm⁴\n"
+                    texto += f"Iy: {row['iyg']:.0f} cm⁴"
+
+                    # Crear o actualizar anotación
+                    if self.hover_annotation is not None:
+                        self.hover_annotation.remove()
+
+                    self.hover_annotation = self.scatter_ax.annotate(
+                        texto,
+                        xy=(row['peso'], row['eficiencia']),
+                        xytext=(10, 10),
+                        textcoords='offset points',
+                        bbox=dict(boxstyle='round,pad=0.5', facecolor='yellow', alpha=0.7),
+                        fontsize=8,
+                        arrowprops=dict(arrowstyle='->', connectionstyle='arc3,rad=0', color='black')
+                    )
+                    self.sweep_canvas.draw_idle()
+                else:
+                    # Mover el mouse lejos de los puntos
+                    if self.hover_annotation is not None:
+                        self.hover_annotation.remove()
+                        self.hover_annotation = None
+                        self.sweep_canvas.draw_idle()
+            except Exception:
+                pass
+
+        # Conectar evento de movimiento del mouse
+        self.sweep_canvas.mpl_connect('motion_notify_event', on_hover)
+
+    def export_sweep_results(self):
+        """Exporta resultados del barrido a Excel"""
+        if self.sweep_result is None:
+            messagebox.showerror("Error", "No hay resultados para exportar. Ejecute primero el barrido")
+            return
+
+        try:
+            # Pedir ruta de guardado
+            filename = filedialog.asksaveasfilename(
+                defaultextension=".xlsx",
+                filetypes=[("Excel files", "*.xlsx"), ("All files", "*.*")]
+            )
+
+            if not filename:
+                return
+
+            # Crear archivo Excel con pandas
+            with pd.ExcelWriter(filename, engine='openpyxl') as writer:
+                # Hoja 1: Todos los resultados
+                self.sweep_result.to_excel(writer, sheet_name='Resultados', index=False)
+
+                # Hoja 2: Pareto front
+                if self.sweep_pareto is not None:
+                    self.sweep_pareto.to_excel(writer, sheet_name='Pareto Front', index=False)
+
+            messagebox.showinfo("Éxito", f"Resultados exportados a:\n{filename}")
+
+        except Exception as e:
+            messagebox.showerror("Error", f"Error exportando: {str(e)}")
+
+    def clear_sweep_results(self):
+        """Limpia los resultados del barrido"""
+        self.sweep_result = None
+        self.sweep_pareto = None
+
+        if self.sweep_canvas is not None:
+            self.sweep_canvas.get_tk_widget().destroy()
+            self.sweep_canvas = None
+
+        # Volver a gráfico normal
+        self.ax.clear()
+        self.canvas.draw()
+
+        messagebox.showinfo("Info", "Resultados de barrido limpiados")
+
+
 if __name__ == "__main__":
 if __name__ == "__main__":
     app = SectionDesignerApp()
     app = SectionDesignerApp()
     app.mainloop()
     app.mainloop()

+ 354 - 0
parametric_sweep.py

@@ -0,0 +1,354 @@
+"""
+Módulo de Barrido Paramétrico para Secciones de Acero
+
+Permite realizar barridos paramétricos dinámicos sobre secciones IPE y Puente nuevo,
+fijando algunos parámetros y variando otros sistemáticamente.
+"""
+
+import numpy as np
+import pandas as pd
+import itertools
+from typing import Dict, List, Tuple
+import math
+
+
+def generate_ipe_points(H, b, tf, tw):
+    """Genera los puntos de una sección IPE a partir de parámetros normalizados"""
+    h_alma = H - 2*tf
+    x_alma_ini = (b - tw) / 2
+    x_alma_fin = (b + tw) / 2
+
+    points = np.array([
+        [0, 0],
+        [b, 0],
+        [b, tf],
+        [x_alma_fin, tf],
+        [x_alma_fin, H - tf],
+        [b, H - tf],
+        [b, H],
+        [0, H],
+        [0, H - tf],
+        [x_alma_ini, H - tf],
+        [x_alma_ini, tf],
+        [0, tf],
+        [0, 0],
+    ])
+
+    return points
+
+
+def generate_puente_nuevo_points(h, b, tf, tw, ha, ta, tr, theta):
+    """Genera los puntos de la sección tipo puente nuevo"""
+    hr = (b-tw) / 2 * math.tan(math.radians(theta))
+    hl = tr / math.cos(math.radians(theta))
+    hr_ = (b/2 - ta - tw/2)* math.tan(math.radians(theta))
+
+    points = np.array([
+        [0, 0],
+        [b, 0],
+        [b, tf + ha],
+        [(b + tw)/2, tf + ha + hr],
+        [(b + tw)/2, tf + ha + hr - hl],
+        [b - ta, tf + ha + hr - hl - hr_],
+        [b - ta, tf],
+        [(b + tw)/2, tf],
+        [(b + tw)/2, h - tf],
+        [b - ta, h - tf],
+        [b - ta, h - tf - ha - hr + hl + hr_],
+        [(b + tw)/2, h - tf - ha - hr + hl],
+        [(b + tw)/2, h - tf - ha - hr],
+        [b, h - tf - ha],
+        [b, h],
+        [0, h],
+        [0, h - tf - ha],
+        [(b - tw)/2, h - tf - ha - hr],
+        [(b - tw)/2, h - tf - ha - hr + hl],
+        [ta, h - tf - ha - hr + hl + hr_],
+        [ta, h - tf],
+        [(b - tw)/2, h - tf],
+        [(b - tw)/2, tf],
+        [ta, tf],
+        [ta, tf + ha + hr - hl - hr_],
+        [(b - tw)/2, tf + ha + hr - hl],
+        [(b - tw)/2, tf + ha + hr],
+        [0, tf + ha],
+        [0, 0],
+    ])
+
+    return points
+
+
+def calculate_section_properties(points):
+    """Calcula propiedades de la sección a partir de puntos"""
+    # Constantes de material
+    DENS = 7850  # kg/m3
+    FY = 355  # MPa
+    GAMMAS = 1.05
+    EYOUNG = 210000  # MPa
+    NU = 0.3
+    FYD = FY * 10**6 / GAMMAS
+
+    npuntos = len(points)
+    px = points[:, 0]
+    py = points[:, 1]
+
+    bmax = np.amax(px) - np.amin(px)
+    hmax = np.amax(py) - np.amin(py)
+
+    # Perímetro
+    long_i = np.zeros(npuntos - 1)
+    for i in range(npuntos - 1):
+        long_i[i] = ((points[i+1, 0] - points[i, 0])**2 + (points[i+1, 1] - points[i, 1])**2)**(1/2)
+    perimetro = abs(sum(long_i))
+
+    # Área
+    area_i = np.zeros(npuntos - 1)
+    for i in range(npuntos - 1):
+        area_i[i] = (points[i+1, 0] - points[i, 0]) * (points[i+1, 1] + points[i, 1]) / 2
+    area = abs(sum(area_i))
+
+    # Peso
+    peso = area * DENS
+
+    # Centro de gravedad
+    cdg_i = np.zeros([npuntos, 2])
+    for i in range(npuntos - 1):
+        h1 = points[i, 1]
+        h2 = points[i+1, 1]
+        b = points[i+1, 0] - points[i, 0]
+        d = points[i, 0]
+
+        if h1 + h2 == 0:
+            cdg_i[i, 1] = 0
+        else:
+            cdg_i[i, 1] = 1/3 * (h1*h1 + h1*h2 + h2*h2) / (h1 + h2)
+
+        if h1 + h2 == 0:
+            cdg_i[i, 0] = d + b/2
+        else:
+            cdg_i[i, 0] = d + b/3 * (h1 + 2*h2) / (h1 + h2)
+
+    statico_i = np.zeros([npuntos, 2])
+    for i in range(npuntos - 1):
+        statico_i[i, 1] = area_i[i] * cdg_i[i, 1]
+        statico_i[i, 0] = area_i[i] * cdg_i[i, 0]
+
+    cdg = sum(statico_i) / sum(area_i)
+    xg = cdg[0]
+    yg = cdg[1]
+
+    # Fibras más alejadas
+    v1y = np.amax(py) - yg
+    v2y = np.amin(py) - yg
+    v1x = np.amax(px) - xg
+    v2x = np.amin(px) - xg
+
+    # Momentos de inercia
+    inercia_i = np.zeros([npuntos, 3])
+    for i in range(npuntos - 1):
+        h1 = points[i, 1]
+        h2 = points[i+1, 1]
+        b = points[i+1, 0] - points[i, 0]
+        d = points[i, 0]
+        xgi = cdg_i[i, 0]
+        ygi = cdg_i[i, 1]
+        ai = area_i[i]
+
+        if h2 >= h1:
+            ixcuad_G_local = 1/12 * b * (h1**3) + b * h1 * (h1/2 - ygi)**2
+            ixtriang_G_loc = 1/36 * b * (h2-h1)**3 + 1/2 * b * (h2-h1) * ((2*h1+h2)/3 - ygi)**2
+        else:
+            ixcuad_G_local = 1/12 * b * (h2**3) + b * h2 * (h2/2 - ygi)**2
+            ixtriang_G_loc = 1/36 * b * (h1-h2)**3 + 1/2 * b * (h1-h2) * ((2*h2+h1)/3 - ygi)**2
+        inercia_i[i, 0] = ixcuad_G_local + ixtriang_G_loc + ai * (yg - ygi)**2
+
+        if h2 >= h1:
+            iycuad = 1/12 * h1 * b**3 + h1 * b * (b/2 + d - xgi)**2
+            iytrian = 1/36 * (h2-h1) * b**3 + 1/2 * b * (h2-h1) * (2/3*b + d - xgi)**2
+        else:
+            iycuad = 1/12 * h2 * b**3 + h2 * b * (b/2 + d - xgi)**2
+            iytrian = 1/36 * (h1-h2) * b**3 + 1/2 * b * (h1-h2) * (1/3*b + d - xgi)**2
+        inercia_i[i, 1] = iycuad + iytrian + ai * (xg - xgi)**2
+
+        if h2 >= h1:
+            pxygcuadrado = b * h1 * (-h1/2 + ygi) * (-d - b/2 + xgi)
+            pxytriangulo = b*b * (h2-h1)**2 / 72 + b * (h2-h1) / 2 * (-(h2-h1)/3 - h1 + ygi) * (-d - 2/3*b + xgi)
+        else:
+            pxygcuadrado = b * h2 * (-h2/2 + ygi) * (-d - b/2 + xgi)
+            pxytriangulo = -b*b * (h1-h2)**2 / 72 + b * (h1-h2) / 2 * (-(h1-h2)/3 - h2 + ygi) * (-d - 1/3*b + xgi)
+        inercia_i[i, 2] = pxygcuadrado + pxytriangulo + ai * (-xg + xgi) * (-yg + ygi)
+
+    ig = sum(inercia_i)
+    ixg = abs(ig[0])
+    iyg = abs(ig[1])
+    pxyg = ig[2]
+    if sum(area_i) >= 0:
+        pxyg = pxyg
+    else:
+        pxyg = -pxyg
+
+    # Radios de giro
+    rx = (ixg / area)**0.5
+    ry = (iyg / area)**0.5
+
+    # Ejes principales
+    ic = (ixg + iyg) / 2
+    ir = (((ixg - iyg)/2)**2 + pxyg**2)**0.5
+    imax = ic + ir
+    imin = ic - ir
+    rmax = (imax / area)**0.5
+    rmin = (imin / area)**0.5
+
+    # Conversión a unidades prácticas (cm, cm2, cm3, cm4, kN)
+    pot = 2
+    area_cm2 = area * 10**(pot*2)
+    ixg_cm4 = ixg * 10**(pot*4)
+    iyg_cm4 = iyg * 10**(pot*4)
+    imax_cm4 = imax * 10**(pot*4)
+    imin_cm4 = imin * 10**(pot*4)
+
+    return {
+        'area': area_cm2,
+        'ixg': ixg_cm4,
+        'iyg': iyg_cm4,
+        'imax': imax_cm4,
+        'imin': imin_cm4,
+        'peso': peso,
+    }
+
+
+class ParametricSweep:
+    """Ejecuta barridos paramétricos de secciones"""
+
+    def __init__(self, section_type: str, fixed_params: Dict, sweep_configs: Dict):
+        """
+        Args:
+            section_type: "ipe" o "puente_nuevo"
+            fixed_params: {param_name: value, ...}
+            sweep_configs: {param_name: {"min": v, "max": v, "steps": n}, ...}
+        """
+        self.section_type = section_type
+        self.fixed_params = fixed_params.copy()
+        self.sweep_configs = sweep_configs.copy()
+        self.results_df = None
+        self.pareto_front = None
+
+    def _is_valid_geometry(self, params: Dict) -> bool:
+        """Valida que la geometría sea válida para IPE"""
+        if self.section_type == "ipe":
+            H = params.get('H', 0)
+            b = params.get('b', 0)
+            tf = params.get('tf', 0)
+            tw = params.get('tw', 0)
+
+            # Validaciones básicas
+            if H <= 0 or b <= 0 or tf <= 0 or tw <= 0:
+                return False
+            # Espesor de flange no puede ser más de la mitad de altura
+            if tf > H / 2:
+                return False
+            # Espesor de alma no puede ser mayor que ancho
+            if tw > b:
+                return False
+            # Altura del alma positiva
+            if H - 2*tf <= 0:
+                return False
+
+        return True
+
+    def generate_sweep_matrix(self) -> List[Dict]:
+        """Genera todas las combinaciones de parámetros a barrer"""
+        # Obtener parámetros a barrer
+        sweep_params = list(self.sweep_configs.keys())
+
+        # Generar valores para cada parámetro
+        param_values = {}
+        for param in sweep_params:
+            config = self.sweep_configs[param]
+            values = np.linspace(config['min'], config['max'], config['steps'])
+            param_values[param] = values
+
+        # Generar todas las combinaciones
+        combinations = []
+        for combo in itertools.product(*[param_values[p] for p in sweep_params]):
+            params = self.fixed_params.copy()
+            for i, param in enumerate(sweep_params):
+                params[param] = combo[i]
+
+            if self._is_valid_geometry(params):
+                combinations.append(params)
+
+        return combinations
+
+    def execute_sweep(self) -> pd.DataFrame:
+        """Ejecuta el barrido y retorna DataFrame con resultados"""
+        combinations = self.generate_sweep_matrix()
+
+        if not combinations:
+            raise ValueError("No hay combinaciones válidas en el barrido")
+
+        results = []
+
+        for params in combinations:
+            # Generar puntos según tipo
+            if self.section_type == "ipe":
+                points = generate_ipe_points(
+                    params['H'], params['b'], params['tf'], params['tw']
+                )
+            elif self.section_type == "puente_nuevo":
+                points = generate_puente_nuevo_points(
+                    params['h'], params['b'], params['tf'], params['tw'],
+                    params['ha'], params['ta'], params['tr'], params['theta']
+                )
+            else:
+                continue
+
+            # Calcular propiedades
+            props = calculate_section_properties(points)
+
+            # Construir fila de resultados
+            row = params.copy()
+            row.update(props)
+
+            results.append(row)
+
+        self.results_df = pd.DataFrame(results)
+
+        # Normalizar valores para cálculo de Pareto
+        self.results_df['peso_norm'] = (
+            self.results_df['peso'] / self.results_df['peso'].max()
+        )
+        self.results_df['ixg_norm'] = (
+            self.results_df['ixg'] / self.results_df['ixg'].max()
+        )
+        self.results_df['iyg_norm'] = (
+            self.results_df['iyg'] / self.results_df['iyg'].max()
+        )
+
+        return self.results_df
+
+    def find_pareto_front(self) -> pd.DataFrame:
+        """Ordena soluciones por eficiencia: (Ix+Iy) / peso
+        Mayor eficiencia = mejor inercia con menos peso
+        """
+        if self.results_df is None:
+            raise ValueError("Ejecutar execute_sweep() primero")
+
+        # Calcular eficiencia para cada solución
+        self.results_df['eficiencia'] = (
+            (self.results_df['ixg'] + self.results_df['iyg']) /
+            (self.results_df['peso'] * 1000)
+        )
+
+        # Ordenar por eficiencia descendente (mejor primero)
+        self.pareto_front = self.results_df.sort_values('eficiencia', ascending=False)
+
+        return self.pareto_front
+
+    def get_results_dataframe(self) -> pd.DataFrame:
+        """Retorna DataFrame con todos los resultados"""
+        return self.results_df
+
+    def get_pareto_front(self) -> pd.DataFrame:
+        """Retorna DataFrame con Pareto front"""
+        return self.pareto_front