drzg15 commited on
Commit
fca9867
·
1 Parent(s): e4f8027

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>📊 Analysis: {name}</h2>
42
- <p><em>Not enough streams selected for pinch analysis in this scenario.</em></p>
43
  </div>
44
  """
45
 
@@ -50,26 +131,27 @@ def _render_pinch_content(
50
  # Streams table
51
  rows = ""
52
  for s in selected_streams:
53
- badge_cls = "hot" if s.Tin > s.Tout else "cold"
54
- badge_lbl = "Hot" if s.Tin > s.Tout else "Cold"
 
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>Stream</th><th>Tin (°C)</th><th>Tout (°C)</th><th>CP (kW/K)</th><th>Q (kW)</th><th>Type</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">Min. Heating Demand</div><div class="metric-value">{pr.hot_utility:.2f} kW</div></div>
70
- <div class="metric-card cold"><div class="metric-label">Min. Cooling Demand</div><div class="metric-value">{pr.cold_utility:.2f} kW</div></div>
71
- <div class="metric-card pinch"><div class="metric-label">Pinch Temperature</div><div class="metric-value">{pr.pinch_temperature:.1f} °C</div></div>
72
- <div class="metric-card recovery"><div class="metric-label">Heat Recovery</div><div class="metric-value">{heat_recovery:.2f} kW</div></div>
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 = f"HP – {best.name} (kW)"
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 = "HP Coverage (kW)"
97
  hc_h_s = hc_c_s = hc_h_p = hc_c_p = "N/A"
98
 
99
  comparison_html = f"""
100
- <h3>&#x1F4CA; Comparison: Status Quo vs {name}</h3>
101
  <table class='data-table'>
102
- <thead><tr><th>Type</th><th>Status Quo (kW)</th><th>Min. after HR (kW)</th><th>Savings (kW)</th><th>Savings (%)</th><th>{hp_hdr}</th><th>HP Coverage (%)</th></tr></thead>
103
  <tbody>
104
- <tr><td>Heating</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>
105
- <tr><td>Cooling</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>
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>Heat Pump Integration ({name})</h3>
121
  <table class='data-table'>
122
- <thead><tr><th>Heat Pump</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>
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='Hot Streams', line=dict(color='red')))
139
- fig.add_trace(go.Scatter(x=pr.composite_diagram.cold["H"], y=pr.composite_diagram.cold["T"], mode='lines+markers', name='Cold Streams', line=dict(color='blue')))
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='Composite Curves', xaxis_title='H (kW)', yaxis_title='T (°C)')
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='Grand Composite Curve', xaxis_title='Net ΔH (kW)', yaxis_title='Shifted T (°C)')
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='Heat Pump Integration')
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='Interval Diagram')
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>📊 Analysis: {name} (ΔTmin = {t_min}°C)</h2>
204
 
205
  <div class="stream-map-layout">
206
- <div class="stream-list-section"><h4>Selected Streams</h4>{streams_table}</div>
207
- <div class="map-display-section"><h4>Process Layout</h4>{map_streams_img_html}</div>
208
  </div>
209
 
210
  {metrics_html}
211
  {comparison_html}
212
 
213
- <h3>Diagrams</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>Interval Diagram</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>Map not available</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>Satellite map not available</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>{s.get('type')}</td><td>{tin}</td><td>{tout}</td><td>{mdot}</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\')">📍 Data Collection</li>'
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\')">⚖️ Comparison</li>'
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='Heating', marker_color='#ef4444'), go.Bar(x=sc_names, y=sc_cold, name='Cooling', marker_color='#3b82f6')])
298
- fig.update_layout(height=400, barmode='group', title="Utilities Comparison")
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>⚖️ Scenario Comparison</h2>
304
  <div class="plot-section">{comp_chart}</div>
305
  <table class="data-table">
306
- <thead><tr><th>Scenario</th><th>ΔTmin</th><th>Min. Heating</th><th>Min. Cooling</th></tr></thead>
307
  <tbody>{sc_rows}</tbody>
308
  </table>
309
  </div>"""
310
 
311
  # Final HTML assembly
312
- return f"""<!DOCTYPE html>
313
- <html lang="en">
314
  <head>
315
- <meta charset="UTF-8"><title>Heat Integration Report</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>Heat Integration Report</h1>
350
- <p style="color:#666">Generated: {now_str}</p>
351
  <ul class="nav-tabs">{nav_links_html}</ul>
352
 
353
  <div id="data-collection" class="page-content active">
354
- <h2>📍 Data Collection</h2>
355
- <h3>Process Level Maps</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>📋 Collected Data</h3>
361
  <table class="data-table">
362
- <thead><tr><th>Process</th><th>Subprocess</th><th>Stream</th><th>Type</th><th>Tin (°C)</th><th>Tout (°C)</th><th>ṁ</th><th>cp</th><th>CP</th><th>Q (kW)</th></tr></thead>
363
  <tbody>{data_rows}</tbody>
364
  </table>
365
- <h3>📝 Data Collection Notes</h3><div style="background:#f9f9f9; padding:15px; border-left:4px solid #3498db; white-space: pre-wrap;">{req.project_notes or 'No notes recorded.'}</div>
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 || {},