Spaces:
Running
Running
update the language of the report
Browse files
backend/app/models/report.py
CHANGED
|
@@ -43,6 +43,7 @@ class ScenarioReportData(BaseModel):
|
|
| 43 |
|
| 44 |
class ReportRequest(BaseModel):
|
| 45 |
"""Input for report generation."""
|
|
|
|
| 46 |
# Project metadata
|
| 47 |
project_notes: str = ""
|
| 48 |
pinch_notes: str = ""
|
|
|
|
| 43 |
|
| 44 |
class ReportRequest(BaseModel):
|
| 45 |
"""Input for report generation."""
|
| 46 |
+
language: str = "en"
|
| 47 |
# Project metadata
|
| 48 |
project_notes: str = ""
|
| 49 |
pinch_notes: str = ""
|
backend/app/services/report_service.py
CHANGED
|
@@ -12,6 +12,85 @@ from app.models.report import ReportRequest, ScenarioReportData, ReportStream
|
|
| 12 |
from app.services.map_service import generate_process_map_b64
|
| 13 |
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
def _fmt(val: Any, decimals: int = 2) -> str:
|
| 16 |
"""Format a numeric value, return '' if None."""
|
| 17 |
if val is None or val == '':
|
|
@@ -31,15 +110,17 @@ def _render_pinch_content(
|
|
| 31 |
heat_pumps: List[Any],
|
| 32 |
energy_demands: List[Any],
|
| 33 |
map_streams_img_html: str,
|
|
|
|
| 34 |
include_plotly_js: bool = False
|
| 35 |
) -> str:
|
| 36 |
"""Helper to render the detailed analysis content for a single scenario."""
|
|
|
|
| 37 |
|
| 38 |
if not pinch_result or len(selected_streams) < 2:
|
| 39 |
return f"""
|
| 40 |
<div id="{id_slug}" class="page-content">
|
| 41 |
-
<h2>📊
|
| 42 |
-
<p><em>
|
| 43 |
</div>
|
| 44 |
"""
|
| 45 |
|
|
@@ -50,26 +131,27 @@ def _render_pinch_content(
|
|
| 50 |
# Streams table
|
| 51 |
rows = ""
|
| 52 |
for s in selected_streams:
|
| 53 |
-
|
| 54 |
-
|
|
|
|
| 55 |
rows += (
|
| 56 |
f"<tr><td>{s.name}</td><td>{s.Tin:.1f}</td><td>{s.Tout:.1f}</td>"
|
| 57 |
f"<td>{s.CP:.2f}</td><td>{s.Q:.2f}</td>"
|
| 58 |
f"<td><span class='badge {badge_cls}'>{badge_lbl}</span></td></tr>\n"
|
| 59 |
)
|
| 60 |
streams_table = (
|
| 61 |
-
"<table class='data-table'><thead><tr>"
|
| 62 |
-
"<th>
|
| 63 |
-
"</tr></thead><tbody>" + rows + "</tbody></table>"
|
| 64 |
)
|
| 65 |
|
| 66 |
# Metrics
|
| 67 |
metrics_html = f"""
|
| 68 |
<div class="metrics-row">
|
| 69 |
-
<div class="metric-card hot"><div class="metric-label">
|
| 70 |
-
<div class="metric-card cold"><div class="metric-label">
|
| 71 |
-
<div class="metric-card pinch"><div class="metric-label">
|
| 72 |
-
<div class="metric-card recovery"><div class="metric-label">
|
| 73 |
</div>"""
|
| 74 |
|
| 75 |
# Comparison table (Status Quo vs Proposal)
|
|
@@ -87,22 +169,22 @@ def _render_pinch_content(
|
|
| 87 |
if best and best.q_sink is not None and best.q_source is not None:
|
| 88 |
hc_heat = min(best.q_sink, pr.hot_utility)
|
| 89 |
hc_cool = min(best.q_source, pr.cold_utility)
|
| 90 |
-
hp_hdr =
|
| 91 |
hc_h_s = f"{hc_heat:.1f}"
|
| 92 |
hc_c_s = f"{hc_cool:.1f}"
|
| 93 |
hc_h_p = f"{hc_heat/pr.hot_utility*100:.1f}%" if pr.hot_utility > 0 else "N/A"
|
| 94 |
hc_c_p = f"{hc_cool/pr.cold_utility*100:.1f}%" if pr.cold_utility > 0 else "N/A"
|
| 95 |
else:
|
| 96 |
-
hp_hdr = "
|
| 97 |
hc_h_s = hc_c_s = hc_h_p = hc_c_p = "N/A"
|
| 98 |
|
| 99 |
comparison_html = f"""
|
| 100 |
-
<h3>
|
| 101 |
<table class='data-table'>
|
| 102 |
-
<thead><tr><th>
|
| 103 |
<tbody>
|
| 104 |
-
<tr><td>
|
| 105 |
-
<tr><td>
|
| 106 |
</tbody>
|
| 107 |
</table>"""
|
| 108 |
|
|
@@ -117,9 +199,9 @@ def _render_pinch_content(
|
|
| 117 |
)
|
| 118 |
if hp_rows:
|
| 119 |
hp_table_html = f"""
|
| 120 |
-
<h3>
|
| 121 |
<table class='data-table'>
|
| 122 |
-
<thead><tr><th>
|
| 123 |
<tbody>{hp_rows}</tbody>
|
| 124 |
</table>"""
|
| 125 |
|
|
@@ -135,10 +217,10 @@ def _render_pinch_content(
|
|
| 135 |
# Composite
|
| 136 |
if pr.composite_diagram.hot.get("H"):
|
| 137 |
fig = go.Figure()
|
| 138 |
-
fig.add_trace(go.Scatter(x=pr.composite_diagram.hot["H"], y=pr.composite_diagram.hot["T"], mode='lines+markers', name='
|
| 139 |
-
fig.add_trace(go.Scatter(x=pr.composite_diagram.cold["H"], y=pr.composite_diagram.cold["T"], mode='lines+markers', name='
|
| 140 |
fig.add_hline(y=pr.pinch_temperature, line_dash='dash', line_color='gray', annotation_text="Pinch")
|
| 141 |
-
fig.update_layout(**chart_layout_base, title='
|
| 142 |
comp_html = fig.to_html(full_html=False, include_plotlyjs=include_js)
|
| 143 |
|
| 144 |
# GCC
|
|
@@ -148,7 +230,6 @@ def _render_pinch_content(
|
|
| 148 |
hc = pr.heat_cascade
|
| 149 |
fig = go.Figure()
|
| 150 |
for i in range(len(gcc_H) - 1):
|
| 151 |
-
# Color based on deltaH in the cascade: > 0 is heating (red), < 0 is cooling (blue)
|
| 152 |
seg_color = 'red' if i < len(hc) and hc[i].get('deltaH', 0) > 0 else ('blue' if i < len(hc) and hc[i].get('deltaH', 0) < 0 else 'gray')
|
| 153 |
fig.add_trace(go.Scatter(
|
| 154 |
x=[gcc_H[i], gcc_H[i+1]],
|
|
@@ -160,7 +241,7 @@ def _render_pinch_content(
|
|
| 160 |
))
|
| 161 |
fig.add_hline(y=pr.pinch_temperature, line_dash='dash', annotation_text="Pinch")
|
| 162 |
fig.add_vline(x=0, line_color='black', line_width=1, opacity=0.3)
|
| 163 |
-
fig.update_layout(**chart_layout_base, title='
|
| 164 |
gcc_html = fig.to_html(full_html=False, include_plotlyjs=False)
|
| 165 |
|
| 166 |
# HPI
|
|
@@ -170,7 +251,6 @@ def _render_pinch_content(
|
|
| 170 |
hc = pr.heat_cascade
|
| 171 |
fig = go.Figure()
|
| 172 |
|
| 173 |
-
# Add color-coded GCC background
|
| 174 |
for i in range(len(gcc_H) - 1):
|
| 175 |
seg_color = 'rgba(255, 0, 0, 0.4)' if i < len(hc) and hc[i].get('deltaH', 0) > 0 else ('rgba(0, 0, 255, 0.4)' if i < len(hc) and hc[i].get('deltaH', 0) < 0 else 'rgba(128, 128, 128, 0.4)')
|
| 176 |
fig.add_trace(go.Scatter(
|
|
@@ -186,7 +266,7 @@ def _render_pinch_content(
|
|
| 186 |
for idx, hp in enumerate([h for h in heat_pumps if h.available][:3]):
|
| 187 |
c = hp_colors[idx % len(hp_colors)]
|
| 188 |
fig.add_trace(go.Scatter(x=[hp.q_source, hp.q_sink], y=[hp.t_source, hp.t_sink], mode='markers', marker=dict(size=12, color=c, symbol='diamond'), name=hp.name))
|
| 189 |
-
fig.update_layout(**chart_layout_base, title='
|
| 190 |
hpi_html = fig.to_html(full_html=False, include_plotlyjs=False)
|
| 191 |
|
| 192 |
# Interval
|
|
@@ -195,22 +275,22 @@ def _render_pinch_content(
|
|
| 195 |
for i, strm in enumerate(pr.streams_data):
|
| 196 |
color = 'red' if strm.get('type') == 'HOT' else 'blue'
|
| 197 |
fig.add_trace(go.Scatter(x=[i+1, i+1], y=[strm.get('ss'), strm.get('st')], mode='lines', line=dict(color=color, width=10)))
|
| 198 |
-
fig.update_layout(**chart_layout_base, title='
|
| 199 |
int_html = fig.to_html(full_html=False, include_plotlyjs=False)
|
| 200 |
|
| 201 |
return f"""
|
| 202 |
<div id="{id_slug}" class="page-content">
|
| 203 |
-
<h2>📊
|
| 204 |
|
| 205 |
<div class="stream-map-layout">
|
| 206 |
-
<div class="stream-list-section"><h4>
|
| 207 |
-
<div class="map-display-section"><h4>
|
| 208 |
</div>
|
| 209 |
|
| 210 |
{metrics_html}
|
| 211 |
{comparison_html}
|
| 212 |
|
| 213 |
-
<h3>
|
| 214 |
<div class="plots-container">
|
| 215 |
<div class="plot-section">{comp_html}</div>
|
| 216 |
<div class="plot-section">{gcc_html}</div>
|
|
@@ -219,26 +299,29 @@ def _render_pinch_content(
|
|
| 219 |
<div class="hpi-table-col">{hp_table_html}</div>
|
| 220 |
<div class="hpi-chart-col"><div class="plot-section">{hpi_html}</div></div>
|
| 221 |
</div>
|
| 222 |
-
<h3>
|
| 223 |
<div class="plot-section" style="width:100%">{int_html}</div>
|
| 224 |
</div>
|
| 225 |
"""
|
| 226 |
|
| 227 |
|
|
|
|
| 228 |
def generate_html_report(req: ReportRequest) -> str:
|
| 229 |
"""Generate a multi-page self-contained HTML report."""
|
| 230 |
now_str = datetime.now().strftime('%Y-%m-%d %H:%M')
|
|
|
|
|
|
|
| 231 |
|
| 232 |
# 1. Map images (Project-wide - Data Collection)
|
| 233 |
original_base = req.current_base
|
| 234 |
|
| 235 |
req.current_base = 'OpenStreetMap'
|
| 236 |
osm_overlaid_b64 = generate_process_map_b64(req)
|
| 237 |
-
map_osm_html = f"<img src='data:image/png;base64,{osm_overlaid_b64}' alt='OSM'>" if osm_overlaid_b64 else "<p>
|
| 238 |
|
| 239 |
req.current_base = 'Satellite'
|
| 240 |
sat_overlaid_b64 = generate_process_map_b64(req)
|
| 241 |
-
map_sat_html = f"<img src='data:image/png;base64,{sat_overlaid_b64}' alt='Satellite'>" if sat_overlaid_b64 else "<p>
|
| 242 |
|
| 243 |
req.current_base = original_base
|
| 244 |
|
|
@@ -254,15 +337,19 @@ def generate_html_report(req: ReportRequest) -> str:
|
|
| 254 |
mdot, cp = sv.get('ṁ', s.get('mdot', '')), sv.get('cp', s.get('cp', ''))
|
| 255 |
CP = sv.get('CP', '')
|
| 256 |
Q = f"{(float(mdot)*float(cp)*abs(float(tout)-float(tin))):.2f}" if (tin and tout and mdot and cp) else ""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
data_rows += (
|
| 258 |
f"<tr><td>{process_name}</td><td>{sp.name}</td><td>{s.get('name')}</td>"
|
| 259 |
-
f"<td>{
|
| 260 |
f"<td>{cp}</td><td>{CP}</td><td>{Q}</td></tr>"
|
| 261 |
)
|
| 262 |
|
| 263 |
# 3. Compile Individual Scenario Pages
|
| 264 |
scenario_pages_html = ""
|
| 265 |
-
nav_links_html = f'<li class="nav-tab active" onclick="showPage(\'data-collection\')">📍
|
| 266 |
|
| 267 |
# Render each saved scenario
|
| 268 |
for i, sc in enumerate(req.scenarios):
|
|
@@ -279,13 +366,14 @@ def generate_html_report(req: ReportRequest) -> str:
|
|
| 279 |
# The first scenario page must include Plotly.js to enable charts across all tabs
|
| 280 |
scenario_pages_html += _render_pinch_content(
|
| 281 |
sc.name, sc_id, sc.t_min, sc.pinch_result, sc.selected_streams, sc.heat_pumps, sc.energy_demands, sc_map_html,
|
|
|
|
| 282 |
include_plotly_js=(i == 0)
|
| 283 |
)
|
| 284 |
|
| 285 |
# 4. Comparison Summary Page
|
| 286 |
comparison_html = ""
|
| 287 |
if req.scenarios:
|
| 288 |
-
nav_links_html += f'<li class="nav-tab" onclick="showPage(\'comparison-summary\')">⚖️
|
| 289 |
sc_rows = ""
|
| 290 |
sc_names, sc_hot, sc_cold = [], [], []
|
| 291 |
for sc in req.scenarios:
|
|
@@ -294,25 +382,25 @@ def generate_html_report(req: ReportRequest) -> str:
|
|
| 294 |
sc_names.append(sc.name); sc_hot.append(phu); sc_cold.append(pcu)
|
| 295 |
sc_rows += f"<tr><td><strong>{sc.name}</strong></td><td>{sc.t_min}K</td><td>{phu:.1f} kW</td><td>{pcu:.1f} kW</td></tr>"
|
| 296 |
|
| 297 |
-
fig = go.Figure([go.Bar(x=sc_names, y=sc_hot, name='
|
| 298 |
-
fig.update_layout(height=400, barmode='group', title=
|
| 299 |
comp_chart = fig.to_html(full_html=False, include_plotlyjs=False)
|
| 300 |
|
| 301 |
comparison_html = f"""
|
| 302 |
<div id="comparison-summary" class="page-content">
|
| 303 |
-
<h2>⚖️
|
| 304 |
<div class="plot-section">{comp_chart}</div>
|
| 305 |
<table class="data-table">
|
| 306 |
-
<thead><tr><th>
|
| 307 |
<tbody>{sc_rows}</tbody>
|
| 308 |
</table>
|
| 309 |
</div>"""
|
| 310 |
|
| 311 |
# Final HTML assembly
|
| 312 |
-
|
| 313 |
-
<html lang="
|
| 314 |
<head>
|
| 315 |
-
<meta charset="UTF-8"><title>
|
| 316 |
<style>
|
| 317 |
body {{ font-family: 'Segoe UI', sans-serif; background:#f5f5f5; margin:0; padding:20px; }}
|
| 318 |
.report-container {{ background:#fff; padding:30px; border-radius:8px; max-width:2400px; margin:auto; box-shadow:0 2px 10px rgba(0,0,0,0.1); }}
|
|
@@ -346,23 +434,23 @@ def generate_html_report(req: ReportRequest) -> str:
|
|
| 346 |
</head>
|
| 347 |
<body>
|
| 348 |
<div class="report-container">
|
| 349 |
-
<h1>
|
| 350 |
-
<p style="color:#666">
|
| 351 |
<ul class="nav-tabs">{nav_links_html}</ul>
|
| 352 |
|
| 353 |
<div id="data-collection" class="page-content active">
|
| 354 |
-
<h2>📍
|
| 355 |
-
<h3>
|
| 356 |
<div class="maps-container">
|
| 357 |
<div class="map-section">{map_osm_html}<p>OpenStreetMap</p></div>
|
| 358 |
<div class="map-section">{map_sat_html}<p>Satellite</p></div>
|
| 359 |
</div>
|
| 360 |
-
<h3>📋
|
| 361 |
<table class="data-table">
|
| 362 |
-
<thead><tr><th>
|
| 363 |
<tbody>{data_rows}</tbody>
|
| 364 |
</table>
|
| 365 |
-
<h3>📝
|
| 366 |
</div>
|
| 367 |
|
| 368 |
{scenario_pages_html}
|
|
@@ -374,7 +462,6 @@ function showPage(id) {{
|
|
| 374 |
document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active'));
|
| 375 |
document.getElementById(id).classList.add('active');
|
| 376 |
|
| 377 |
-
// Select the correct tab based on the ID passed
|
| 378 |
const tabs = document.querySelectorAll('.nav-tab');
|
| 379 |
tabs.forEach(t => {{
|
| 380 |
if (t.getAttribute('onclick').includes(id)) t.classList.add('active');
|
|
@@ -386,3 +473,4 @@ function showPage(id) {{
|
|
| 386 |
</body></html>"""
|
| 387 |
|
| 388 |
return html
|
|
|
|
|
|
| 12 |
from app.services.map_service import generate_process_map_b64
|
| 13 |
|
| 14 |
|
| 15 |
+
I18N = {
|
| 16 |
+
"en": {
|
| 17 |
+
"report_title": "Heat Integration Report",
|
| 18 |
+
"generated": "Generated",
|
| 19 |
+
"data_collection": "Data Collection",
|
| 20 |
+
"process_maps": "Process Level Maps",
|
| 21 |
+
"collected_data": "Collected Data",
|
| 22 |
+
"notes": "Data Collection Notes",
|
| 23 |
+
"no_notes": "No notes recorded.",
|
| 24 |
+
"process": "Process",
|
| 25 |
+
"subprocess": "Subprocess",
|
| 26 |
+
"stream": "Stream",
|
| 27 |
+
"type": "Type",
|
| 28 |
+
"hot": "Hot",
|
| 29 |
+
"cold": "Cold",
|
| 30 |
+
"analysis": "Analysis",
|
| 31 |
+
"not_enough_streams": "Not enough streams selected for pinch analysis in this scenario.",
|
| 32 |
+
"min_heating": "Min. Heating Demand",
|
| 33 |
+
"min_cooling": "Min. Cooling Demand",
|
| 34 |
+
"pinch_temp": "Pinch Temperature",
|
| 35 |
+
"heat_recovery": "Heat Recovery",
|
| 36 |
+
"comparison": "Comparison",
|
| 37 |
+
"status_quo": "Status Quo",
|
| 38 |
+
"savings": "Savings",
|
| 39 |
+
"hp_hdr": "HP Coverage",
|
| 40 |
+
"hp_hdr_kw": "HP – {name} (kW)",
|
| 41 |
+
"hp_integration": "Heat Pump Integration",
|
| 42 |
+
"hp_table_hdr": "Heat Pump",
|
| 43 |
+
"diagrams": "Diagrams",
|
| 44 |
+
"interval_diagram": "Interval Diagram",
|
| 45 |
+
"scenario": "Scenario",
|
| 46 |
+
"comparison_summary": "Scenario Comparison",
|
| 47 |
+
"utilities_comparison": "Utilities Comparison",
|
| 48 |
+
"composite_title": "Composite Curves",
|
| 49 |
+
"gcc_title": "Grand Composite Curve",
|
| 50 |
+
"hpi_title": "Heat Pump Integration",
|
| 51 |
+
"selected_streams": "Selected Streams",
|
| 52 |
+
"process_layout": "Process Layout"
|
| 53 |
+
},
|
| 54 |
+
"de": {
|
| 55 |
+
"report_title": "Wärmeintegrationsbericht",
|
| 56 |
+
"generated": "Generiert",
|
| 57 |
+
"data_collection": "Datenerfassung",
|
| 58 |
+
"process_maps": "Prozesskarten",
|
| 59 |
+
"collected_data": "Erfasste Daten",
|
| 60 |
+
"notes": "Notizen zur Datenerfassung",
|
| 61 |
+
"no_notes": "Keine Notizen erfasst.",
|
| 62 |
+
"process": "Prozess",
|
| 63 |
+
"subprocess": "Teilprozess",
|
| 64 |
+
"stream": "Strom",
|
| 65 |
+
"type": "Typ",
|
| 66 |
+
"hot": "Heiß",
|
| 67 |
+
"cold": "Kalt",
|
| 68 |
+
"analysis": "Analyse",
|
| 69 |
+
"not_enough_streams": "Nicht genügend Ströme für die Pinch-Analyse in diesem Szenario ausgewählt.",
|
| 70 |
+
"min_heating": "Min. Heizbedarf",
|
| 71 |
+
"min_cooling": "Min. Kühlbedarf",
|
| 72 |
+
"pinch_temp": "Pinch-Temperatur",
|
| 73 |
+
"heat_recovery": "Wärmerückgewinnung",
|
| 74 |
+
"comparison": "Vergleich",
|
| 75 |
+
"status_quo": "Status Quo",
|
| 76 |
+
"savings": "Einsparung",
|
| 77 |
+
"hp_hdr": "WP-Abdeckung",
|
| 78 |
+
"hp_hdr_kw": "WP – {name} (kW)",
|
| 79 |
+
"hp_integration": "Wärmepumpen-Integration",
|
| 80 |
+
"hp_table_hdr": "Wärmepumpe",
|
| 81 |
+
"diagrams": "Diagramme",
|
| 82 |
+
"interval_diagram": "Intervalldiagramm",
|
| 83 |
+
"scenario": "Szenario",
|
| 84 |
+
"comparison_summary": "Szenarienvergleich",
|
| 85 |
+
"utilities_comparison": "Vergleich der Versorgungsleistungen",
|
| 86 |
+
"composite_title": "Verbundkurven",
|
| 87 |
+
"gcc_title": "Grand-Composite-Kurve",
|
| 88 |
+
"hpi_title": "Wärmepumpen-Integration",
|
| 89 |
+
"selected_streams": "Ausgewählte Ströme",
|
| 90 |
+
"process_layout": "Prozess-Layout"
|
| 91 |
+
}
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
def _fmt(val: Any, decimals: int = 2) -> str:
|
| 95 |
"""Format a numeric value, return '' if None."""
|
| 96 |
if val is None or val == '':
|
|
|
|
| 110 |
heat_pumps: List[Any],
|
| 111 |
energy_demands: List[Any],
|
| 112 |
map_streams_img_html: str,
|
| 113 |
+
lang: str = "en",
|
| 114 |
include_plotly_js: bool = False
|
| 115 |
) -> str:
|
| 116 |
"""Helper to render the detailed analysis content for a single scenario."""
|
| 117 |
+
texts = I18N.get(lang, I18N["en"])
|
| 118 |
|
| 119 |
if not pinch_result or len(selected_streams) < 2:
|
| 120 |
return f"""
|
| 121 |
<div id="{id_slug}" class="page-content">
|
| 122 |
+
<h2>📊 {texts['analysis']}: {name}</h2>
|
| 123 |
+
<p><em>{texts['not_enough_streams']}</em></p>
|
| 124 |
</div>
|
| 125 |
"""
|
| 126 |
|
|
|
|
| 131 |
# Streams table
|
| 132 |
rows = ""
|
| 133 |
for s in selected_streams:
|
| 134 |
+
is_hot = s.Tin > s.Tout
|
| 135 |
+
badge_cls = "hot" if is_hot else "cold"
|
| 136 |
+
badge_lbl = texts['hot'] if is_hot else texts['cold']
|
| 137 |
rows += (
|
| 138 |
f"<tr><td>{s.name}</td><td>{s.Tin:.1f}</td><td>{s.Tout:.1f}</td>"
|
| 139 |
f"<td>{s.CP:.2f}</td><td>{s.Q:.2f}</td>"
|
| 140 |
f"<td><span class='badge {badge_cls}'>{badge_lbl}</span></td></tr>\n"
|
| 141 |
)
|
| 142 |
streams_table = (
|
| 143 |
+
f"<table class='data-table'><thead><tr>"
|
| 144 |
+
f"<th>{texts['stream']}</th><th>Tin (°C)</th><th>Tout (°C)</th><th>CP (kW/K)</th><th>Q (kW)</th><th>{texts['type']}</th>"
|
| 145 |
+
f"</tr></thead><tbody>" + rows + "</tbody></table>"
|
| 146 |
)
|
| 147 |
|
| 148 |
# Metrics
|
| 149 |
metrics_html = f"""
|
| 150 |
<div class="metrics-row">
|
| 151 |
+
<div class="metric-card hot"><div class="metric-label">{texts['min_heating']}</div><div class="metric-value">{pr.hot_utility:.2f} kW</div></div>
|
| 152 |
+
<div class="metric-card cold"><div class="metric-label">{texts['min_cooling']}</div><div class="metric-value">{pr.cold_utility:.2f} kW</div></div>
|
| 153 |
+
<div class="metric-card pinch"><div class="metric-label">{texts['pinch_temp']}</div><div class="metric-value">{pr.pinch_temperature:.1f} °C</div></div>
|
| 154 |
+
<div class="metric-card recovery"><div class="metric-label">{texts['heat_recovery']}</div><div class="metric-value">{heat_recovery:.2f} kW</div></div>
|
| 155 |
</div>"""
|
| 156 |
|
| 157 |
# Comparison table (Status Quo vs Proposal)
|
|
|
|
| 169 |
if best and best.q_sink is not None and best.q_source is not None:
|
| 170 |
hc_heat = min(best.q_sink, pr.hot_utility)
|
| 171 |
hc_cool = min(best.q_source, pr.cold_utility)
|
| 172 |
+
hp_hdr = texts['hp_hdr_kw'].format(name=best.name)
|
| 173 |
hc_h_s = f"{hc_heat:.1f}"
|
| 174 |
hc_c_s = f"{hc_cool:.1f}"
|
| 175 |
hc_h_p = f"{hc_heat/pr.hot_utility*100:.1f}%" if pr.hot_utility > 0 else "N/A"
|
| 176 |
hc_c_p = f"{hc_cool/pr.cold_utility*100:.1f}%" if pr.cold_utility > 0 else "N/A"
|
| 177 |
else:
|
| 178 |
+
hp_hdr = f"{texts['hp_hdr']} (kW)"
|
| 179 |
hc_h_s = hc_c_s = hc_h_p = hc_c_p = "N/A"
|
| 180 |
|
| 181 |
comparison_html = f"""
|
| 182 |
+
<h3>📊 {texts['comparison']}: {texts['status_quo']} vs {name}</h3>
|
| 183 |
<table class='data-table'>
|
| 184 |
+
<thead><tr><th>{texts['type']}</th><th>{texts['status_quo']} (kW)</th><th>Min. after HR (kW)</th><th>{texts['savings']} (kW)</th><th>{texts['savings']} (%)</th><th>{hp_hdr}</th><th>{texts['hp_hdr']} (%)</th></tr></thead>
|
| 185 |
<tbody>
|
| 186 |
+
<tr><td>{texts['hot']}</td><td>{tot_heat:.1f}</td><td>{pr.hot_utility:.1f}</td><td>{sh:.1f}</td><td>{sh_pct}</td><td>{hc_h_s}</td><td>{hc_h_p}</td></tr>
|
| 187 |
+
<tr><td>{texts['cold']}</td><td>{tot_cool:.1f}</td><td>{pr.cold_utility:.1f}</td><td>{sc:.1f}</td><td>{sc_pct}</td><td>{hc_c_s}</td><td>{hc_c_p}</td></tr>
|
| 188 |
</tbody>
|
| 189 |
</table>"""
|
| 190 |
|
|
|
|
| 199 |
)
|
| 200 |
if hp_rows:
|
| 201 |
hp_table_html = f"""
|
| 202 |
+
<h3>{texts['hp_integration']} ({name})</h3>
|
| 203 |
<table class='data-table'>
|
| 204 |
+
<thead><tr><th>{texts['hp_table_hdr']}</th><th>COP</th><th>T_source (°C)</th><th>T_sink (°C)</th><th>Q_source (kW)</th><th>Q_sink (kW)</th></tr></thead>
|
| 205 |
<tbody>{hp_rows}</tbody>
|
| 206 |
</table>"""
|
| 207 |
|
|
|
|
| 217 |
# Composite
|
| 218 |
if pr.composite_diagram.hot.get("H"):
|
| 219 |
fig = go.Figure()
|
| 220 |
+
fig.add_trace(go.Scatter(x=pr.composite_diagram.hot["H"], y=pr.composite_diagram.hot["T"], mode='lines+markers', name=texts['hot'], line=dict(color='red')))
|
| 221 |
+
fig.add_trace(go.Scatter(x=pr.composite_diagram.cold["H"], y=pr.composite_diagram.cold["T"], mode='lines+markers', name=texts['cold'], line=dict(color='blue')))
|
| 222 |
fig.add_hline(y=pr.pinch_temperature, line_dash='dash', line_color='gray', annotation_text="Pinch")
|
| 223 |
+
fig.update_layout(**chart_layout_base, title=texts['composite_title'], xaxis_title='H (kW)', yaxis_title='T (°C)')
|
| 224 |
comp_html = fig.to_html(full_html=False, include_plotlyjs=include_js)
|
| 225 |
|
| 226 |
# GCC
|
|
|
|
| 230 |
hc = pr.heat_cascade
|
| 231 |
fig = go.Figure()
|
| 232 |
for i in range(len(gcc_H) - 1):
|
|
|
|
| 233 |
seg_color = 'red' if i < len(hc) and hc[i].get('deltaH', 0) > 0 else ('blue' if i < len(hc) and hc[i].get('deltaH', 0) < 0 else 'gray')
|
| 234 |
fig.add_trace(go.Scatter(
|
| 235 |
x=[gcc_H[i], gcc_H[i+1]],
|
|
|
|
| 241 |
))
|
| 242 |
fig.add_hline(y=pr.pinch_temperature, line_dash='dash', annotation_text="Pinch")
|
| 243 |
fig.add_vline(x=0, line_color='black', line_width=1, opacity=0.3)
|
| 244 |
+
fig.update_layout(**chart_layout_base, title=texts['gcc_title'], xaxis_title='Net ΔH (kW)', yaxis_title='Shifted T (°C)')
|
| 245 |
gcc_html = fig.to_html(full_html=False, include_plotlyjs=False)
|
| 246 |
|
| 247 |
# HPI
|
|
|
|
| 251 |
hc = pr.heat_cascade
|
| 252 |
fig = go.Figure()
|
| 253 |
|
|
|
|
| 254 |
for i in range(len(gcc_H) - 1):
|
| 255 |
seg_color = 'rgba(255, 0, 0, 0.4)' if i < len(hc) and hc[i].get('deltaH', 0) > 0 else ('rgba(0, 0, 255, 0.4)' if i < len(hc) and hc[i].get('deltaH', 0) < 0 else 'rgba(128, 128, 128, 0.4)')
|
| 256 |
fig.add_trace(go.Scatter(
|
|
|
|
| 266 |
for idx, hp in enumerate([h for h in heat_pumps if h.available][:3]):
|
| 267 |
c = hp_colors[idx % len(hp_colors)]
|
| 268 |
fig.add_trace(go.Scatter(x=[hp.q_source, hp.q_sink], y=[hp.t_source, hp.t_sink], mode='markers', marker=dict(size=12, color=c, symbol='diamond'), name=hp.name))
|
| 269 |
+
fig.update_layout(**chart_layout_base, title=texts['hpi_title'])
|
| 270 |
hpi_html = fig.to_html(full_html=False, include_plotlyjs=False)
|
| 271 |
|
| 272 |
# Interval
|
|
|
|
| 275 |
for i, strm in enumerate(pr.streams_data):
|
| 276 |
color = 'red' if strm.get('type') == 'HOT' else 'blue'
|
| 277 |
fig.add_trace(go.Scatter(x=[i+1, i+1], y=[strm.get('ss'), strm.get('st')], mode='lines', line=dict(color=color, width=10)))
|
| 278 |
+
fig.update_layout(**chart_layout_base, title=texts['interval_diagram'])
|
| 279 |
int_html = fig.to_html(full_html=False, include_plotlyjs=False)
|
| 280 |
|
| 281 |
return f"""
|
| 282 |
<div id="{id_slug}" class="page-content">
|
| 283 |
+
<h2>📊 {texts['analysis']}: {name} (ΔTmin = {t_min}°C)</h2>
|
| 284 |
|
| 285 |
<div class="stream-map-layout">
|
| 286 |
+
<div class="stream-list-section"><h4>{texts['selected_streams']}</h4>{streams_table}</div>
|
| 287 |
+
<div class="map-display-section"><h4>{texts['process_layout']}</h4>{map_streams_img_html}</div>
|
| 288 |
</div>
|
| 289 |
|
| 290 |
{metrics_html}
|
| 291 |
{comparison_html}
|
| 292 |
|
| 293 |
+
<h3>{texts['diagrams']}</h3>
|
| 294 |
<div class="plots-container">
|
| 295 |
<div class="plot-section">{comp_html}</div>
|
| 296 |
<div class="plot-section">{gcc_html}</div>
|
|
|
|
| 299 |
<div class="hpi-table-col">{hp_table_html}</div>
|
| 300 |
<div class="hpi-chart-col"><div class="plot-section">{hpi_html}</div></div>
|
| 301 |
</div>
|
| 302 |
+
<h3>{texts['interval_diagram']}</h3>
|
| 303 |
<div class="plot-section" style="width:100%">{int_html}</div>
|
| 304 |
</div>
|
| 305 |
"""
|
| 306 |
|
| 307 |
|
| 308 |
+
|
| 309 |
def generate_html_report(req: ReportRequest) -> str:
|
| 310 |
"""Generate a multi-page self-contained HTML report."""
|
| 311 |
now_str = datetime.now().strftime('%Y-%m-%d %H:%M')
|
| 312 |
+
lang = req.language if req.language in I18N else "en"
|
| 313 |
+
texts = I18N[lang]
|
| 314 |
|
| 315 |
# 1. Map images (Project-wide - Data Collection)
|
| 316 |
original_base = req.current_base
|
| 317 |
|
| 318 |
req.current_base = 'OpenStreetMap'
|
| 319 |
osm_overlaid_b64 = generate_process_map_b64(req)
|
| 320 |
+
map_osm_html = f"<img src='data:image/png;base64,{osm_overlaid_b64}' alt='OSM'>" if osm_overlaid_b64 else f"<p>{texts['process_maps']} N/A</p>"
|
| 321 |
|
| 322 |
req.current_base = 'Satellite'
|
| 323 |
sat_overlaid_b64 = generate_process_map_b64(req)
|
| 324 |
+
map_sat_html = f"<img src='data:image/png;base64,{sat_overlaid_b64}' alt='Satellite'>" if sat_overlaid_b64 else f"<p>{texts['process_maps']} N/A</p>"
|
| 325 |
|
| 326 |
req.current_base = original_base
|
| 327 |
|
|
|
|
| 337 |
mdot, cp = sv.get('ṁ', s.get('mdot', '')), sv.get('cp', s.get('cp', ''))
|
| 338 |
CP = sv.get('CP', '')
|
| 339 |
Q = f"{(float(mdot)*float(cp)*abs(float(tout)-float(tin))):.2f}" if (tin and tout and mdot and cp) else ""
|
| 340 |
+
|
| 341 |
+
s_type = s.get('type', '')
|
| 342 |
+
localized_type = texts['hot'] if s_type == 'HOT' else (texts['cold'] if s_type == 'COLD' else s_type)
|
| 343 |
+
|
| 344 |
data_rows += (
|
| 345 |
f"<tr><td>{process_name}</td><td>{sp.name}</td><td>{s.get('name')}</td>"
|
| 346 |
+
f"<td>{localized_type}</td><td>{tin}</td><td>{tout}</td><td>{mdot}</td>"
|
| 347 |
f"<td>{cp}</td><td>{CP}</td><td>{Q}</td></tr>"
|
| 348 |
)
|
| 349 |
|
| 350 |
# 3. Compile Individual Scenario Pages
|
| 351 |
scenario_pages_html = ""
|
| 352 |
+
nav_links_html = f'<li class="nav-tab active" onclick="showPage(\'data-collection\')">📍 {texts["data_collection"]}</li>'
|
| 353 |
|
| 354 |
# Render each saved scenario
|
| 355 |
for i, sc in enumerate(req.scenarios):
|
|
|
|
| 366 |
# The first scenario page must include Plotly.js to enable charts across all tabs
|
| 367 |
scenario_pages_html += _render_pinch_content(
|
| 368 |
sc.name, sc_id, sc.t_min, sc.pinch_result, sc.selected_streams, sc.heat_pumps, sc.energy_demands, sc_map_html,
|
| 369 |
+
lang=lang,
|
| 370 |
include_plotly_js=(i == 0)
|
| 371 |
)
|
| 372 |
|
| 373 |
# 4. Comparison Summary Page
|
| 374 |
comparison_html = ""
|
| 375 |
if req.scenarios:
|
| 376 |
+
nav_links_html += f'<li class="nav-tab" onclick="showPage(\'comparison-summary\')">⚖️ {texts["comparison"]}</li>'
|
| 377 |
sc_rows = ""
|
| 378 |
sc_names, sc_hot, sc_cold = [], [], []
|
| 379 |
for sc in req.scenarios:
|
|
|
|
| 382 |
sc_names.append(sc.name); sc_hot.append(phu); sc_cold.append(pcu)
|
| 383 |
sc_rows += f"<tr><td><strong>{sc.name}</strong></td><td>{sc.t_min}K</td><td>{phu:.1f} kW</td><td>{pcu:.1f} kW</td></tr>"
|
| 384 |
|
| 385 |
+
fig = go.Figure([go.Bar(x=sc_names, y=sc_hot, name=texts['hot'], marker_color='#ef4444'), go.Bar(x=sc_names, y=sc_cold, name=texts['cold'], marker_color='#3b82f6')])
|
| 386 |
+
fig.update_layout(height=400, barmode='group', title=texts['utilities_comparison'])
|
| 387 |
comp_chart = fig.to_html(full_html=False, include_plotlyjs=False)
|
| 388 |
|
| 389 |
comparison_html = f"""
|
| 390 |
<div id="comparison-summary" class="page-content">
|
| 391 |
+
<h2>⚖️ {texts['comparison_summary']}</h2>
|
| 392 |
<div class="plot-section">{comp_chart}</div>
|
| 393 |
<table class="data-table">
|
| 394 |
+
<thead><tr><th>{texts['scenario']}</th><th>ΔTmin</th><th>{texts['min_heating']}</th><th>{texts['min_cooling']}</th></tr></thead>
|
| 395 |
<tbody>{sc_rows}</tbody>
|
| 396 |
</table>
|
| 397 |
</div>"""
|
| 398 |
|
| 399 |
# Final HTML assembly
|
| 400 |
+
html = f"""<!DOCTYPE html>
|
| 401 |
+
<html lang="{lang}">
|
| 402 |
<head>
|
| 403 |
+
<meta charset="UTF-8"><title>{texts['report_title']}</title>
|
| 404 |
<style>
|
| 405 |
body {{ font-family: 'Segoe UI', sans-serif; background:#f5f5f5; margin:0; padding:20px; }}
|
| 406 |
.report-container {{ background:#fff; padding:30px; border-radius:8px; max-width:2400px; margin:auto; box-shadow:0 2px 10px rgba(0,0,0,0.1); }}
|
|
|
|
| 434 |
</head>
|
| 435 |
<body>
|
| 436 |
<div class="report-container">
|
| 437 |
+
<h1>{texts['report_title']}</h1>
|
| 438 |
+
<p style="color:#666">{texts['generated']}: {now_str}</p>
|
| 439 |
<ul class="nav-tabs">{nav_links_html}</ul>
|
| 440 |
|
| 441 |
<div id="data-collection" class="page-content active">
|
| 442 |
+
<h2>📍 {texts['data_collection']}</h2>
|
| 443 |
+
<h3>{texts['process_maps']}</h3>
|
| 444 |
<div class="maps-container">
|
| 445 |
<div class="map-section">{map_osm_html}<p>OpenStreetMap</p></div>
|
| 446 |
<div class="map-section">{map_sat_html}<p>Satellite</p></div>
|
| 447 |
</div>
|
| 448 |
+
<h3>📋 {texts['collected_data']}</h3>
|
| 449 |
<table class="data-table">
|
| 450 |
+
<thead><tr><th>{texts['process']}</th><th>{texts['subprocess']}</th><th>{texts['stream']}</th><th>{texts['type']}</th><th>Tin (°C)</th><th>Tout (°C)</th><th>ṁ</th><th>cp</th><th>CP</th><th>Q (kW)</th></tr></thead>
|
| 451 |
<tbody>{data_rows}</tbody>
|
| 452 |
</table>
|
| 453 |
+
<h3>📝 {texts['notes']}</h3><div style="background:#f9f9f9; padding:15px; border-left:4px solid #3498db; white-space: pre-wrap;">{req.project_notes or texts['no_notes']}</div>
|
| 454 |
</div>
|
| 455 |
|
| 456 |
{scenario_pages_html}
|
|
|
|
| 462 |
document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active'));
|
| 463 |
document.getElementById(id).classList.add('active');
|
| 464 |
|
|
|
|
| 465 |
const tabs = document.querySelectorAll('.nav-tab');
|
| 466 |
tabs.forEach(t => {{
|
| 467 |
if (t.getAttribute('onclick').includes(id)) t.classList.add('active');
|
|
|
|
| 473 |
</body></html>"""
|
| 474 |
|
| 475 |
return html
|
| 476 |
+
|
frontend/src/pages/PotentialAnalysisPage.tsx
CHANGED
|
@@ -34,7 +34,7 @@ import AnalysisHelp from '../components/ui/AnalysisHelp';
|
|
| 34 |
import ChartHelpButton from '../components/ui/ChartHelpButton';
|
| 35 |
|
| 36 |
export default function PotentialAnalysisPage() {
|
| 37 |
-
const { t } = useTranslation();
|
| 38 |
const processes = useProjectStore((s) => s.state.processes);
|
| 39 |
const pinchNotes = useProjectStore((s) => s.state.pinch_notes);
|
| 40 |
const setPinchNotes = useProjectStore((s) => s.setPinchNotes);
|
|
@@ -311,6 +311,7 @@ export default function PotentialAnalysisPage() {
|
|
| 311 |
// Helper to build payload for report and map preview
|
| 312 |
const buildReportPayload = useCallback(() => {
|
| 313 |
return {
|
|
|
|
| 314 |
project_notes: projectState.project_notes || '',
|
| 315 |
pinch_notes: projectState.pinch_notes || '',
|
| 316 |
map_snapshots_encoded: projectState.map_snapshots_encoded || {},
|
|
|
|
| 34 |
import ChartHelpButton from '../components/ui/ChartHelpButton';
|
| 35 |
|
| 36 |
export default function PotentialAnalysisPage() {
|
| 37 |
+
const { t, i18n } = useTranslation();
|
| 38 |
const processes = useProjectStore((s) => s.state.processes);
|
| 39 |
const pinchNotes = useProjectStore((s) => s.state.pinch_notes);
|
| 40 |
const setPinchNotes = useProjectStore((s) => s.setPinchNotes);
|
|
|
|
| 311 |
// Helper to build payload for report and map preview
|
| 312 |
const buildReportPayload = useCallback(() => {
|
| 313 |
return {
|
| 314 |
+
language: i18n.language || 'en',
|
| 315 |
project_notes: projectState.project_notes || '',
|
| 316 |
pinch_notes: projectState.pinch_notes || '',
|
| 317 |
map_snapshots_encoded: projectState.map_snapshots_encoded || {},
|