""" ScriptsManager Metadata: @description: Interactive Water Hammer Simulator for Syrup Pumping Systems @description_long: docs/hammer_simulator.md @description_es: Simulador Interactivo de Golpe de Ariete para Sistemas de Bombeo de Jarabe @description_long_es: docs/hammer_simulator_es.md @description_it: Simulatore Interattivo del Colpo d'Ariete per Sistemi di Pompaggio Sciroppo @description_long_it: docs/hammer_simulator_it.md @description_fr: Simulateur Interactif de Coup de Bélier pour Systèmes de Pompage de Sirop @description_long_fr: docs/hammer_simulator_fr.md @required_level: operator @category: simulation @tags: hydraulics,water_hammer,pumping,syrup,transient_analysis @parameters: [] @execution_timeout: 3600 @flask_port: auto """ import argparse import os import json import math import numpy as np from flask import Flask, render_template, request, jsonify, session, send_file from flask_socketio import SocketIO, emit from datetime import datetime import logging import webbrowser import threading import time import sys import sqlite3 import uuid import base64 import io import zipfile from pathlib import Path # Import for PDF and image generation try: import matplotlib matplotlib.use("Agg") # Use non-interactive backend import matplotlib.pyplot as plt from matplotlib.backends.backend_pdf import PdfPages MATPLOTLIB_AVAILABLE = True except ImportError: MATPLOTLIB_AVAILABLE = False # Import for PlantUML integration try: import requests import urllib.parse REQUESTS_AVAILABLE = True except ImportError: REQUESTS_AVAILABLE = False try: from reportlab.lib.pagesizes import letter, A4 from reportlab.platypus import ( SimpleDocTemplate, Paragraph, Spacer, Image as RLImage, Table, TableStyle, ) from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.lib.units import inch from reportlab.lib import colors REPORTLAB_AVAILABLE = True except ImportError: REPORTLAB_AVAILABLE = False def parse_arguments(): """Parse command line arguments from ScriptsManager""" parser = argparse.ArgumentParser( description="Water Hammer Simulator for ScriptsManager" ) parser.add_argument( "--data-dir", required=True, help="Data directory for persistent storage" ) parser.add_argument("--user-level", required=True, help="User permission level") parser.add_argument("--port", type=int, required=True, help="Flask port number") parser.add_argument("--project-id", required=True, help="Project identifier") parser.add_argument("--project-name", required=True, help="Project display name") parser.add_argument( "--theme", required=False, default="light", help="User interface theme (light/dark)" ) parser.add_argument( "--language", required=False, default="en", help="User interface language (en/es/it/fr)" ) parser.add_argument( "--user-id", required=False, default="unknown", help="User identifier" ) parser.add_argument( "--session-id", required=False, help="Session identifier from ScriptsManager" ) return parser.parse_args() class ExecutionLogger: """Comprehensive logging system according to ScriptsManager specifications""" def __init__(self, data_dir, user_id, project_id, script_name): self.data_dir = data_dir self.user_id = user_id self.project_id = project_id self.script_name = script_name self.execution_id = str(uuid.uuid4()) self.start_time = datetime.now() # Create logs directory structure self.logs_dir = os.path.join( data_dir, "..", "..", "..", "logs", "executions", user_id ) today = datetime.now().strftime("%Y-%m-%d") self.daily_log_dir = os.path.join(self.logs_dir, today) os.makedirs(self.daily_log_dir, exist_ok=True) # Log file path self.log_file = os.path.join( self.daily_log_dir, f"execution_{self.execution_id}.log" ) # Setup logging self.logger = logging.getLogger(f"execution_{self.execution_id}") self.logger.setLevel(logging.INFO) # File handler file_handler = logging.FileHandler(self.log_file, encoding="utf-8") file_handler.setLevel(logging.INFO) # Formatter formatter = logging.Formatter( "%(asctime)s - %(levelname)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S" ) file_handler.setFormatter(formatter) self.logger.addHandler(file_handler) # Log execution start self.log_info(f"Execution started for script: {script_name}") self.log_info(f"User: {user_id}, Project: {project_id}") self.log_info(f"Execution ID: {self.execution_id}") def log_info(self, message): """Log info message""" self.logger.info(message) def log_error(self, message): """Log error message""" self.logger.error(message) def log_warning(self, message): """Log warning message""" self.logger.warning(message) def log_calculation(self, params, results): """Log calculation details""" self.log_info("=== CALCULATION PERFORMED ===") self.log_info(f"Input parameters: {json.dumps(params, indent=2)}") self.log_info(f"Results: {json.dumps(results.get('parameters', {}), indent=2)}") def log_user_action(self, action, details=None): """Log user actions""" message = f"User action: {action}" if details: message += f" - {details}" self.log_info(message) def log_session_event(self, event, details=None): """Log session events""" message = f"Session event: {event}" if details: message += f" - {details}" self.log_info(message) def finalize_execution(self, exit_code=0): """Finalize execution logging""" duration = (datetime.now() - self.start_time).total_seconds() self.log_info(f"Execution completed. Duration: {duration:.2f} seconds") self.log_info(f"Exit code: {exit_code}") # Remove handlers to close file for handler in self.logger.handlers[:]: handler.close() self.logger.removeHandler(handler) class SessionManager: """Enhanced session management according to specifications""" def __init__(self, user_id, project_id, execution_logger): self.user_id = user_id self.project_id = project_id self.session_id = str(uuid.uuid4()) self.start_time = datetime.now() self.last_activity = self.start_time self.logger = execution_logger self.logger.log_session_event( "session_created", { "session_id": self.session_id, "user_id": user_id, "project_id": project_id, }, ) def update_activity(self): """Update last activity timestamp""" self.last_activity = datetime.now() def is_expired(self, timeout_minutes=30): """Check if session is expired""" return (datetime.now() - self.last_activity).seconds > (timeout_minutes * 60) def cleanup(self): """Cleanup session""" duration = (datetime.now() - self.start_time).total_seconds() self.logger.log_session_event( "session_ended", {"duration_seconds": duration, "session_id": self.session_id}, ) class PlantUMLGenerator: """Generator for PlantUML system diagrams""" def __init__(self, server_url="http://192.168.88.26:8881"): self.server_url = server_url def generate_system_diagram(self, params, results=None): """Generate PlantUML code for the water hammer system diagram""" # Extract key parameters pipe_length = params.get("pipe_length", 300) # Convert to mm pipe_diameter = params.get("pipe_diameter", 0.065) * 1000 pump_pressure = params.get("pump_pressure", 7) flow_rate = params.get("flow_rate", 22000) closure_time = params.get("closure_time", 2.0) damper_enabled = params.get("damper_enabled", False) damper_position = params.get("damper_position", 280) damper_volume = params.get("damper_volume", 50) # Get results if available real_surge = "" wave_speed = "" critical_time = "" if results: real_surge = f"{results.get('real_surge', 0):.1f} bar" wave_speed = f"{results.get('wave_speed', 0):.0f} m/s" critical_time = f"{results.get('critical_time', 0):.2f} s" # Start PlantUML diagram diagram = "@startuml\n" diagram += "!theme plain\n" diagram += "skinparam backgroundColor white\n" diagram += "skinparam componentStyle rectangle\n\n" # Title diagram += "title Water Hammer System Diagram\\n" if real_surge: diagram += f"Pressure Surge: {real_surge} | " if wave_speed: diagram += f"Wave Speed: {wave_speed} | " if critical_time: diagram += f"Critical Time: {critical_time}" diagram += "\n\n" # Define components diagram += 'rectangle "PUMP" as pump #lightblue {\n' diagram += f" Pressure: {pump_pressure} bar\\n" diagram += f" Flow: {flow_rate/1000:.1f} m³/h\n" diagram += "}\n\n" # Pipeline sections if damper_enabled: # Pipeline with damper pre_damper_length = damper_position post_damper_length = pipe_length - damper_position diagram += 'rectangle "PIPELINE" as pipe1 #lightgray {\n' diagram += f" Length: {pre_damper_length:.0f} m\\n" diagram += f" Diameter: {pipe_diameter:.0f} mm\n" diagram += "}\n\n" diagram += 'rectangle "DAMPER" as damper #orange {\n' diagram += f" Volume: {damper_volume} L\\n" diagram += f" Position: {damper_position:.0f} m\n" diagram += "}\n\n" diagram += 'rectangle "PIPELINE" as pipe2 #lightgray {\n' diagram += f" Length: {post_damper_length:.0f} m\\n" diagram += f" Diameter: {pipe_diameter:.0f} mm\n" diagram += "}\n\n" else: # Single pipeline diagram += 'rectangle "PIPELINE" as pipeline #lightgray {\n' diagram += f" Total Length: {pipe_length:.0f} m\\n" diagram += f" Diameter: {pipe_diameter:.0f} mm\n" diagram += "}\n\n" # Valve diagram += 'rectangle "VALVE" as valve #red {\n' diagram += f" Closure Time: {closure_time} s\n" diagram += "}\n\n" # Tank/Reservoir diagram += 'rectangle "TANK" as tank #lightgreen {\n' diagram += " Destination\n" diagram += "}\n\n" # Connections if damper_enabled: diagram += "pump --> pipe1 : Flow\n" diagram += "pipe1 --> damper\n" diagram += "damper --> pipe2\n" diagram += "pipe2 --> valve\n" else: diagram += "pump --> pipeline : Flow\n" diagram += "pipeline --> valve\n" diagram += "valve --> tank\n\n" # Add notes with key parameters diagram += "note top of pump\n" diagram += "System Parameters:\\n" diagram += f"• Total Length: {pipe_length} m\\n" diagram += f"• Flow Rate: {flow_rate} L/h\\n" diagram += f"• Pump Pressure: {pump_pressure} bar\n" if damper_enabled: diagram += f"• Damper: YES ({damper_position}m)\n" else: diagram += "• Damper: NO\n" diagram += "end note\n\n" if real_surge: diagram += "note bottom of valve\n" diagram += f"Results:\\n" diagram += f"• Pressure Surge: {real_surge}\\n" if wave_speed: diagram += f"• Wave Speed: {wave_speed}\\n" if critical_time: diagram += f"• Critical Time: {critical_time}\n" diagram += "end note\n\n" diagram += "@enduml" return diagram def render_diagram(self, plantuml_code): """Send PlantUML code to server and get rendered image""" if not REQUESTS_AVAILABLE: raise ImportError("requests library not available for PlantUML rendering") try: # Encode PlantUML code for URL encoded_code = self._encode_plantuml(plantuml_code) # Construct URL for PNG format url = f"{self.server_url}/png/{encoded_code}" # Request the image response = requests.get(url, timeout=30) response.raise_for_status() return response.content except Exception as e: raise Exception(f"Error rendering PlantUML diagram: {str(e)}") def _encode_plantuml(self, plantuml_code): """Encode PlantUML code for URL transmission""" import zlib import base64 # Compress the code compressed = zlib.compress(plantuml_code.encode("utf-8")) # Base64 encode with URL-safe alphabet encoded = base64.b64encode(compressed).decode("ascii") # Replace characters for URL safety encoded = encoded.replace("+", "-").replace("/", "_") return encoded class WaterHammerCalculator: """Core calculation engine for water hammer analysis""" def __init__(self): self.default_params = { "pipe_length": 300.0, # m "pipe_diameter": 0.065, # m (65mm internal diameter) "wall_thickness": 0.003, # m (3mm wall thickness) "roughness": 1.5e-6, # m (stainless steel roughness) "flow_rate": 22000.0, # L/h "pump_pressure": 7.0, # bar "fluid_density": 1100.0, # kg/m³ (syrup density) "fluid_temperature": 20.0, # °C "bulk_modulus": 2.2e9, # Pa "young_modulus": 200e9, # Pa (steel) "closure_time": 2.0, # s "damper_enabled": False, "damper_volume": 50.0, # L "damper_precharge": 4.0, # bar "damper_gas_percentage": 60.0, # % "damper_position": 280.0, # m "damper_connection_diameter": 0.05, # m (50mm diameter) "damper_connection_length": 0.5, # m "simulation_time": 10.0, # s } def calculate_system_parameters(self, params): """Calculate derived system parameters""" # Get values (dimensions already in meters) L = params["pipe_length"] # m D = params["pipe_diameter"] # m (already in meters) e = params["wall_thickness"] # m (already in meters) Q = params["flow_rate"] / 3600 / 1000 # Convert L/h to m³/s P_pump = params["pump_pressure"] # bar rho = params["fluid_density"] # kg/m³ K = params["bulk_modulus"] # Pa E = params["young_modulus"] # Pa t_close = params["closure_time"] # s # Calculate fluid velocity A = math.pi * (D / 2) ** 2 V = Q / A # Calculate wave speed (Joukowsky formula) a = math.sqrt(K / rho) / math.sqrt(1 + (K * D) / (E * e)) # Critical time t_critical = 2 * L / a # Joukowsky pressure surge delta_p_joukowsky = rho * a * V / 100000 # Convert to bar # Time reduction factor if t_close <= t_critical: time_factor = 1.0 else: time_factor = t_critical / t_close # Damper effect damper_factor = 1.0 damper_efficiency = 0.0 damper_effective_volume = 0.0 if params["damper_enabled"]: # Detailed hydropneumatic damper calculation damper_vol_total = params["damper_volume"] / 1000 # Convert L to m³ damper_p0 = params["damper_precharge"] * 100000 # Convert bar to Pa damper_gas_vol = damper_vol_total * params["damper_gas_percentage"] / 100 damper_pos = params["damper_position"] # Operating pressure P_operation = params["pump_pressure"] * 100000 # Pa # Effective gas volume considering isothermal compression # P0*V0 = P1*V1 (Boyle's Law) damper_vol_effective = (damper_gas_vol * damper_p0) / P_operation # Equivalent compressibility of damper + fluid system # C_eq = V_gas_effective / (V_pipe * K_fluid) V_pipe = A * L damper_compressibility = damper_vol_effective / (V_pipe * K / P_operation) # Reduction factor based on effective compressibility # Simplified equation for hydropneumatic damper C_total = 1 / K + damper_compressibility C_original = 1 / K compressibility_factor = C_original / C_total # Efficiency based on damper position normalized_distance = damper_pos / L position_efficiency = 1.0 - 0.3 * abs(normalized_distance - 0.9) # Final damper factor damper_factor = 1.0 - (1.0 - compressibility_factor) * position_efficiency # Store damper results damper_efficiency = (1.0 - damper_factor) * 100 # Percentage damper_effective_volume = damper_vol_effective * 1000 # L # Total reduction factor total_factor = time_factor * damper_factor # Real pressure surge delta_p_real = delta_p_joukowsky * total_factor return { "fluid_velocity": V, "wave_speed": a, "critical_time": t_critical, "joukowsky_surge": delta_p_joukowsky, "time_factor": time_factor, "damper_factor": damper_factor, "total_factor": total_factor, "real_surge": delta_p_real, "damper_efficiency": damper_efficiency, "damper_effective_volume": damper_effective_volume, } def simulate_transient(self, params, results): """Generate transient simulation data""" t_sim = params["simulation_time"] dt = 0.01 # Time step times = np.arange(0, t_sim, dt) # System parameters P0 = params["pump_pressure"] Q0 = params["flow_rate"] / 3600 / 1000 # m³/s t_close = params["closure_time"] t_critical = results["critical_time"] delta_p_real = results["real_surge"] # Generate pressure and flow profiles pressures_start = [] pressures_mid = [] pressures_end = [] flows = [] for t in times: # Valve closure profile if t <= t_close: closure_factor = 1 - (t / t_close) else: closure_factor = 0 # Flow rate Q_t = Q0 * closure_factor flows.append(Q_t * 3600 * 1000) # Convert back to L/h # Pressure oscillations # Simplified model - in reality this would be much more complex if t <= t_close: # During closure P_surge = P0 + delta_p_real * (t / t_close) else: # After closure - oscillations phase = (t - t_close) * 2 * math.pi / t_critical decay = math.exp(-(t - t_close) / (5 * t_critical)) # Damping P_surge = P0 + delta_p_real * decay * math.cos(phase) # Pressure at different points pressures_start.append(max(0, P_surge)) # Slightly lower at midpoint pressures_mid.append(max(0, P_surge * 0.8)) pressures_end.append(max(0, P_surge * 0.6)) # Lower at end return { "times": times.tolist(), "pressures_start": pressures_start, "pressures_mid": pressures_mid, "pressures_end": pressures_end, "flows": flows, } class DataManager: """Manage persistent data storage""" def __init__(self, data_dir, project_id, project_name): # Ensure data_dir is an absolute path self.data_dir = os.path.abspath(data_dir) self.project_id = project_id self.project_name = project_name self.config_file = os.path.join(self.data_dir, "hammer_config.json") self.results_file = os.path.join(self.data_dir, "hammer_results.json") # Ensure data directory exists os.makedirs(self.data_dir, exist_ok=True) def load_config(self): """Load configuration from file""" if os.path.exists(self.config_file): try: with open(self.config_file, "r") as f: return json.load(f) except Exception as e: logging.warning(f"Error loading config: {e}") return {} def save_config(self, config): """Save configuration to file""" try: with open(self.config_file, "w") as f: json.dump(config, f, indent=2) except Exception as e: logging.error(f"Error saving config: {e}") def save_results(self, results): """Save calculation results""" try: with open(self.results_file, "w") as f: json.dump(results, f, indent=2) except Exception as e: logging.error(f"Error saving results: {e}") class ExportManager: """Manage export functionality for reports and visualizations""" def __init__(self, data_dir, project_id, project_name, execution_logger): # Ensure data_dir is an absolute path self.data_dir = os.path.abspath(data_dir) self.project_id = project_id self.project_name = project_name self.logger = execution_logger self.exports_dir = os.path.join(self.data_dir, "exports") os.makedirs(self.exports_dir, exist_ok=True) def generate_plots(self, params, results): """Generate matplotlib plots for export""" if not MATPLOTLIB_AVAILABLE: raise ImportError("matplotlib is required for plot generation") plots = {} # Configure matplotlib for better PDF output plt.style.use("default") plt.rcParams["figure.dpi"] = 150 plt.rcParams["savefig.dpi"] = 300 plt.rcParams["font.size"] = 10 # Plot 1: Pressure vs Time fig1, ax1 = plt.subplots(figsize=(10, 6)) simulation = results["simulation"] times = simulation["times"] ax1.plot( times, simulation["pressures_start"], "b-", linewidth=2, label="Pipe Start" ) ax1.plot( times, simulation["pressures_mid"], "g-", linewidth=2, label="Pipe Middle" ) ax1.plot( times, simulation["pressures_end"], "r-", linewidth=2, label="Pipe End" ) ax1.set_xlabel("Time (s)") ax1.set_ylabel("Pressure (bar)") ax1.set_title("Water Hammer Pressure Transient Analysis") ax1.grid(True, alpha=0.3) ax1.legend() # Add horizontal line for initial pressure ax1.axhline( y=params["pump_pressure"], color="gray", linestyle="--", alpha=0.7, label="Initial Pressure", ) plots["pressure_plot"] = fig1 # Plot 2: Flow Rate vs Time fig2, ax2 = plt.subplots(figsize=(10, 6)) ax2.plot(times, simulation["flows"], "purple", linewidth=2) ax2.set_xlabel("Time (s)") ax2.set_ylabel("Flow Rate (L/h)") ax2.set_title("Flow Rate During Valve Closure") ax2.grid(True, alpha=0.3) plots["flow_plot"] = fig2 # Plot 3: System Parameters Summary fig3, ((ax3a, ax3b), (ax3c, ax3d)) = plt.subplots(2, 2, figsize=(12, 8)) # Wave speed comparison parameters = results["parameters"] ax3a.bar( ["Fluid Velocity", "Wave Speed"], [parameters["fluid_velocity"], parameters["wave_speed"]], color=["blue", "red"], alpha=0.7, ) ax3a.set_ylabel("Velocity (m/s)") ax3a.set_title("Fluid vs Wave Velocity") # Pressure components pressures = ["Initial", "Joukowsky", "Real Surge"] pressure_values = [ params["pump_pressure"], parameters["joukowsky_surge"], parameters["real_surge"], ] ax3b.bar( pressures, pressure_values, color=["green", "orange", "red"], alpha=0.7 ) ax3b.set_ylabel("Pressure (bar)") ax3b.set_title("Pressure Analysis") # Time factors factors = ["Time Factor", "Damper Factor", "Total Factor"] factor_values = [ parameters["time_factor"], parameters["damper_factor"], parameters["total_factor"], ] ax3c.bar(factors, factor_values, color=["blue", "purple", "black"], alpha=0.7) ax3c.set_ylabel("Reduction Factor") ax3c.set_title("Mitigation Factors") ax3c.set_ylim(0, 1.1) # Damper effectiveness (if enabled) if params.get("damper_enabled", False): damper_data = ["Efficiency (%)", "Effective Vol (L)"] damper_values = [ parameters["damper_efficiency"], parameters["damper_effective_volume"], ] ax3d.bar(damper_data, damper_values, color=["cyan", "magenta"], alpha=0.7) ax3d.set_title("Damper Performance") else: ax3d.text( 0.5, 0.5, "No Damper\nConfigured", horizontalalignment="center", verticalalignment="center", transform=ax3d.transAxes, fontsize=12, ) ax3d.set_title("Damper Performance") plt.tight_layout() plots["summary_plot"] = fig3 return plots def save_plot_as_image(self, plot, filename): """Save a matplotlib plot as PNG image""" filepath = os.path.join(self.exports_dir, f"{filename}.png") plot.savefig( filepath, dpi=300, bbox_inches="tight", facecolor="white", edgecolor="none" ) plt.close(plot) # Free memory return filepath def export_to_pdf(self, params, results): """Export complete analysis to PDF""" if not REPORTLAB_AVAILABLE: raise ImportError("reportlab is required for PDF generation") if not MATPLOTLIB_AVAILABLE: raise ImportError("matplotlib is required for plot generation") timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") pdf_filename = f"hammer_analysis_{timestamp}.pdf" pdf_path = os.path.join(self.exports_dir, pdf_filename) # Ensure the exports directory exists os.makedirs(self.exports_dir, exist_ok=True) # Generate plots plots = self.generate_plots(params, results) # Ensure the parent directory of the PDF file exists pdf_dir = os.path.dirname(pdf_path) os.makedirs(pdf_dir, exist_ok=True) # Create PDF document doc = SimpleDocTemplate(pdf_path, pagesize=A4) styles = getSampleStyleSheet() story = [] # Title title_style = ParagraphStyle( "CustomTitle", parent=styles["Heading1"], fontSize=18, spaceAfter=30, alignment=1, # Center alignment ) story.append(Paragraph("Water Hammer Analysis Report", title_style)) story.append(Spacer(1, 20)) # Project information story.append(Paragraph("Project Information", styles["Heading2"])) project_info = f""" Project ID: {self.project_id}
Analysis Date: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
Execution ID: {self.logger.execution_id}
""" story.append(Paragraph(project_info, styles["Normal"])) story.append(Spacer(1, 20)) # System Parameters story.append(Paragraph("System Parameters", styles["Heading2"])) params_table_data = [ ["Parameter", "Value", "Unit"], ["Pipe Length", f"{params['pipe_length']:.1f}", "m"], ["Pipe Diameter", f"{params['pipe_diameter']*1000:.1f}", "mm"], ["Wall Thickness", f"{params['wall_thickness']*1000:.1f}", "mm"], ["Flow Rate", f"{params['flow_rate']:.0f}", "L/h"], ["Pump Pressure", f"{params['pump_pressure']:.1f}", "bar"], ["Fluid Density", f"{params['fluid_density']:.0f}", "kg/m³"], ["Closure Time", f"{params['closure_time']:.1f}", "s"], [ "Damper Enabled", "Yes" if params.get("damper_enabled", False) else "No", "", ], ] params_table = Table(params_table_data) params_table.setStyle( TableStyle( [ ("BACKGROUND", (0, 0), (-1, 0), colors.grey), ("TEXTCOLOR", (0, 0), (-1, 0), colors.whitesmoke), ("ALIGN", (0, 0), (-1, -1), "CENTER"), ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), ("FONTSIZE", (0, 0), (-1, 0), 12), ("BOTTOMPADDING", (0, 0), (-1, 0), 12), ("BACKGROUND", (0, 1), (-1, -1), colors.beige), ("GRID", (0, 0), (-1, -1), 1, colors.black), ] ) ) story.append(params_table) story.append(Spacer(1, 20)) # Results Summary story.append(Paragraph("Analysis Results", styles["Heading2"])) parameters = results["parameters"] results_table_data = [ ["Result", "Value", "Unit"], ["Fluid Velocity", f"{parameters['fluid_velocity']:.2f}", "m/s"], ["Wave Speed", f"{parameters['wave_speed']:.1f}", "m/s"], ["Critical Time", f"{parameters['critical_time']:.2f}", "s"], ["Joukowsky Surge", f"{parameters['joukowsky_surge']:.1f}", "bar"], ["Real Surge", f"{parameters['real_surge']:.1f}", "bar"], ["Total Reduction Factor", f"{parameters['total_factor']:.3f}", ""], ] if params.get("damper_enabled", False): results_table_data.extend( [ [ "Damper Efficiency", f"{parameters['damper_efficiency']:.1f}", "%", ], [ "Effective Volume", f"{parameters['damper_effective_volume']:.1f}", "L", ], ] ) results_table = Table(results_table_data) results_table.setStyle( TableStyle( [ ("BACKGROUND", (0, 0), (-1, 0), colors.grey), ("TEXTCOLOR", (0, 0), (-1, 0), colors.whitesmoke), ("ALIGN", (0, 0), (-1, -1), "CENTER"), ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), ("FONTSIZE", (0, 0), (-1, 0), 12), ("BOTTOMPADDING", (0, 0), (-1, 0), 12), ("BACKGROUND", (0, 1), (-1, -1), colors.beige), ("GRID", (0, 0), (-1, -1), 1, colors.black), ] ) ) story.append(results_table) story.append(Spacer(1, 30)) # Add plots to PDF story.append(Paragraph("Analysis Plots", styles["Heading2"])) for plot_name, plot in plots.items(): # Save plot as temporary image temp_image_path = self.save_plot_as_image(plot, f"temp_{plot_name}") # Add image to PDF img = RLImage(temp_image_path, width=7 * inch, height=4 * inch) story.append(img) story.append(Spacer(1, 20)) # Build PDF doc.build(story) # Clean up temporary images for file in os.listdir(self.exports_dir): if file.startswith("temp_") and file.endswith(".png"): os.remove(os.path.join(self.exports_dir, file)) self.logger.log_user_action("exported_pdf", pdf_filename) return pdf_path def export_to_obsidian_zip(self, params, results): """Export as Obsidian-style Markdown with attached images in ZIP""" try: timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") zip_filename = f"hammer_analysis_obsidian_{timestamp}.zip" zip_path = os.path.join(self.exports_dir, zip_filename) # Debug logging to understand path construction self.logger.log_info(f"DEBUG: exports_dir = {self.exports_dir}") self.logger.log_info(f"DEBUG: zip_path = {zip_path}") # Ensure the exports directory exists os.makedirs(self.exports_dir, exist_ok=True) # Generate plots and save as images plots = self.generate_plots(params, results) image_paths = {} for plot_name, plot in plots.items(): image_filename = f"{plot_name}_{timestamp}.png" image_path = self.save_plot_as_image( plot, image_filename.replace(".png", "") ) image_paths[plot_name] = image_filename # Generate Markdown content md_content = self._generate_markdown_content( params, results, image_paths, timestamp ) # Create ZIP file # Ensure the parent directory of the ZIP file exists zip_dir = os.path.dirname(zip_path) os.makedirs(zip_dir, exist_ok=True) with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zipf: # Add markdown file md_filename = f"Water_Hammer_Analysis_{timestamp}.md" zipf.writestr(md_filename, md_content) # Add image files attachments_dir = "attachments" for plot_name, image_filename in image_paths.items(): actual_image_path = os.path.join(self.exports_dir, image_filename) if os.path.exists(actual_image_path): zipf.write( actual_image_path, f"{attachments_dir}/{image_filename}" ) # Clean up individual image file - temporarily disabled for debugging # os.remove(actual_image_path) self.logger.log_user_action("exported_obsidian_zip", zip_filename) return zip_path except Exception as e: self.logger.log_error(f"Error during Obsidian export: {str(e)}") # If the file was created despite the error, return it anyway if ( "zip_path" in locals() and os.path.exists(zip_path) and os.path.getsize(zip_path) > 0 ): self.logger.log_info(f"Export file created successfully despite error") return zip_path else: raise e def _generate_markdown_content(self, params, results, image_paths, timestamp): """Generate Markdown content for Obsidian export""" md_content = f"""# Water Hammer Analysis Report **Generated on:** {datetime.now().strftime("%Y-%m-%d %H:%M:%S")} **Project:** {self.project_name} **Project ID:** {self.project_id} **Execution ID:** {self.logger.execution_id} --- ## System Parameters | Parameter | Value | Unit | |-----------|-------|------| | Pipe Length | {params['pipe_length']:.1f} | m | | Pipe Diameter | {params['pipe_diameter']*1000:.1f} | mm | | Wall Thickness | {params['wall_thickness']*1000:.1f} | mm | | Flow Rate | {params['flow_rate']:.0f} | L/h | | Pump Pressure | {params['pump_pressure']:.1f} | bar | | Fluid Density | {params['fluid_density']:.0f} | kg/m³ | | Fluid Temperature | {params['fluid_temperature']:.1f} | °C | | Closure Time | {params['closure_time']:.1f} | s | | Damper Enabled | {'Yes' if params.get('damper_enabled', False) else 'No'} | | ### Damper Configuration """ if params.get("damper_enabled", False): md_content += f""" | Parameter | Value | Unit | |-----------|-------|------| | Damper Volume | {params['damper_volume']:.1f} | L | | Precharge Pressure | {params['damper_precharge']:.1f} | bar | | Gas Percentage | {params['damper_gas_percentage']:.1f} | % | | Position | {params['damper_position']:.1f} | m | | Connection Diameter | {params['damper_connection_diameter']*1000:.1f} | mm | """ else: md_content += "\n*No damper configured for this analysis.*\n" md_content += f""" --- ## Analysis Results ### Key Parameters | Result | Value | Unit | |--------|-------|------| | Fluid Velocity | {results['parameters']['fluid_velocity']:.2f} | m/s | | Wave Speed | {results['parameters']['wave_speed']:.1f} | m/s | | Critical Time | {results['parameters']['critical_time']:.2f} | s | | Joukowsky Surge | {results['parameters']['joukowsky_surge']:.1f} | bar | | **Real Surge** | **{results['parameters']['real_surge']:.1f}** | **bar** | | Time Factor | {results['parameters']['time_factor']:.3f} | | | Damper Factor | {results['parameters']['damper_factor']:.3f} | | | **Total Factor** | **{results['parameters']['total_factor']:.3f}** | | """ if params.get("damper_enabled", False): md_content += f""" ### Damper Performance | Metric | Value | Unit | |--------|-------|------| | Damper Efficiency | {results['parameters']['damper_efficiency']:.1f} | % | | Effective Volume | {results['parameters']['damper_effective_volume']:.1f} | L | """ md_content += f""" --- ## Visualization ### Pressure Transient Analysis ![[attachments/{image_paths['pressure_plot']}]] *Pressure evolution at different points along the pipeline during the transient event.* ### Flow Rate Evolution ![[attachments/{image_paths['flow_plot']}]] *Flow rate variation during valve closure operation.* ### System Parameters Summary ![[attachments/{image_paths['summary_plot']}]] *Comprehensive summary of system parameters, pressure components, and mitigation factors.* --- ## Safety Assessment ### Risk Evaluation """ surge = results["parameters"]["real_surge"] if surge > 20: risk_level = "🔴 **HIGH RISK**" recommendations = [ "Immediate action required", "Consider hydropneumatic damper installation", "Implement slower valve closure", "Review system design", ] elif surge > 10: risk_level = "🟡 **MEDIUM RISK**" recommendations = [ "Monitoring recommended", "Consider protection measures", "Evaluate damper installation", ] else: risk_level = "🟢 **LOW RISK**" recommendations = [ "System within acceptable limits", "Continue regular monitoring", ] md_content += f""" **Overall Risk Level:** {risk_level} ### Recommendations """ for rec in recommendations: md_content += f"- {rec}\n" if results["parameters"]["time_factor"] > 0.8: md_content += "- Consider slower valve closure to reduce surge\n" if not params.get("damper_enabled", False) and surge > 5: md_content += ( "- Consider installing hydropneumatic damper for surge protection\n" ) md_content += f""" --- ## Technical Notes ### Calculation Method - **Wave Speed:** Joukowsky formula with pipe elasticity correction - **Pressure Surge:** Modified Joukowsky equation with time and damper factors - **Damper Analysis:** Isothermal gas compression model - **Transient Simulation:** Simplified waterhammer equations ### Assumptions - Isothermal process for gas compression - Uniform pipe properties - Incompressible fluid (except for wave propagation) - Instantaneous valve operation (linear closure) ### References - Wylie, E.B. & Streeter, V.L. "Fluid Transients in Systems" - Watters, G.Z. "Analysis and Control of Unsteady Flow in Pipelines" --- *Report generated by SIDEL Water Hammer Simulator v1.0* *Timestamp: {timestamp}* """ return md_content def create_flask_app( data_manager, user_level, project_id, project_name, port, user_id, execution_logger, session_manager, theme="light", language="en", ): """Create and configure Flask application""" # Get the directory of this script for static files script_dir = os.path.dirname(os.path.abspath(__file__)) app = Flask( __name__, static_folder=os.path.join(script_dir, "static"), template_folder=os.path.join(script_dir, "templates"), ) app.secret_key = f"hammer_sim_{project_id}_{port}" # Initialize SocketIO for real-time logging socketio = SocketIO(app, cors_allowed_origins="*") # Initialize calculator and export manager calculator = WaterHammerCalculator() export_manager = ExportManager( data_manager.data_dir, project_id, project_name, execution_logger ) execution_logger.log_info("Flask application initialized") execution_logger.log_info(f"Static folder: {app.static_folder}") execution_logger.log_info(f"Template folder: {app.template_folder}") @app.route("/") def index(): """Main application page""" execution_logger.log_user_action("accessed_main_page") session_manager.update_activity() return render_template( "hammer_simulator.html", user_level=user_level, project_id=project_id, project_name=project_name, user_id=user_id, execution_id=execution_logger.execution_id, theme=theme, language=language, ) @app.route("/api/parameters", methods=["GET"]) def get_parameters(): """Get current parameters""" execution_logger.log_user_action("requested_parameters") session_manager.update_activity() saved_config = data_manager.load_config() params = calculator.default_params.copy() params.update(saved_config) execution_logger.log_info(f"Returned {len(params)} parameters") return jsonify(params) @app.route("/api/parameters", methods=["POST"]) def save_parameters(): """Save parameters""" params = request.get_json() execution_logger.log_user_action( "saved_parameters", f"Updated {len(params)} parameters" ) session_manager.update_activity() data_manager.save_config(params) execution_logger.log_info("Parameters saved successfully") return jsonify({"status": "success"}) @app.route("/api/calculate", methods=["POST"]) def calculate(): """Perform water hammer calculations""" params = request.get_json() execution_logger.log_user_action("started_calculation") session_manager.update_activity() try: # Ensure all required parameters have default values default_params = calculator.default_params.copy() default_params.update(params) # Calculate system parameters results = calculator.calculate_system_parameters(default_params) # Generate transient simulation simulation_data = calculator.simulate_transient(default_params, results) # Combine results full_results = { "parameters": results, "simulation": simulation_data, "timestamp": datetime.now().isoformat(), "execution_id": execution_logger.execution_id, "user_id": user_id, "project_id": project_id, } # Log calculation execution_logger.log_calculation(default_params, full_results) # Save results data_manager.save_results(full_results) execution_logger.log_user_action( "completed_calculation", "Calculation successful" ) # Emit real-time update via WebSocket socketio.emit( "calculation_complete", { "execution_id": execution_logger.execution_id, "timestamp": datetime.now().isoformat(), "status": "success", }, ) return jsonify(full_results) except Exception as e: execution_logger.log_error(f"Calculation failed: {str(e)}") execution_logger.log_user_action("calculation_failed", str(e)) # Emit error via WebSocket socketio.emit( "calculation_error", { "execution_id": execution_logger.execution_id, "error": str(e), "timestamp": datetime.now().isoformat(), }, ) return jsonify({"error": str(e)}), 500 @app.route("/api/presets/", methods=["GET"]) def get_preset(preset_name): """Get predefined parameter presets""" presets = { "rapid_closure": {"closure_time": 0.5, "damper_enabled": False}, "slow_closure": {"closure_time": 10.0, "damper_enabled": False}, "with_damper": { "closure_time": 2.0, "damper_enabled": True, "damper_volume": 100.0, "damper_precharge": 5.0, }, "critical_system": { "closure_time": 0.2, "pump_pressure": 15.0, "flow_rate": 40000.0, }, } if preset_name in presets: return jsonify(presets[preset_name]) else: return jsonify({"error": "Preset not found"}), 404 @app.route("/api/safety_evaluation", methods=["POST"]) def safety_evaluation(): """Evaluate system safety""" results = request.get_json() evaluation = {"overall_risk": "low", "recommendations": [], "warnings": []} # Check pressure surge surge = results["parameters"]["real_surge"] if surge > 20: evaluation["overall_risk"] = "high" evaluation["warnings"].append("Extremely high pressure surge detected") elif surge > 10: evaluation["overall_risk"] = "medium" evaluation["warnings"].append( "High pressure surge - consider protection measures" ) # Check closure time if results["parameters"]["time_factor"] > 0.8: evaluation["recommendations"].append("Consider slower valve closure") # Damper recommendations if not results.get("damper_enabled", False) and surge > 5: evaluation["recommendations"].append( "Consider installing hydropneumatic damper" ) return jsonify(evaluation) @app.route("/api/generate_diagram", methods=["POST"]) def generate_diagram(): """Generate system diagram using PlantUML""" try: data = request.get_json() params = data.get("params", {}) results = data.get("results", {}) execution_logger.log_user_action("generate_diagram_requested") session_manager.update_activity() # Check if requests library is available if not REQUESTS_AVAILABLE: return ( jsonify( { "error": "PlantUML integration not available - " + "requests library missing" } ), 500, ) # Create PlantUML generator plantuml_generator = PlantUMLGenerator() # Generate PlantUML code plantuml_code = plantuml_generator.generate_system_diagram(params, results) # Try to render the diagram try: image_bytes = plantuml_generator.render_diagram(plantuml_code) # Convert to base64 for sending to client image_base64 = base64.b64encode(image_bytes).decode("utf-8") execution_logger.log_user_action("diagram_generated_successfully") return jsonify( { "success": True, "plantuml_code": plantuml_code, "image_base64": image_base64, "image_format": "png", } ) except Exception as render_error: # If rendering fails, still return the PlantUML code execution_logger.log_error( f"Diagram rendering failed: {str(render_error)}" ) return jsonify( { "success": False, "plantuml_code": plantuml_code, "error": f"Rendering failed: {str(render_error)}", "fallback": True, } ) except Exception as e: execution_logger.log_error(f"Diagram generation failed: {str(e)}") return jsonify({"error": f"Diagram generation failed: {str(e)}"}), 500 @app.route("/api/export/pdf", methods=["POST"]) def export_pdf(): """Export analysis results to PDF""" try: data = request.get_json() params = data.get("params", {}) results = data.get("results", {}) execution_logger.log_user_action("export_pdf_requested") session_manager.update_activity() if not REPORTLAB_AVAILABLE or not MATPLOTLIB_AVAILABLE: missing_libs = [] if not REPORTLAB_AVAILABLE: missing_libs.append("reportlab") if not MATPLOTLIB_AVAILABLE: missing_libs.append("matplotlib") return ( jsonify( { "error": f"Missing required libraries: {', '.join(missing_libs)}", "details": "Please install: pip install reportlab matplotlib", } ), 400, ) pdf_path = export_manager.export_to_pdf(params, results) pdf_filename = os.path.basename(pdf_path) execution_logger.log_info(f"PDF exported successfully: {pdf_filename}") return send_file( pdf_path, as_attachment=True, download_name=pdf_filename, mimetype="application/pdf", ) except Exception as e: execution_logger.log_error(f"PDF export failed: {str(e)}") return jsonify({"error": str(e)}), 500 @app.route("/api/export/obsidian", methods=["POST"]) def export_obsidian(): """Export analysis results as Obsidian-style ZIP""" try: data = request.get_json() params = data.get("params", {}) results = data.get("results", {}) execution_logger.log_user_action("export_obsidian_requested") session_manager.update_activity() if not MATPLOTLIB_AVAILABLE: return ( jsonify( { "error": "Missing required library: matplotlib", "details": "Please install: pip install matplotlib", } ), 400, ) try: zip_path = export_manager.export_to_obsidian_zip(params, results) except Exception as export_error: # Log the error but check if file was created anyway execution_logger.log_error( f"Export method threw error: {str(export_error)}" ) # Try to find the most recent export file as fallback import glob export_pattern = os.path.join( export_manager.exports_dir, "hammer_analysis_obsidian_*.zip" ) export_files = glob.glob(export_pattern) if export_files: # Get the most recent file zip_path = max(export_files, key=os.path.getctime) execution_logger.log_info( f"Using most recent export file despite error: {os.path.basename(zip_path)}" ) else: # No files found, this is a real failure raise export_error zip_filename = os.path.basename(zip_path) # Verify the file was actually created successfully if os.path.exists(zip_path) and os.path.getsize(zip_path) > 0: execution_logger.log_info( f"Obsidian ZIP exported successfully: {zip_filename}" ) return send_file( zip_path, as_attachment=True, download_name=zip_filename, mimetype="application/zip", ) else: execution_logger.log_error( f"Export file not created or empty: {zip_path}" ) return ( jsonify({"error": "Export file was not created successfully"}), 500, ) except Exception as e: execution_logger.log_error(f"Obsidian export failed: {str(e)}") return jsonify({"error": str(e)}), 500 @app.route("/api/export/capabilities", methods=["GET"]) def export_capabilities(): """Get available export capabilities""" capabilities = { "pdf_available": REPORTLAB_AVAILABLE and MATPLOTLIB_AVAILABLE, "obsidian_available": MATPLOTLIB_AVAILABLE, "missing_dependencies": [], } if not MATPLOTLIB_AVAILABLE: capabilities["missing_dependencies"].append("matplotlib") if not REPORTLAB_AVAILABLE: capabilities["missing_dependencies"].append("reportlab") return jsonify(capabilities) @app.route("/api/status", methods=["GET"]) def get_status(): """Get script status""" execution_logger.log_user_action("requested_status") session_manager.update_activity() return jsonify( { "status": "running", "user_level": user_level, "user_id": user_id, "project_id": project_id, "project_name": project_name, "port": port, "execution_id": execution_logger.execution_id, "session_id": session_manager.session_id, "uptime_seconds": ( datetime.now() - session_manager.start_time ).total_seconds(), "timestamp": datetime.now().isoformat(), } ) @app.route("/api/ping", methods=["POST"]) def ping_interface(): """Keep interface alive (heartbeat from browser tab)""" session_manager.update_activity() session["last_ping"] = datetime.now().isoformat() # Log periodic heartbeat (less frequently to avoid log spam) if ( not hasattr(ping_interface, "last_log") or (datetime.now() - ping_interface.last_log).seconds > 300 ): # Log every 5 minutes execution_logger.log_session_event("heartbeat_received") ping_interface.last_log = datetime.now() return jsonify( { "status": "alive", "session_active": not session_manager.is_expired(), "timestamp": datetime.now().isoformat(), } ) @app.route("/api/shutdown", methods=["POST"]) def shutdown(): """Graceful shutdown endpoint""" execution_logger.log_session_event("shutdown_requested") execution_logger.log_info("Graceful shutdown initiated") # Cleanup session session_manager.cleanup() # Finalize execution logging execution_logger.finalize_execution(0) return jsonify({"status": "shutting_down"}) @app.route("/api/logs/current", methods=["GET"]) def get_current_logs(): """Get current execution logs""" execution_logger.log_user_action("requested_current_logs") session_manager.update_activity() try: if os.path.exists(execution_logger.log_file): with open(execution_logger.log_file, "r", encoding="utf-8") as f: logs = f.read() return jsonify( { "logs": logs, "execution_id": execution_logger.execution_id, "log_file": execution_logger.log_file, } ) else: return jsonify( {"logs": "", "execution_id": execution_logger.execution_id} ) except Exception as e: execution_logger.log_error(f"Error reading logs: {str(e)}") return jsonify({"error": str(e)}), 500 # WebSocket events @socketio.on("connect") def handle_connect(): execution_logger.log_session_event("websocket_connected") emit("connected", {"execution_id": execution_logger.execution_id}) @socketio.on("disconnect") def handle_disconnect(): execution_logger.log_session_event("websocket_disconnected") @socketio.on("request_log_stream") def handle_log_stream(): """Handle request for log streaming""" execution_logger.log_user_action("requested_log_stream") # Send current logs if os.path.exists(execution_logger.log_file): with open(execution_logger.log_file, "r", encoding="utf-8") as f: logs = f.read() emit( "log_update", {"logs": logs, "execution_id": execution_logger.execution_id}, ) return app, socketio def open_browser(port): """Open browser tab after Flask server starts""" time.sleep(1) # Wait for Flask to fully start url = f"http://127.0.0.1:{port}" webbrowser.open_new_tab(url) logging.info(f"Opened browser tab: {url}") def main(): """Main function""" # Parse arguments args = parse_arguments() # Setup basic logging logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", ) # Initialize execution logger execution_logger = ExecutionLogger( args.data_dir, args.user_id, args.project_id, "hammer_simulator.py" ) # Initialize session manager session_manager = SessionManager(args.user_id, args.project_id, execution_logger) try: # Initialize data manager data_manager = DataManager(args.data_dir, args.project_id, args.project_name) execution_logger.log_info("Data manager initialized") # Create Flask app with all components app, socketio = create_flask_app( data_manager, args.user_level, args.project_id, args.project_name, args.port, args.user_id, execution_logger, session_manager, args.theme, args.language, ) execution_logger.log_info("Flask application created successfully") # Note: Browser opening is now handled by ScriptsManager frontend # to ensure it opens in the same browser window/tab context # browser_thread = threading.Thread(target=open_browser, args=(args.port,)) # browser_thread.daemon = True # browser_thread.start() # execution_logger.log_info("Browser opening thread started") # Log startup completion execution_logger.log_info( f"Starting Water Hammer Simulator for project: {args.project_name}" ) execution_logger.log_info(f"Running on port {args.port}") execution_logger.log_session_event("application_started") # Run Flask app with SocketIO # Clear any existing Werkzeug environment variables to avoid conflicts if "WERKZEUG_SERVER_FD" in os.environ: del os.environ["WERKZEUG_SERVER_FD"] if "WERKZEUG_RUN_MAIN" in os.environ: del os.environ["WERKZEUG_RUN_MAIN"] execution_logger.log_info("About to start SocketIO server") socketio.run( app, host="0.0.0.0", port=args.port, debug=False, allow_unsafe_werkzeug=True, use_reloader=False, # Disable reloader to avoid fd conflicts ) execution_logger.log_info("SocketIO server ended") except KeyboardInterrupt: execution_logger.log_session_event("interrupted_by_user") execution_logger.finalize_execution(1) session_manager.cleanup() except Exception as e: execution_logger.log_error(f"Application error: {str(e)}") execution_logger.finalize_execution(1) session_manager.cleanup() raise if __name__ == "__main__": main()