SIDEL_ScriptsManager/app/backend/script_groups/hammer/hammer_simulator.py

1744 lines
62 KiB
Python

"""
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"""
<b>Project ID:</b> {self.project_id}<br/>
<b>Analysis Date:</b> {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}<br/>
<b>Execution ID:</b> {self.logger.execution_id}<br/>
"""
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/<preset_name>", 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"]
socketio.run(
app,
host="127.0.0.1",
port=args.port,
debug=False,
allow_unsafe_werkzeug=True,
use_reloader=False, # Disable reloader to avoid fd conflicts
)
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()