mabuseif commited on
Commit
f88cb90
·
verified ·
1 Parent(s): 82d8035

Upload 11 files

Browse files
app/building_energy.py ADDED
@@ -0,0 +1,1136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ HVAC Load Calculator - Building Energy Module
3
+
4
+ This module handles the building energy consumption calculations based on the HVAC loads,
5
+ system efficiencies, and operational parameters. It provides energy use estimates,
6
+ cost analysis, and carbon emissions calculations.
7
+
8
+ Developed by: Dr Majed Abuseif, Deakin University
9
+ © 2025
10
+ """
11
+
12
+ import streamlit as st
13
+ import pandas as pd
14
+ import numpy as np
15
+ import json
16
+ import logging
17
+ import plotly.graph_objects as go
18
+ import plotly.express as px
19
+ from typing import Dict, List, Any, Optional, Tuple, Union
20
+ from datetime import datetime
21
+
22
+ # Configure logging
23
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
24
+ logger = logging.getLogger(__name__)
25
+
26
+ # Constants
27
+ MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
28
+ DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] # Non-leap year
29
+ HOURS_IN_YEAR = 8760
30
+
31
+ # Default HVAC system parameters
32
+ DEFAULT_HVAC_SYSTEMS = {
33
+ "Split System Heat Pump": {
34
+ "cooling_cop": 3.5,
35
+ "heating_cop": 4.0,
36
+ "fan_power_ratio": 0.1, # Fan power as fraction of total capacity
37
+ "pump_power_ratio": 0.05, # Pump power as fraction of total capacity
38
+ "part_load_performance": "Good", # Qualitative assessment
39
+ "cost_per_kw": 500, # Installation cost per kW of capacity
40
+ "lifespan_years": 15,
41
+ "maintenance_cost_ratio": 0.02 # Annual maintenance as fraction of installation cost
42
+ },
43
+ "VRF System": {
44
+ "cooling_cop": 4.2,
45
+ "heating_cop": 4.5,
46
+ "fan_power_ratio": 0.08,
47
+ "pump_power_ratio": 0.04,
48
+ "part_load_performance": "Excellent",
49
+ "cost_per_kw": 800,
50
+ "lifespan_years": 20,
51
+ "maintenance_cost_ratio": 0.015
52
+ },
53
+ "Chiller with Gas Boiler": {
54
+ "cooling_cop": 5.0,
55
+ "heating_cop": 0.85, # Gas boiler efficiency
56
+ "fan_power_ratio": 0.12,
57
+ "pump_power_ratio": 0.08,
58
+ "part_load_performance": "Good",
59
+ "cost_per_kw": 1000,
60
+ "lifespan_years": 25,
61
+ "maintenance_cost_ratio": 0.025
62
+ },
63
+ "Air-Cooled Packaged Unit": {
64
+ "cooling_cop": 3.2,
65
+ "heating_cop": 3.8,
66
+ "fan_power_ratio": 0.15,
67
+ "pump_power_ratio": 0.03,
68
+ "part_load_performance": "Fair",
69
+ "cost_per_kw": 400,
70
+ "lifespan_years": 12,
71
+ "maintenance_cost_ratio": 0.03
72
+ },
73
+ "Water-Source Heat Pump": {
74
+ "cooling_cop": 4.8,
75
+ "heating_cop": 5.2,
76
+ "fan_power_ratio": 0.07,
77
+ "pump_power_ratio": 0.1,
78
+ "part_load_performance": "Excellent",
79
+ "cost_per_kw": 900,
80
+ "lifespan_years": 20,
81
+ "maintenance_cost_ratio": 0.02
82
+ },
83
+ "Custom System": {
84
+ "cooling_cop": 4.0,
85
+ "heating_cop": 4.0,
86
+ "fan_power_ratio": 0.1,
87
+ "pump_power_ratio": 0.05,
88
+ "part_load_performance": "Good",
89
+ "cost_per_kw": 600,
90
+ "lifespan_years": 15,
91
+ "maintenance_cost_ratio": 0.02
92
+ }
93
+ }
94
+
95
+ # Default energy rates
96
+ DEFAULT_ENERGY_RATES = {
97
+ "electricity": {
98
+ "rate": 0.25, # $/kWh
99
+ "demand_charge": 15.0, # $/kW-month
100
+ "carbon_intensity": 0.5 # kg CO2e/kWh
101
+ },
102
+ "natural_gas": {
103
+ "rate": 0.08, # $/kWh
104
+ "demand_charge": 0.0, # $/kW-month
105
+ "carbon_intensity": 0.2 # kg CO2e/kWh
106
+ }
107
+ }
108
+
109
+ def display_building_energy_page():
110
+ """
111
+ Display the building energy page.
112
+ This is the main function called by main.py when the Building Energy page is selected.
113
+ """
114
+ st.title("Building Energy Consumption")
115
+
116
+ # Display help information in an expandable section
117
+ with st.expander("Help & Information"):
118
+ display_building_energy_help()
119
+
120
+ # Check if HVAC loads have been calculated
121
+ if "hvac_loads" not in st.session_state.project_data or not st.session_state.project_data["hvac_loads"]:
122
+ st.warning("Please complete the HVAC Loads calculations before proceeding to Building Energy analysis.")
123
+
124
+ # Navigation buttons
125
+ col1, col2 = st.columns(2)
126
+ with col1:
127
+ if st.button("Back to HVAC Loads", key="back_to_hvac_loads"):
128
+ st.session_state.current_page = "HVAC Loads"
129
+ st.rerun()
130
+ return
131
+
132
+ # Initialize building energy data if not present
133
+ initialize_building_energy_data()
134
+
135
+ # Create tabs for different aspects of energy analysis
136
+ tabs = st.tabs(["HVAC System", "Energy Consumption", "Energy Costs", "Carbon Emissions"])
137
+
138
+ with tabs[0]:
139
+ display_hvac_system_tab()
140
+
141
+ with tabs[1]:
142
+ display_energy_consumption_tab()
143
+
144
+ with tabs[2]:
145
+ display_energy_costs_tab()
146
+
147
+ with tabs[3]:
148
+ display_carbon_emissions_tab()
149
+
150
+ # Navigation buttons
151
+ col1, col2 = st.columns(2)
152
+
153
+ with col1:
154
+ if st.button("Back to HVAC Loads", key="back_to_hvac_loads"):
155
+ st.session_state.current_page = "HVAC Loads"
156
+ st.rerun()
157
+
158
+ with col2:
159
+ if st.button("Continue to Renewable Energy", key="continue_to_renewable_energy"):
160
+ st.session_state.current_page = "Renewable Energy"
161
+ st.rerun()
162
+
163
+ def initialize_building_energy_data():
164
+ """Initialize building energy data in session state if not present."""
165
+ if "building_energy" not in st.session_state.project_data:
166
+ st.session_state.project_data["building_energy"] = {
167
+ "hvac_system": {
168
+ "system_type": "Split System Heat Pump",
169
+ "cooling_cop": DEFAULT_HVAC_SYSTEMS["Split System Heat Pump"]["cooling_cop"],
170
+ "heating_cop": DEFAULT_HVAC_SYSTEMS["Split System Heat Pump"]["heating_cop"],
171
+ "fan_power_ratio": DEFAULT_HVAC_SYSTEMS["Split System Heat Pump"]["fan_power_ratio"],
172
+ "pump_power_ratio": DEFAULT_HVAC_SYSTEMS["Split System Heat Pump"]["pump_power_ratio"],
173
+ "part_load_performance": DEFAULT_HVAC_SYSTEMS["Split System Heat Pump"]["part_load_performance"],
174
+ "cost_per_kw": DEFAULT_HVAC_SYSTEMS["Split System Heat Pump"]["cost_per_kw"],
175
+ "lifespan_years": DEFAULT_HVAC_SYSTEMS["Split System Heat Pump"]["lifespan_years"],
176
+ "maintenance_cost_ratio": DEFAULT_HVAC_SYSTEMS["Split System Heat Pump"]["maintenance_cost_ratio"]
177
+ },
178
+ "energy_rates": {
179
+ "electricity": DEFAULT_ENERGY_RATES["electricity"].copy(),
180
+ "natural_gas": DEFAULT_ENERGY_RATES["natural_gas"].copy()
181
+ },
182
+ "results": None
183
+ }
184
+
185
+ def display_hvac_system_tab():
186
+ """Display the HVAC system selection and configuration tab."""
187
+ st.header("HVAC System Configuration")
188
+
189
+ # Get current HVAC system data
190
+ hvac_system = st.session_state.project_data["building_energy"]["hvac_system"]
191
+
192
+ # System type selection
193
+ system_type = st.selectbox(
194
+ "HVAC System Type",
195
+ list(DEFAULT_HVAC_SYSTEMS.keys()),
196
+ index=list(DEFAULT_HVAC_SYSTEMS.keys()).index(hvac_system["system_type"]),
197
+ help="Select the type of HVAC system for the building."
198
+ )
199
+
200
+ # If system type changed, update default values
201
+ if system_type != hvac_system["system_type"] and system_type != "Custom System":
202
+ hvac_system.update({
203
+ "system_type": system_type,
204
+ "cooling_cop": DEFAULT_HVAC_SYSTEMS[system_type]["cooling_cop"],
205
+ "heating_cop": DEFAULT_HVAC_SYSTEMS[system_type]["heating_cop"],
206
+ "fan_power_ratio": DEFAULT_HVAC_SYSTEMS[system_type]["fan_power_ratio"],
207
+ "pump_power_ratio": DEFAULT_HVAC_SYSTEMS[system_type]["pump_power_ratio"],
208
+ "part_load_performance": DEFAULT_HVAC_SYSTEMS[system_type]["part_load_performance"],
209
+ "cost_per_kw": DEFAULT_HVAC_SYSTEMS[system_type]["cost_per_kw"],
210
+ "lifespan_years": DEFAULT_HVAC_SYSTEMS[system_type]["lifespan_years"],
211
+ "maintenance_cost_ratio": DEFAULT_HVAC_SYSTEMS[system_type]["maintenance_cost_ratio"]
212
+ })
213
+ elif system_type != hvac_system["system_type"] and system_type == "Custom System":
214
+ hvac_system["system_type"] = system_type
215
+
216
+ # System parameters
217
+ st.subheader("System Performance Parameters")
218
+
219
+ col1, col2 = st.columns(2)
220
+
221
+ with col1:
222
+ cooling_cop = st.number_input(
223
+ "Cooling COP",
224
+ min_value=1.0,
225
+ max_value=10.0,
226
+ value=float(hvac_system["cooling_cop"]),
227
+ step=0.1,
228
+ format="%.1f",
229
+ help="Coefficient of Performance for cooling (higher is more efficient)."
230
+ )
231
+
232
+ fan_power_ratio = st.number_input(
233
+ "Fan Power Ratio",
234
+ min_value=0.01,
235
+ max_value=0.5,
236
+ value=float(hvac_system["fan_power_ratio"]),
237
+ step=0.01,
238
+ format="%.2f",
239
+ help="Fan power as a fraction of total system capacity."
240
+ )
241
+
242
+ with col2:
243
+ heating_cop = st.number_input(
244
+ "Heating COP/Efficiency",
245
+ min_value=0.5,
246
+ max_value=10.0,
247
+ value=float(hvac_system["heating_cop"]),
248
+ step=0.1,
249
+ format="%.1f",
250
+ help="Coefficient of Performance for heating (for heat pumps) or efficiency (for boilers)."
251
+ )
252
+
253
+ pump_power_ratio = st.number_input(
254
+ "Pump Power Ratio",
255
+ min_value=0.0,
256
+ max_value=0.5,
257
+ value=float(hvac_system["pump_power_ratio"]),
258
+ step=0.01,
259
+ format="%.2f",
260
+ help="Pump power as a fraction of total system capacity."
261
+ )
262
+
263
+ # System cost parameters
264
+ st.subheader("System Cost Parameters")
265
+
266
+ col1, col2, col3 = st.columns(3)
267
+
268
+ with col1:
269
+ cost_per_kw = st.number_input(
270
+ "Installation Cost ($/kW)",
271
+ min_value=100.0,
272
+ max_value=2000.0,
273
+ value=float(hvac_system["cost_per_kw"]),
274
+ step=50.0,
275
+ format="%.0f",
276
+ help="Installation cost per kW of system capacity."
277
+ )
278
+
279
+ with col2:
280
+ lifespan_years = st.number_input(
281
+ "System Lifespan (years)",
282
+ min_value=5,
283
+ max_value=30,
284
+ value=int(hvac_system["lifespan_years"]),
285
+ step=1,
286
+ help="Expected lifespan of the HVAC system in years."
287
+ )
288
+
289
+ with col3:
290
+ maintenance_cost_ratio = st.number_input(
291
+ "Annual Maintenance Cost Ratio",
292
+ min_value=0.005,
293
+ max_value=0.1,
294
+ value=float(hvac_system["maintenance_cost_ratio"]),
295
+ step=0.005,
296
+ format="%.3f",
297
+ help="Annual maintenance cost as a fraction of installation cost."
298
+ )
299
+
300
+ # Energy rates
301
+ st.subheader("Energy Rates")
302
+
303
+ energy_rates = st.session_state.project_data["building_energy"]["energy_rates"]
304
+
305
+ col1, col2 = st.columns(2)
306
+
307
+ with col1:
308
+ st.write("Electricity")
309
+ electricity_rate = st.number_input(
310
+ "Electricity Rate ($/kWh)",
311
+ min_value=0.05,
312
+ max_value=1.0,
313
+ value=float(energy_rates["electricity"]["rate"]),
314
+ step=0.01,
315
+ format="%.2f",
316
+ help="Cost of electricity per kWh."
317
+ )
318
+
319
+ electricity_demand_charge = st.number_input(
320
+ "Electricity Demand Charge ($/kW-month)",
321
+ min_value=0.0,
322
+ max_value=50.0,
323
+ value=float(energy_rates["electricity"]["demand_charge"]),
324
+ step=1.0,
325
+ format="%.1f",
326
+ help="Monthly demand charge for peak electricity usage."
327
+ )
328
+
329
+ electricity_carbon = st.number_input(
330
+ "Electricity Carbon Intensity (kg CO2e/kWh)",
331
+ min_value=0.0,
332
+ max_value=2.0,
333
+ value=float(energy_rates["electricity"]["carbon_intensity"]),
334
+ step=0.05,
335
+ format="%.2f",
336
+ help="Carbon emissions per kWh of electricity."
337
+ )
338
+
339
+ with col2:
340
+ st.write("Natural Gas")
341
+ gas_rate = st.number_input(
342
+ "Natural Gas Rate ($/kWh)",
343
+ min_value=0.01,
344
+ max_value=0.5,
345
+ value=float(energy_rates["natural_gas"]["rate"]),
346
+ step=0.01,
347
+ format="%.2f",
348
+ help="Cost of natural gas per kWh equivalent."
349
+ )
350
+
351
+ gas_demand_charge = st.number_input(
352
+ "Natural Gas Demand Charge ($/kW-month)",
353
+ min_value=0.0,
354
+ max_value=20.0,
355
+ value=float(energy_rates["natural_gas"]["demand_charge"]),
356
+ step=1.0,
357
+ format="%.1f",
358
+ help="Monthly demand charge for peak natural gas usage."
359
+ )
360
+
361
+ gas_carbon = st.number_input(
362
+ "Natural Gas Carbon Intensity (kg CO2e/kWh)",
363
+ min_value=0.1,
364
+ max_value=1.0,
365
+ value=float(energy_rates["natural_gas"]["carbon_intensity"]),
366
+ step=0.05,
367
+ format="%.2f",
368
+ help="Carbon emissions per kWh equivalent of natural gas."
369
+ )
370
+
371
+ # Update HVAC system data
372
+ hvac_system.update({
373
+ "cooling_cop": cooling_cop,
374
+ "heating_cop": heating_cop,
375
+ "fan_power_ratio": fan_power_ratio,
376
+ "pump_power_ratio": pump_power_ratio,
377
+ "cost_per_kw": cost_per_kw,
378
+ "lifespan_years": lifespan_years,
379
+ "maintenance_cost_ratio": maintenance_cost_ratio
380
+ })
381
+
382
+ # Update energy rates
383
+ energy_rates["electricity"].update({
384
+ "rate": electricity_rate,
385
+ "demand_charge": electricity_demand_charge,
386
+ "carbon_intensity": electricity_carbon
387
+ })
388
+
389
+ energy_rates["natural_gas"].update({
390
+ "rate": gas_rate,
391
+ "demand_charge": gas_demand_charge,
392
+ "carbon_intensity": gas_carbon
393
+ })
394
+
395
+ # Calculate energy consumption button
396
+ if st.button("Calculate Energy Consumption", key="calculate_energy"):
397
+ try:
398
+ results = calculate_energy_consumption()
399
+ st.session_state.project_data["building_energy"]["results"] = results
400
+ st.success("Energy consumption calculated successfully.")
401
+ logger.info("Energy consumption calculated.")
402
+ st.rerun() # Refresh to show results
403
+ except Exception as e:
404
+ st.error(f"Error calculating energy consumption: {e}")
405
+ logger.error(f"Error calculating energy consumption: {e}", exc_info=True)
406
+ st.session_state.project_data["building_energy"]["results"] = None
407
+
408
+ def display_energy_consumption_tab():
409
+ """Display the energy consumption analysis tab."""
410
+ st.header("Energy Consumption Analysis")
411
+
412
+ # Check if results are available
413
+ results = st.session_state.project_data["building_energy"].get("results")
414
+ if not results:
415
+ st.info("Please calculate energy consumption using the HVAC System tab.")
416
+ return
417
+
418
+ # Display annual energy summary
419
+ st.subheader("Annual Energy Summary")
420
+
421
+ col1, col2, col3 = st.columns(3)
422
+
423
+ with col1:
424
+ st.metric(
425
+ "Total Annual Energy",
426
+ f"{results['annual_total_energy'] / 1000:.1f} MWh",
427
+ help="Total annual energy consumption for heating, cooling, and auxiliary systems."
428
+ )
429
+
430
+ with col2:
431
+ st.metric(
432
+ "Energy Use Intensity",
433
+ f"{results['energy_use_intensity']:.1f} kWh/m²",
434
+ help="Annual energy consumption per square meter of floor area."
435
+ )
436
+
437
+ with col3:
438
+ st.metric(
439
+ "Peak Demand",
440
+ f"{results['peak_demand'] / 1000:.2f} kW",
441
+ help="Maximum power demand throughout the year."
442
+ )
443
+
444
+ # Display energy breakdown
445
+ st.subheader("Energy Consumption Breakdown")
446
+
447
+ # Create pie chart of energy components
448
+ energy_components = {
449
+ "Cooling": results["annual_cooling_energy"],
450
+ "Heating": results["annual_heating_energy"],
451
+ "Fans": results["annual_fan_energy"],
452
+ "Pumps": results["annual_pump_energy"]
453
+ }
454
+
455
+ fig_pie = px.pie(
456
+ values=list(energy_components.values()),
457
+ names=list(energy_components.keys()),
458
+ title="Annual Energy Consumption by Component"
459
+ )
460
+ st.plotly_chart(fig_pie, use_container_width=True)
461
+
462
+ # Display monthly energy consumption
463
+ st.subheader("Monthly Energy Consumption")
464
+
465
+ # Create bar chart of monthly energy
466
+ monthly_data = {
467
+ "Month": MONTHS,
468
+ "Cooling (kWh)": results["monthly_cooling_energy"],
469
+ "Heating (kWh)": results["monthly_heating_energy"],
470
+ "Fans (kWh)": results["monthly_fan_energy"],
471
+ "Pumps (kWh)": results["monthly_pump_energy"]
472
+ }
473
+
474
+ monthly_df = pd.DataFrame(monthly_data)
475
+
476
+ fig_monthly = px.bar(
477
+ monthly_df,
478
+ x="Month",
479
+ y=["Cooling (kWh)", "Heating (kWh)", "Fans (kWh)", "Pumps (kWh)"],
480
+ title="Monthly Energy Consumption by Component",
481
+ barmode="stack"
482
+ )
483
+ st.plotly_chart(fig_monthly, use_container_width=True)
484
+
485
+ # Display hourly energy profile for a typical day
486
+ st.subheader("Hourly Energy Profile")
487
+
488
+ # Allow user to select month for hourly profile
489
+ selected_month = st.selectbox(
490
+ "Select Month for Hourly Profile",
491
+ MONTHS,
492
+ index=6, # Default to July
493
+ help="Select a month to view the typical daily energy profile."
494
+ )
495
+
496
+ month_index = MONTHS.index(selected_month)
497
+
498
+ # Get hourly data for the selected month
499
+ month_start_hour = sum(DAYS_IN_MONTH[:month_index]) * 24
500
+ month_hours = DAYS_IN_MONTH[month_index] * 24
501
+
502
+ # Calculate average hourly profile for the month
503
+ hourly_profile = calculate_average_daily_profile(
504
+ results["hourly_total_energy"][month_start_hour:month_start_hour + month_hours],
505
+ DAYS_IN_MONTH[month_index]
506
+ )
507
+
508
+ # Create hourly profile chart
509
+ fig_hourly = px.line(
510
+ x=list(range(24)),
511
+ y=hourly_profile,
512
+ title=f"Average Daily Energy Profile for {selected_month}",
513
+ labels={"x": "Hour of Day", "y": "Energy (kWh)"}
514
+ )
515
+ st.plotly_chart(fig_hourly, use_container_width=True)
516
+
517
+ # Display load duration curve
518
+ st.subheader("Load Duration Curve")
519
+
520
+ # Sort hourly energy from highest to lowest
521
+ load_duration = sorted(results["hourly_total_energy"], reverse=True)
522
+
523
+ fig_duration = px.line(
524
+ x=list(range(1, HOURS_IN_YEAR + 1)),
525
+ y=load_duration,
526
+ title="Load Duration Curve",
527
+ labels={"x": "Hours", "y": "Energy (kWh)"}
528
+ )
529
+ fig_duration.update_xaxes(type="log")
530
+ st.plotly_chart(fig_duration, use_container_width=True)
531
+
532
+ def display_energy_costs_tab():
533
+ """Display the energy costs analysis tab."""
534
+ st.header("Energy Cost Analysis")
535
+
536
+ # Check if results are available
537
+ results = st.session_state.project_data["building_energy"].get("results")
538
+ if not results:
539
+ st.info("Please calculate energy consumption using the HVAC System tab.")
540
+ return
541
+
542
+ # Display annual cost summary
543
+ st.subheader("Annual Cost Summary")
544
+
545
+ col1, col2, col3 = st.columns(3)
546
+
547
+ with col1:
548
+ st.metric(
549
+ "Total Annual Energy Cost",
550
+ f"${results['annual_energy_cost']:.0f}",
551
+ help="Total annual cost for all energy consumption."
552
+ )
553
+
554
+ with col2:
555
+ st.metric(
556
+ "Energy Cost per Area",
557
+ f"${results['energy_cost_per_area']:.2f}/m²",
558
+ help="Annual energy cost per square meter of floor area."
559
+ )
560
+
561
+ with col3:
562
+ st.metric(
563
+ "Average Energy Rate",
564
+ f"${results['average_energy_rate']:.3f}/kWh",
565
+ help="Average cost per kWh of energy consumed."
566
+ )
567
+
568
+ # Display cost breakdown
569
+ st.subheader("Cost Breakdown")
570
+
571
+ # Create pie chart of cost components
572
+ cost_components = {
573
+ "Electricity Consumption": results["annual_electricity_consumption_cost"],
574
+ "Electricity Demand": results["annual_electricity_demand_cost"],
575
+ "Natural Gas Consumption": results["annual_gas_consumption_cost"],
576
+ "Natural Gas Demand": results["annual_gas_demand_cost"]
577
+ }
578
+
579
+ fig_cost_pie = px.pie(
580
+ values=list(cost_components.values()),
581
+ names=list(cost_components.keys()),
582
+ title="Annual Energy Cost Breakdown"
583
+ )
584
+ st.plotly_chart(fig_cost_pie, use_container_width=True)
585
+
586
+ # Display monthly energy costs
587
+ st.subheader("Monthly Energy Costs")
588
+
589
+ # Create bar chart of monthly costs
590
+ monthly_cost_data = {
591
+ "Month": MONTHS,
592
+ "Electricity Consumption": results["monthly_electricity_consumption_cost"],
593
+ "Electricity Demand": results["monthly_electricity_demand_cost"],
594
+ "Natural Gas Consumption": results["monthly_gas_consumption_cost"],
595
+ "Natural Gas Demand": results["monthly_gas_demand_cost"]
596
+ }
597
+
598
+ monthly_cost_df = pd.DataFrame(monthly_cost_data)
599
+
600
+ fig_monthly_cost = px.bar(
601
+ monthly_cost_df,
602
+ x="Month",
603
+ y=["Electricity Consumption", "Electricity Demand", "Natural Gas Consumption", "Natural Gas Demand"],
604
+ title="Monthly Energy Costs by Component",
605
+ barmode="stack"
606
+ )
607
+ st.plotly_chart(fig_monthly_cost, use_container_width=True)
608
+
609
+ # Display lifecycle cost analysis
610
+ st.subheader("Lifecycle Cost Analysis")
611
+
612
+ # Get HVAC system data
613
+ hvac_system = st.session_state.project_data["building_energy"]["hvac_system"]
614
+
615
+ # Calculate lifecycle costs
616
+ lifecycle_years = 30 # Standard lifecycle analysis period
617
+
618
+ # Initial cost
619
+ peak_load = max(
620
+ results["peak_cooling_load"],
621
+ results["peak_heating_load"]
622
+ )
623
+ initial_cost = peak_load * hvac_system["cost_per_kw"]
624
+
625
+ # Replacement costs
626
+ num_replacements = lifecycle_years // hvac_system["lifespan_years"]
627
+ replacement_cost = initial_cost * num_replacements
628
+
629
+ # Maintenance costs
630
+ annual_maintenance = initial_cost * hvac_system["maintenance_cost_ratio"]
631
+ total_maintenance = annual_maintenance * lifecycle_years
632
+
633
+ # Energy costs
634
+ total_energy_cost = results["annual_energy_cost"] * lifecycle_years
635
+
636
+ # Total lifecycle cost
637
+ total_lifecycle_cost = initial_cost + replacement_cost + total_maintenance + total_energy_cost
638
+
639
+ # Create lifecycle cost breakdown
640
+ lifecycle_components = {
641
+ "Initial Installation": initial_cost,
642
+ "Replacement": replacement_cost,
643
+ "Maintenance": total_maintenance,
644
+ "Energy": total_energy_cost
645
+ }
646
+
647
+ fig_lifecycle = px.pie(
648
+ values=list(lifecycle_components.values()),
649
+ names=list(lifecycle_components.keys()),
650
+ title=f"{lifecycle_years}-Year Lifecycle Cost Breakdown"
651
+ )
652
+ st.plotly_chart(fig_lifecycle, use_container_width=True)
653
+
654
+ # Display lifecycle cost summary
655
+ col1, col2 = st.columns(2)
656
+
657
+ with col1:
658
+ st.metric(
659
+ "Total Lifecycle Cost",
660
+ f"${total_lifecycle_cost:.0f}",
661
+ help=f"Total cost over {lifecycle_years} years including installation, replacement, maintenance, and energy."
662
+ )
663
+
664
+ with col2:
665
+ st.metric(
666
+ "Annualized Cost",
667
+ f"${total_lifecycle_cost / lifecycle_years:.0f}/year",
668
+ help=f"Average annual cost over {lifecycle_years} years."
669
+ )
670
+
671
+ def display_carbon_emissions_tab():
672
+ """Display the carbon emissions analysis tab."""
673
+ st.header("Carbon Emissions Analysis")
674
+
675
+ # Check if results are available
676
+ results = st.session_state.project_data["building_energy"].get("results")
677
+ if not results:
678
+ st.info("Please calculate energy consumption using the HVAC System tab.")
679
+ return
680
+
681
+ # Display annual emissions summary
682
+ st.subheader("Annual Emissions Summary")
683
+
684
+ col1, col2 = st.columns(2)
685
+
686
+ with col1:
687
+ st.metric(
688
+ "Total Annual Emissions",
689
+ f"{results['annual_carbon_emissions']:.1f} tonnes CO2e",
690
+ help="Total annual carbon emissions from all energy consumption."
691
+ )
692
+
693
+ with col2:
694
+ st.metric(
695
+ "Emissions Intensity",
696
+ f"{results['carbon_emissions_intensity']:.1f} kg CO2e/m²",
697
+ help="Annual carbon emissions per square meter of floor area."
698
+ )
699
+
700
+ # Display emissions breakdown
701
+ st.subheader("Emissions Breakdown")
702
+
703
+ # Create pie chart of emissions sources
704
+ emissions_components = {
705
+ "Electricity": results["annual_electricity_emissions"],
706
+ "Natural Gas": results["annual_gas_emissions"]
707
+ }
708
+
709
+ fig_emissions_pie = px.pie(
710
+ values=list(emissions_components.values()),
711
+ names=list(emissions_components.keys()),
712
+ title="Annual Carbon Emissions by Source"
713
+ )
714
+ st.plotly_chart(fig_emissions_pie, use_container_width=True)
715
+
716
+ # Display monthly emissions
717
+ st.subheader("Monthly Carbon Emissions")
718
+
719
+ # Create bar chart of monthly emissions
720
+ monthly_emissions_data = {
721
+ "Month": MONTHS,
722
+ "Electricity Emissions": results["monthly_electricity_emissions"],
723
+ "Natural Gas Emissions": results["monthly_gas_emissions"]
724
+ }
725
+
726
+ monthly_emissions_df = pd.DataFrame(monthly_emissions_data)
727
+
728
+ fig_monthly_emissions = px.bar(
729
+ monthly_emissions_df,
730
+ x="Month",
731
+ y=["Electricity Emissions", "Natural Gas Emissions"],
732
+ title="Monthly Carbon Emissions by Source",
733
+ barmode="stack"
734
+ )
735
+ st.plotly_chart(fig_monthly_emissions, use_container_width=True)
736
+
737
+ # Display emissions benchmark comparison
738
+ st.subheader("Emissions Benchmark Comparison")
739
+
740
+ # Define benchmark emissions intensities (kg CO2e/m²/year)
741
+ benchmarks = {
742
+ "This Building": results["carbon_emissions_intensity"],
743
+ "Low Energy Building": 15.0,
744
+ "Average Building": 50.0,
745
+ "High Energy Building": 100.0
746
+ }
747
+
748
+ # Create benchmark comparison chart
749
+ fig_benchmark = px.bar(
750
+ x=list(benchmarks.keys()),
751
+ y=list(benchmarks.values()),
752
+ title="Carbon Emissions Intensity Comparison",
753
+ labels={"x": "Building Type", "y": "Emissions Intensity (kg CO2e/m²/year)"}
754
+ )
755
+ st.plotly_chart(fig_benchmark, use_container_width=True)
756
+
757
+ # Display emissions reduction potential
758
+ st.subheader("Emissions Reduction Potential")
759
+
760
+ # Calculate potential reductions
761
+ potential_reductions = {
762
+ "Current Emissions": results["annual_carbon_emissions"],
763
+ "With 25% More Efficient HVAC": results["annual_carbon_emissions"] * 0.75,
764
+ "With 50% Renewable Electricity": results["annual_carbon_emissions"] - (results["annual_electricity_emissions"] * 0.5),
765
+ "With Both Measures": results["annual_carbon_emissions"] * 0.75 - (results["annual_electricity_emissions"] * 0.5 * 0.75)
766
+ }
767
+
768
+ # Create reduction potential chart
769
+ fig_reduction = px.bar(
770
+ x=list(potential_reductions.keys()),
771
+ y=list(potential_reductions.values()),
772
+ title="Carbon Emissions Reduction Potential",
773
+ labels={"x": "Scenario", "y": "Annual Emissions (tonnes CO2e)"}
774
+ )
775
+ st.plotly_chart(fig_reduction, use_container_width=True)
776
+
777
+ def calculate_energy_consumption() -> Dict[str, Any]:
778
+ """
779
+ Calculate building energy consumption based on HVAC loads and system parameters.
780
+
781
+ Returns:
782
+ Dictionary containing energy consumption results.
783
+ """
784
+ logger.info("Starting energy consumption calculations...")
785
+
786
+ # Get required data
787
+ hvac_loads = st.session_state.project_data["hvac_loads"]
788
+ building_info = st.session_state.project_data["building_info"]
789
+ hvac_system = st.session_state.project_data["building_energy"]["hvac_system"]
790
+ energy_rates = st.session_state.project_data["building_energy"]["energy_rates"]
791
+
792
+ # Get hourly load data
793
+ hourly_cooling_sensible = np.array(hvac_loads["hourly_cooling_sensible"])
794
+ hourly_cooling_latent = np.array(hvac_loads["hourly_cooling_latent"])
795
+ hourly_cooling_total = np.array(hvac_loads["hourly_cooling_total"])
796
+ hourly_heating = np.array(hvac_loads["hourly_heating"])
797
+
798
+ # Get system parameters
799
+ cooling_cop = hvac_system["cooling_cop"]
800
+ heating_cop = hvac_system["heating_cop"]
801
+ fan_power_ratio = hvac_system["fan_power_ratio"]
802
+ pump_power_ratio = hvac_system["pump_power_ratio"]
803
+
804
+ # Calculate peak loads (W)
805
+ peak_cooling_load = hvac_loads["peak_cooling_total"]
806
+ peak_heating_load = hvac_loads["peak_heating"]
807
+
808
+ # Calculate hourly energy consumption (kWh)
809
+ # Convert from W to kWh by dividing by 1000
810
+ hourly_cooling_energy = hourly_cooling_total / cooling_cop / 1000
811
+ hourly_heating_energy = hourly_heating / heating_cop / 1000
812
+
813
+ # Calculate fan and pump energy
814
+ # Fans run whenever there's heating or cooling
815
+ hourly_fan_energy = (
816
+ (hourly_cooling_total + hourly_heating) * fan_power_ratio / 1000
817
+ )
818
+
819
+ # Pumps run primarily for cooling, but also for some heating systems
820
+ hourly_pump_energy = (
821
+ hourly_cooling_total * pump_power_ratio / 1000 +
822
+ hourly_heating * pump_power_ratio * 0.5 / 1000 # Assume 50% pump usage for heating
823
+ )
824
+
825
+ # Calculate total hourly energy
826
+ hourly_total_energy = (
827
+ hourly_cooling_energy +
828
+ hourly_heating_energy +
829
+ hourly_fan_energy +
830
+ hourly_pump_energy
831
+ )
832
+
833
+ # Calculate monthly energy consumption
834
+ monthly_cooling_energy = calculate_monthly_totals(hourly_cooling_energy)
835
+ monthly_heating_energy = calculate_monthly_totals(hourly_heating_energy)
836
+ monthly_fan_energy = calculate_monthly_totals(hourly_fan_energy)
837
+ monthly_pump_energy = calculate_monthly_totals(hourly_pump_energy)
838
+ monthly_total_energy = calculate_monthly_totals(hourly_total_energy)
839
+
840
+ # Calculate annual energy consumption
841
+ annual_cooling_energy = sum(monthly_cooling_energy)
842
+ annual_heating_energy = sum(monthly_heating_energy)
843
+ annual_fan_energy = sum(monthly_fan_energy)
844
+ annual_pump_energy = sum(monthly_pump_energy)
845
+ annual_total_energy = sum(monthly_total_energy)
846
+
847
+ # Calculate energy use intensity (kWh/m²)
848
+ floor_area = building_info["floor_area"]
849
+ energy_use_intensity = annual_total_energy / floor_area
850
+
851
+ # Calculate peak demand (kW)
852
+ peak_demand = max(hourly_total_energy)
853
+
854
+ # Calculate energy costs
855
+ # Determine energy source for heating (electricity or gas)
856
+ is_electric_heating = hvac_system["system_type"] not in ["Chiller with Gas Boiler"]
857
+
858
+ # Calculate electricity and gas consumption
859
+ if is_electric_heating:
860
+ # All electric system
861
+ hourly_electricity = hourly_total_energy
862
+ hourly_gas = np.zeros(HOURS_IN_YEAR)
863
+ else:
864
+ # Gas heating, electric cooling
865
+ hourly_electricity = (
866
+ hourly_cooling_energy +
867
+ hourly_fan_energy +
868
+ hourly_pump_energy
869
+ )
870
+ hourly_gas = hourly_heating_energy
871
+
872
+ # Calculate monthly electricity and gas consumption
873
+ monthly_electricity = calculate_monthly_totals(hourly_electricity)
874
+ monthly_gas = calculate_monthly_totals(hourly_gas)
875
+
876
+ # Calculate annual electricity and gas consumption
877
+ annual_electricity = sum(monthly_electricity)
878
+ annual_gas = sum(monthly_gas)
879
+
880
+ # Calculate monthly peak demand
881
+ monthly_electricity_peak = calculate_monthly_peaks(hourly_electricity)
882
+ monthly_gas_peak = calculate_monthly_peaks(hourly_gas)
883
+
884
+ # Calculate energy costs
885
+ # Consumption costs
886
+ monthly_electricity_consumption_cost = [
887
+ monthly_electricity[i] * energy_rates["electricity"]["rate"]
888
+ for i in range(12)
889
+ ]
890
+
891
+ monthly_gas_consumption_cost = [
892
+ monthly_gas[i] * energy_rates["natural_gas"]["rate"]
893
+ for i in range(12)
894
+ ]
895
+
896
+ # Demand costs
897
+ monthly_electricity_demand_cost = [
898
+ monthly_electricity_peak[i] * energy_rates["electricity"]["demand_charge"]
899
+ for i in range(12)
900
+ ]
901
+
902
+ monthly_gas_demand_cost = [
903
+ monthly_gas_peak[i] * energy_rates["natural_gas"]["demand_charge"]
904
+ for i in range(12)
905
+ ]
906
+
907
+ # Total monthly costs
908
+ monthly_total_cost = [
909
+ monthly_electricity_consumption_cost[i] +
910
+ monthly_electricity_demand_cost[i] +
911
+ monthly_gas_consumption_cost[i] +
912
+ monthly_gas_demand_cost[i]
913
+ for i in range(12)
914
+ ]
915
+
916
+ # Annual costs
917
+ annual_electricity_consumption_cost = sum(monthly_electricity_consumption_cost)
918
+ annual_electricity_demand_cost = sum(monthly_electricity_demand_cost)
919
+ annual_gas_consumption_cost = sum(monthly_gas_consumption_cost)
920
+ annual_gas_demand_cost = sum(monthly_gas_demand_cost)
921
+ annual_energy_cost = sum(monthly_total_cost)
922
+
923
+ # Calculate cost metrics
924
+ energy_cost_per_area = annual_energy_cost / floor_area
925
+ average_energy_rate = annual_energy_cost / annual_total_energy if annual_total_energy > 0 else 0
926
+
927
+ # Calculate carbon emissions
928
+ # Monthly emissions
929
+ monthly_electricity_emissions = [
930
+ monthly_electricity[i] * energy_rates["electricity"]["carbon_intensity"]
931
+ for i in range(12)
932
+ ]
933
+
934
+ monthly_gas_emissions = [
935
+ monthly_gas[i] * energy_rates["natural_gas"]["carbon_intensity"]
936
+ for i in range(12)
937
+ ]
938
+
939
+ monthly_total_emissions = [
940
+ monthly_electricity_emissions[i] + monthly_gas_emissions[i]
941
+ for i in range(12)
942
+ ]
943
+
944
+ # Annual emissions
945
+ annual_electricity_emissions = sum(monthly_electricity_emissions)
946
+ annual_gas_emissions = sum(monthly_gas_emissions)
947
+ annual_carbon_emissions = annual_electricity_emissions + annual_gas_emissions
948
+
949
+ # Convert from kg to tonnes
950
+ annual_carbon_emissions /= 1000
951
+ annual_electricity_emissions /= 1000
952
+ annual_gas_emissions /= 1000
953
+
954
+ # Calculate emissions intensity
955
+ carbon_emissions_intensity = annual_carbon_emissions * 1000 / floor_area # kg CO2e/m²
956
+
957
+ # Compile results
958
+ results = {
959
+ # Energy consumption
960
+ "hourly_cooling_energy": hourly_cooling_energy.tolist(),
961
+ "hourly_heating_energy": hourly_heating_energy.tolist(),
962
+ "hourly_fan_energy": hourly_fan_energy.tolist(),
963
+ "hourly_pump_energy": hourly_pump_energy.tolist(),
964
+ "hourly_total_energy": hourly_total_energy.tolist(),
965
+ "hourly_electricity": hourly_electricity.tolist(),
966
+ "hourly_gas": hourly_gas.tolist(),
967
+
968
+ "monthly_cooling_energy": monthly_cooling_energy,
969
+ "monthly_heating_energy": monthly_heating_energy,
970
+ "monthly_fan_energy": monthly_fan_energy,
971
+ "monthly_pump_energy": monthly_pump_energy,
972
+ "monthly_total_energy": monthly_total_energy,
973
+ "monthly_electricity": monthly_electricity,
974
+ "monthly_gas": monthly_gas,
975
+ "monthly_electricity_peak": monthly_electricity_peak,
976
+ "monthly_gas_peak": monthly_gas_peak,
977
+
978
+ "annual_cooling_energy": annual_cooling_energy,
979
+ "annual_heating_energy": annual_heating_energy,
980
+ "annual_fan_energy": annual_fan_energy,
981
+ "annual_pump_energy": annual_pump_energy,
982
+ "annual_total_energy": annual_total_energy,
983
+ "annual_electricity": annual_electricity,
984
+ "annual_gas": annual_gas,
985
+
986
+ "energy_use_intensity": energy_use_intensity,
987
+ "peak_demand": peak_demand,
988
+ "peak_cooling_load": peak_cooling_load,
989
+ "peak_heating_load": peak_heating_load,
990
+
991
+ # Energy costs
992
+ "monthly_electricity_consumption_cost": monthly_electricity_consumption_cost,
993
+ "monthly_electricity_demand_cost": monthly_electricity_demand_cost,
994
+ "monthly_gas_consumption_cost": monthly_gas_consumption_cost,
995
+ "monthly_gas_demand_cost": monthly_gas_demand_cost,
996
+ "monthly_total_cost": monthly_total_cost,
997
+
998
+ "annual_electricity_consumption_cost": annual_electricity_consumption_cost,
999
+ "annual_electricity_demand_cost": annual_electricity_demand_cost,
1000
+ "annual_gas_consumption_cost": annual_gas_consumption_cost,
1001
+ "annual_gas_demand_cost": annual_gas_demand_cost,
1002
+ "annual_energy_cost": annual_energy_cost,
1003
+
1004
+ "energy_cost_per_area": energy_cost_per_area,
1005
+ "average_energy_rate": average_energy_rate,
1006
+
1007
+ # Carbon emissions
1008
+ "monthly_electricity_emissions": monthly_electricity_emissions,
1009
+ "monthly_gas_emissions": monthly_gas_emissions,
1010
+ "monthly_total_emissions": monthly_total_emissions,
1011
+
1012
+ "annual_electricity_emissions": annual_electricity_emissions,
1013
+ "annual_gas_emissions": annual_gas_emissions,
1014
+ "annual_carbon_emissions": annual_carbon_emissions,
1015
+
1016
+ "carbon_emissions_intensity": carbon_emissions_intensity,
1017
+
1018
+ # Calculation timestamp
1019
+ "calculation_timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
1020
+ }
1021
+
1022
+ logger.info("Energy consumption calculations completed.")
1023
+ return results
1024
+
1025
+ def calculate_monthly_totals(hourly_data: np.ndarray) -> List[float]:
1026
+ """
1027
+ Calculate monthly totals from hourly data.
1028
+
1029
+ Args:
1030
+ hourly_data: Numpy array of hourly values
1031
+
1032
+ Returns:
1033
+ List of monthly totals
1034
+ """
1035
+ monthly_totals = []
1036
+ hour_index = 0
1037
+
1038
+ for days in DAYS_IN_MONTH:
1039
+ hours_in_month = days * 24
1040
+ month_total = sum(hourly_data[hour_index:hour_index + hours_in_month])
1041
+ monthly_totals.append(month_total)
1042
+ hour_index += hours_in_month
1043
+
1044
+ return monthly_totals
1045
+
1046
+ def calculate_monthly_peaks(hourly_data: np.ndarray) -> List[float]:
1047
+ """
1048
+ Calculate monthly peak values from hourly data.
1049
+
1050
+ Args:
1051
+ hourly_data: Numpy array of hourly values
1052
+
1053
+ Returns:
1054
+ List of monthly peak values
1055
+ """
1056
+ monthly_peaks = []
1057
+ hour_index = 0
1058
+
1059
+ for days in DAYS_IN_MONTH:
1060
+ hours_in_month = days * 24
1061
+ month_data = hourly_data[hour_index:hour_index + hours_in_month]
1062
+ monthly_peaks.append(max(month_data) if len(month_data) > 0 else 0)
1063
+ hour_index += hours_in_month
1064
+
1065
+ return monthly_peaks
1066
+
1067
+ def calculate_average_daily_profile(hourly_data: np.ndarray, days: int) -> List[float]:
1068
+ """
1069
+ Calculate average daily profile from hourly data for a month.
1070
+
1071
+ Args:
1072
+ hourly_data: Numpy array of hourly values for the month
1073
+ days: Number of days in the month
1074
+
1075
+ Returns:
1076
+ List of 24 hourly average values
1077
+ """
1078
+ daily_profile = [0.0] * 24
1079
+
1080
+ for hour in range(len(hourly_data)):
1081
+ hour_of_day = hour % 24
1082
+ daily_profile[hour_of_day] += hourly_data[hour]
1083
+
1084
+ # Calculate averages
1085
+ for i in range(24):
1086
+ daily_profile[i] /= days
1087
+
1088
+ return daily_profile
1089
+
1090
+ def display_building_energy_help():
1091
+ """
1092
+ Display help information for the building energy page.
1093
+ """
1094
+ st.markdown("""
1095
+ ### Building Energy Consumption Help
1096
+
1097
+ This section calculates the building's energy consumption, costs, and carbon emissions based on the HVAC loads and system parameters.
1098
+
1099
+ **Key Concepts:**
1100
+
1101
+ * **COP (Coefficient of Performance)**: Ratio of useful heating or cooling provided to energy input. Higher values indicate better efficiency.
1102
+ * **Energy Use Intensity (EUI)**: Annual energy consumption per unit floor area (kWh/m²).
1103
+ * **Peak Demand**: Maximum power required at any point during the year.
1104
+ * **Demand Charges**: Utility charges based on peak power demand, typically monthly.
1105
+ * **Carbon Emissions**: Greenhouse gas emissions associated with energy consumption.
1106
+
1107
+ **Workflow:**
1108
+
1109
+ 1. **HVAC System Tab**:
1110
+ * Select the type of HVAC system for your building.
1111
+ * Adjust system performance parameters (COP, fan power, etc.).
1112
+ * Set energy rates and carbon intensities.
1113
+ * Click "Calculate Energy Consumption" to perform the analysis.
1114
+
1115
+ 2. **Energy Consumption Tab**:
1116
+ * Review annual and monthly energy consumption.
1117
+ * Examine energy breakdown by component.
1118
+ * Analyze hourly energy profiles and load duration curve.
1119
+
1120
+ 3. **Energy Costs Tab**:
1121
+ * Review annual and monthly energy costs.
1122
+ * Examine cost breakdown by component.
1123
+ * Analyze lifecycle cost including installation, maintenance, and energy.
1124
+
1125
+ 4. **Carbon Emissions Tab**:
1126
+ * Review annual and monthly carbon emissions.
1127
+ * Compare emissions to benchmarks.
1128
+ * Explore emissions reduction potential.
1129
+
1130
+ **Important:**
1131
+
1132
+ * Energy calculations are based on the HVAC loads calculated in the previous section.
1133
+ * System efficiency has a major impact on energy consumption and costs.
1134
+ * Consider both initial costs and lifecycle costs when evaluating systems.
1135
+ * Carbon emissions vary significantly based on the energy source and local grid mix.
1136
+ """)
app/building_information.py ADDED
@@ -0,0 +1,328 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ HVAC Load Calculator - Building Information Module
3
+
4
+ This module handles the building information input page of the HVAC Load Calculator application,
5
+ allowing users to define basic building parameters such as project name, floor area, building height,
6
+ indoor design conditions, orientation, and building type.
7
+
8
+ Developed by: Dr Majed Abuseif, Deakin University
9
+ © 2025
10
+ """
11
+
12
+ import streamlit as st
13
+ import logging
14
+ import math
15
+ from typing import Dict, Any, Optional, List, Tuple
16
+ from data.internal_loads import BUILDING_TYPES
17
+
18
+ # Configure logging
19
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
20
+ logger = logging.getLogger(__name__)
21
+
22
+ def display_building_info_page():
23
+ """
24
+ Display the building information input page.
25
+ This is the main function called by main.py when the Building Information page is selected.
26
+ """
27
+ st.title("Building Information")
28
+
29
+ # Display help information in an expandable section
30
+ with st.expander("Help & Information"):
31
+ display_building_info_help()
32
+
33
+ # Create the main form for building information input
34
+ with st.form("building_info_form"):
35
+ # Get current values from session state or use defaults
36
+ current_values = st.session_state.project_data["building_info"]
37
+
38
+ # Project Name
39
+ project_name = st.text_input(
40
+ "Project Name",
41
+ value=current_values["project_name"],
42
+ help="Enter a unique identifier for your project."
43
+ )
44
+
45
+ # Create two columns for layout
46
+ col1, col2 = st.columns(2)
47
+
48
+ with col1:
49
+ # Floor Area
50
+ floor_area = st.number_input(
51
+ "Floor Area (square meters)",
52
+ min_value=1.0,
53
+ max_value=100000.0,
54
+ value=float(current_values["floor_area"]),
55
+ step=10.0,
56
+ format="%.1f",
57
+ help="Total conditioned floor area of the building in square meters."
58
+ )
59
+
60
+ # Building Height
61
+ building_height = st.number_input(
62
+ "Building Height (meters)",
63
+ min_value=2.0,
64
+ max_value=100.0,
65
+ value=float(current_values["building_height"]),
66
+ step=0.1,
67
+ format="%.1f",
68
+ help="Average ceiling height of the building in meters."
69
+ )
70
+
71
+ # Building Type
72
+ building_type = st.selectbox(
73
+ "Building Type",
74
+ options=BUILDING_TYPES,
75
+ index=BUILDING_TYPES.index(current_values["building_type"]) if current_values["building_type"] in BUILDING_TYPES else 0,
76
+ help="Primary use of the building, which affects default internal loads and ventilation rates."
77
+ )
78
+
79
+ with col2:
80
+ # Indoor Design Temperature
81
+ indoor_design_temp = st.number_input(
82
+ "Indoor Design Temperature (°C)",
83
+ min_value=15.0,
84
+ max_value=30.0,
85
+ value=float(current_values["indoor_design_temp"]),
86
+ step=0.5,
87
+ format="%.1f",
88
+ help="Target indoor temperature for cooling season comfort (15–30°C)."
89
+ )
90
+
91
+ # Indoor Design Relative Humidity
92
+ indoor_design_rh = st.number_input(
93
+ "Indoor Design Relative Humidity (%)",
94
+ min_value=20.0,
95
+ max_value=80.0,
96
+ value=float(current_values["indoor_design_rh"]),
97
+ step=5.0,
98
+ format="%.1f",
99
+ help="Target indoor relative humidity for comfort (20–80%)."
100
+ )
101
+
102
+ # Ventilation Rate
103
+ ventilation_rate = st.number_input(
104
+ "Ventilation Rate (L/s/m²)",
105
+ min_value=0.0,
106
+ max_value=10.0,
107
+ value=float(current_values["ventilation_rate"]),
108
+ step=0.1,
109
+ format="%.2f",
110
+ help="Fresh air ventilation rate in liters per second per square meter."
111
+ )
112
+
113
+ # Building Orientation
114
+ st.subheader("Building Orientation")
115
+
116
+ # Display orientation diagram
117
+ display_orientation_diagram()
118
+
119
+ orientation_angle = st.slider(
120
+ "Orientation Angle (°)",
121
+ min_value=-180,
122
+ max_value=180,
123
+ value=int(current_values["orientation_angle"]),
124
+ step=5,
125
+ help="Sets the building's rotation angle relative to north (0°). Component orientations are defined as A (North), B (South), C (East), D (West) at 0°. Adjusting this angle rotates all component orientations accordingly."
126
+ )
127
+
128
+ # Operation Hours
129
+ operation_hours = st.slider(
130
+ "Operation Hours (hours/day)",
131
+ min_value=0,
132
+ max_value=24,
133
+ value=int(current_values["operation_hours"]),
134
+ step=1,
135
+ help="Daily hours the building is occupied or operational, affecting internal loads (0–24 hours)."
136
+ )
137
+
138
+ # Form submission button
139
+ submit_button = st.form_submit_button("Save Building Information")
140
+
141
+ if submit_button:
142
+ # Validate inputs
143
+ validation_errors = validate_building_info(
144
+ project_name, floor_area, building_height, building_type,
145
+ indoor_design_temp, indoor_design_rh, ventilation_rate,
146
+ orientation_angle, operation_hours
147
+ )
148
+
149
+ if validation_errors:
150
+ # Display validation errors
151
+ for error in validation_errors:
152
+ st.error(error)
153
+ else:
154
+ # Update session state with validated inputs
155
+ st.session_state.project_data["project_name"] = project_name
156
+ st.session_state.project_data["building_info"].update({
157
+ "project_name": project_name,
158
+ "floor_area": floor_area,
159
+ "building_height": building_height,
160
+ "building_type": building_type,
161
+ "indoor_design_temp": indoor_design_temp,
162
+ "indoor_design_rh": indoor_design_rh,
163
+ "ventilation_rate": ventilation_rate,
164
+ "orientation_angle": float(orientation_angle),
165
+ "operation_hours": operation_hours
166
+ })
167
+
168
+ # Log the update
169
+ logger.info(f"Building information updated for project: {project_name}")
170
+
171
+ # Show success message
172
+ st.success("Building information saved successfully!")
173
+
174
+ # Navigation buttons
175
+ col1, col2 = st.columns(2)
176
+
177
+ with col1:
178
+ if st.button("Back to Intro", key="back_to_intro"):
179
+ st.session_state.current_page = "Intro"
180
+ st.rerun()
181
+
182
+ with col2:
183
+ if st.button("Continue to Climate Data", key="continue_to_climate"):
184
+ # Check if required fields are filled before proceeding
185
+ if not st.session_state.project_data["building_info"]["project_name"]:
186
+ st.error("Please enter a project name before continuing.")
187
+ else:
188
+ st.session_state.current_page = "Climate Data"
189
+ st.rerun()
190
+
191
+ def validate_building_info(
192
+ project_name: str,
193
+ floor_area: float,
194
+ building_height: float,
195
+ building_type: str,
196
+ indoor_design_temp: float,
197
+ indoor_design_rh: float,
198
+ ventilation_rate: float,
199
+ orientation_angle: int,
200
+ operation_hours: int
201
+ ) -> List[str]:
202
+ """
203
+ Validate building information inputs.
204
+
205
+ Args:
206
+ project_name: Project name
207
+ floor_area: Floor area in square meters
208
+ building_height: Building height in meters
209
+ building_type: Building type
210
+ indoor_design_temp: Indoor design temperature in °C
211
+ indoor_design_rh: Indoor design relative humidity in %
212
+ ventilation_rate: Ventilation rate in L/s/m²
213
+ orientation_angle: Building orientation angle in degrees
214
+ operation_hours: Building operation hours per day
215
+
216
+ Returns:
217
+ List of validation error messages, empty if all inputs are valid
218
+ """
219
+ errors = []
220
+
221
+ # Validate project name
222
+ if not project_name or project_name.strip() == "":
223
+ errors.append("Project name is required.")
224
+
225
+ # Validate floor area
226
+ if floor_area <= 0:
227
+ errors.append("Floor area must be greater than zero.")
228
+ elif floor_area > 100000:
229
+ errors.append("Floor area exceeds maximum value (100,000 m²).")
230
+
231
+ # Validate building height
232
+ if building_height < 2.0:
233
+ errors.append("Building height must be at least 2.0 meters.")
234
+ elif building_height > 100.0:
235
+ errors.append("Building height exceeds maximum value (100 meters).")
236
+
237
+ # Validate building type
238
+ if building_type not in BUILDING_TYPES:
239
+ errors.append("Please select a valid building type.")
240
+
241
+ # Validate indoor design temperature
242
+ if indoor_design_temp < 15.0 or indoor_design_temp > 30.0:
243
+ errors.append("Indoor design temperature must be between 15°C and 30°C.")
244
+
245
+ # Validate indoor design relative humidity
246
+ if indoor_design_rh < 20.0 or indoor_design_rh > 80.0:
247
+ errors.append("Indoor design relative humidity must be between 20% and 80%.")
248
+
249
+ # Validate ventilation rate
250
+ if ventilation_rate < 0.0:
251
+ errors.append("Ventilation rate cannot be negative.")
252
+ elif ventilation_rate > 10.0:
253
+ errors.append("Ventilation rate exceeds maximum value (10 L/s/m²).")
254
+
255
+ # Validate orientation angle
256
+ if orientation_angle < -180 or orientation_angle > 180:
257
+ errors.append("Orientation angle must be between -180° and 180°.")
258
+
259
+ # Validate operation hours
260
+ if operation_hours < 0 or operation_hours > 24:
261
+ errors.append("Operation hours must be between 0 and 24.")
262
+
263
+ return errors
264
+
265
+ def display_building_info_help():
266
+ """Display help information for the building information page."""
267
+ st.markdown("""
268
+ ### Building Information Help
269
+
270
+ This section collects basic information about your building project. The inputs provided here will be used throughout the calculation process.
271
+
272
+ **Key Parameters:**
273
+
274
+ * **Project Name**: A unique identifier for your project.
275
+ * **Floor Area**: The total conditioned floor area of the building in square meters.
276
+ * **Building Height**: The average ceiling height of the building in meters.
277
+ * **Building Type**: The primary use of the building, which affects default internal loads and ventilation rates.
278
+ * **Indoor Design Temperature**: The target indoor temperature for cooling season comfort (typically 22-26°C).
279
+ * **Indoor Design Relative Humidity**: The target indoor relative humidity for comfort (typically 40-60%).
280
+ * **Ventilation Rate**: The fresh air ventilation rate in liters per second per square meter.
281
+ * **Orientation Angle**: The building's rotation angle relative to north (0°).
282
+ * **Operation Hours**: Daily hours the building is occupied or operational, affecting internal loads.
283
+
284
+ **Building Orientation:**
285
+
286
+ The orientation angle rotates the entire building relative to true north. At 0°, the building facades are aligned with the cardinal directions:
287
+ * Facade A: North (0°)
288
+ * Facade B: South (180°)
289
+ * Facade C: East (90°)
290
+ * Facade D: West (270°)
291
+
292
+ Adjusting the orientation angle will rotate all facades accordingly.
293
+ """)
294
+
295
+ def display_orientation_diagram():
296
+ """Display a simple ASCII diagram showing building orientation."""
297
+ orientation_diagram = """
298
+ North (0°)
299
+ ^
300
+ |
301
+ |
302
+ West (270°) <---+---> East (90°)
303
+ |
304
+ |
305
+ v
306
+ South (180°)
307
+
308
+ Facades at 0° orientation:
309
+ - Facade A: North (0°)
310
+ - Facade B: South (180°)
311
+ - Facade C: East (90°)
312
+ - Facade D: West (270°)
313
+ """
314
+
315
+ st.text(orientation_diagram)
316
+
317
+ def calculate_volume(floor_area: float, height: float) -> float:
318
+ """
319
+ Calculate the building volume.
320
+
321
+ Args:
322
+ floor_area: Floor area in square meters
323
+ height: Building height in meters
324
+
325
+ Returns:
326
+ Building volume in cubic meters
327
+ """
328
+ return floor_area * height
app/climate_data.py ADDED
@@ -0,0 +1,548 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ HVAC Load Calculator - Climate Data Module
3
+
4
+ This module handles the climate data selection, EPW file processing, and display of climate information
5
+ for the HVAC Load Calculator application. It allows users to upload EPW weather files and extracts
6
+ relevant climate data for use in load calculations.
7
+
8
+ Developed by: Dr Majed Abuseif, Deakin University
9
+ © 2025
10
+ """
11
+
12
+ import streamlit as st
13
+ import pandas as pd
14
+ import numpy as np
15
+ import os
16
+ import json
17
+ import io
18
+ import logging
19
+ import plotly.graph_objects as go
20
+ import plotly.express as px
21
+ from datetime import datetime
22
+ from typing import Dict, List, Any, Optional, Tuple, Union
23
+ import math
24
+
25
+ # Configure logging
26
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
27
+ logger = logging.getLogger(__name__)
28
+
29
+ # Define constants
30
+ MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
31
+ CLIMATE_ZONES = ["0A", "0B", "1A", "1B", "2A", "2B", "3A", "3B", "3C", "4A", "4B", "4C", "5A", "5B", "5C", "6A", "6B", "7", "8"]
32
+
33
+ class ClimateDataManager:
34
+ """Class for managing climate data from EPW files."""
35
+
36
+ def __init__(self):
37
+ """Initialize climate data manager."""
38
+ pass
39
+
40
+ def load_epw(self, uploaded_file) -> Dict[str, Any]:
41
+ """
42
+ Parse an EPW file and extract climate data.
43
+
44
+ Args:
45
+ uploaded_file: The uploaded EPW file object
46
+
47
+ Returns:
48
+ Dict containing parsed climate data
49
+ """
50
+ try:
51
+ # Read the EPW file
52
+ content = uploaded_file.getvalue().decode('utf-8')
53
+ lines = content.split('\n')
54
+
55
+ # Extract header information (first 8 lines)
56
+ header_lines = lines[:8]
57
+
58
+ # Parse location data from line 1
59
+ location_data = header_lines[0].split(',')
60
+
61
+ # Extract location information
62
+ location = {
63
+ "city": location_data[1].strip(),
64
+ "state_province": location_data[2].strip(),
65
+ "country": location_data[3].strip(),
66
+ "source": location_data[4].strip(),
67
+ "wmo": location_data[5].strip(),
68
+ "latitude": float(location_data[6]),
69
+ "longitude": float(location_data[7]),
70
+ "timezone": float(location_data[8]),
71
+ "elevation": float(location_data[9])
72
+ }
73
+
74
+ # Parse data rows (starting from line 9)
75
+ data_lines = lines[8:]
76
+
77
+ # Create a DataFrame from the data rows
78
+ data = []
79
+ for line in data_lines:
80
+ if line.strip(): # Skip empty lines
81
+ data.append(line.split(','))
82
+
83
+ # Create DataFrame
84
+ columns = [
85
+ "year", "month", "day", "hour", "minute", "data_source", "dry_bulb_temp",
86
+ "dew_point_temp", "relative_humidity", "atmospheric_pressure", "extraterrestrial_radiation",
87
+ "extraterrestrial_radiation_normal", "horizontal_infrared_radiation", "global_horizontal_radiation",
88
+ "direct_normal_radiation", "diffuse_horizontal_radiation", "global_horizontal_illuminance",
89
+ "direct_normal_illuminance", "diffuse_horizontal_illuminance", "zenith_luminance",
90
+ "wind_direction", "wind_speed", "total_sky_cover", "opaque_sky_cover", "visibility",
91
+ "ceiling_height", "present_weather_observation", "present_weather_codes",
92
+ "precipitable_water", "aerosol_optical_depth", "snow_depth", "days_since_last_snowfall",
93
+ "albedo", "liquid_precipitation_depth", "liquid_precipitation_quantity"
94
+ ]
95
+
96
+ df = pd.DataFrame(data, columns=columns)
97
+
98
+ # Convert numeric columns
99
+ numeric_columns = [
100
+ "dry_bulb_temp", "dew_point_temp", "relative_humidity", "atmospheric_pressure",
101
+ "global_horizontal_radiation", "direct_normal_radiation", "diffuse_horizontal_radiation",
102
+ "wind_direction", "wind_speed"
103
+ ]
104
+
105
+ for col in numeric_columns:
106
+ df[col] = pd.to_numeric(df[col], errors='coerce')
107
+
108
+ # Calculate design conditions
109
+ design_conditions = self._calculate_design_conditions(df)
110
+
111
+ # Process hourly data
112
+ hourly_data = self._process_hourly_data(df)
113
+
114
+ # Determine climate zone based on HDD and CDD
115
+ climate_zone = self._determine_climate_zone(
116
+ design_conditions["heating_degree_days"],
117
+ design_conditions["cooling_degree_days"]
118
+ )
119
+
120
+ # Create climate data dictionary
121
+ climate_data = {
122
+ "id": f"{location['city']}_{location['country']}".replace(" ", "_"),
123
+ "location": location,
124
+ "design_conditions": design_conditions,
125
+ "climate_zone": climate_zone,
126
+ "hourly_data": hourly_data,
127
+ "epw_filename": uploaded_file.name
128
+ }
129
+
130
+ logger.info(f"EPW file processed successfully: {uploaded_file.name}")
131
+ return climate_data
132
+
133
+ except Exception as e:
134
+ logger.error(f"Error processing EPW file: {str(e)}")
135
+ raise ValueError(f"Error processing EPW file: {str(e)}")
136
+
137
+ def _calculate_design_conditions(self, df: pd.DataFrame) -> Dict[str, Any]:
138
+ """
139
+ Calculate design conditions from EPW data.
140
+
141
+ Args:
142
+ df: DataFrame containing EPW data
143
+
144
+ Returns:
145
+ Dict containing design conditions
146
+ """
147
+ try:
148
+ # Convert temperatures from C to K if needed
149
+ temp_col = df["dry_bulb_temp"].astype(float)
150
+
151
+ # Calculate design temperatures
152
+ winter_design_temp = np.percentile(temp_col, 0.4) # 99.6% heating design temperature
153
+ summer_design_temp_db = np.percentile(temp_col, 99.6) # 0.4% cooling design temperature
154
+
155
+ # Calculate wet-bulb temperature (simplified)
156
+ rh_col = df["relative_humidity"].astype(float)
157
+ wet_bulb_temp = self._calculate_wet_bulb(temp_col, rh_col)
158
+ summer_design_temp_wb = np.percentile(wet_bulb_temp, 99.6) # 0.4% cooling wet-bulb temperature
159
+
160
+ # Calculate degree days
161
+ df["month"] = df["month"].astype(int)
162
+ df["day"] = df["day"].astype(int)
163
+ df["hour"] = df["hour"].astype(int)
164
+
165
+ # Group by day and calculate average temperature
166
+ df["date"] = pd.to_datetime(df[["year", "month", "day"]].astype(int))
167
+ daily_temps = df.groupby("date")["dry_bulb_temp"].mean()
168
+
169
+ # Calculate heating and cooling degree days (base 18°C)
170
+ heating_degree_days = sum(max(0, 18 - temp) for temp in daily_temps)
171
+ cooling_degree_days = sum(max(0, temp - 18) for temp in daily_temps)
172
+
173
+ # Calculate monthly average temperatures
174
+ monthly_temps = df.groupby(df["month"])["dry_bulb_temp"].mean().tolist()
175
+
176
+ # Calculate monthly average radiation
177
+ monthly_radiation = df.groupby(df["month"])["global_horizontal_radiation"].mean().tolist()
178
+
179
+ # Calculate summer daily temperature range
180
+ # Summer months: 6-8 for Northern Hemisphere, 12-2 for Southern Hemisphere
181
+ # Determine hemisphere based on latitude
182
+ latitude = df["latitude"].iloc[0] if "latitude" in df.columns else 0
183
+
184
+ if latitude >= 0: # Northern Hemisphere
185
+ summer_months = [6, 7, 8]
186
+ else: # Southern Hemisphere
187
+ summer_months = [12, 1, 2]
188
+
189
+ summer_data = df[df["month"].isin(summer_months)]
190
+ summer_daily_range = 0
191
+
192
+ if not summer_data.empty:
193
+ summer_daily_max = summer_data.groupby(["month", "day"])["dry_bulb_temp"].max()
194
+ summer_daily_min = summer_data.groupby(["month", "day"])["dry_bulb_temp"].min()
195
+ summer_daily_range = (summer_daily_max - summer_daily_min).mean()
196
+
197
+ # Calculate mean wind speed and pressure
198
+ wind_speed = df["wind_speed"].mean()
199
+ pressure = df["atmospheric_pressure"].mean()
200
+
201
+ return {
202
+ "winter_design_temp": round(winter_design_temp, 1),
203
+ "summer_design_temp_db": round(summer_design_temp_db, 1),
204
+ "summer_design_temp_wb": round(summer_design_temp_wb, 1),
205
+ "heating_degree_days": round(heating_degree_days),
206
+ "cooling_degree_days": round(cooling_degree_days),
207
+ "monthly_average_temps": [round(t, 1) for t in monthly_temps],
208
+ "monthly_average_radiation": [round(r, 1) for r in monthly_radiation],
209
+ "summer_daily_range": round(summer_daily_range, 1),
210
+ "wind_speed": round(wind_speed, 1),
211
+ "pressure": round(pressure)
212
+ }
213
+
214
+ except Exception as e:
215
+ logger.error(f"Error calculating design conditions: {str(e)}")
216
+ # Return default values if calculation fails
217
+ return {
218
+ "winter_design_temp": 0.0,
219
+ "summer_design_temp_db": 30.0,
220
+ "summer_design_temp_wb": 25.0,
221
+ "heating_degree_days": 0,
222
+ "cooling_degree_days": 0,
223
+ "monthly_average_temps": [20.0] * 12,
224
+ "monthly_average_radiation": [150.0] * 12,
225
+ "summer_daily_range": 8.0,
226
+ "wind_speed": 3.0,
227
+ "pressure": 101325.0
228
+ }
229
+
230
+ def _process_hourly_data(self, df: pd.DataFrame) -> List[Dict[str, Any]]:
231
+ """
232
+ Process hourly data from EPW DataFrame.
233
+
234
+ Args:
235
+ df: DataFrame containing EPW data
236
+
237
+ Returns:
238
+ List of hourly data records
239
+ """
240
+ hourly_data = []
241
+
242
+ try:
243
+ # Ensure numeric columns
244
+ df["dry_bulb_temp"] = pd.to_numeric(df["dry_bulb_temp"], errors='coerce')
245
+ df["relative_humidity"] = pd.to_numeric(df["relative_humidity"], errors='coerce')
246
+ df["atmospheric_pressure"] = pd.to_numeric(df["atmospheric_pressure"], errors='coerce')
247
+ df["global_horizontal_radiation"] = pd.to_numeric(df["global_horizontal_radiation"], errors='coerce')
248
+ df["direct_normal_radiation"] = pd.to_numeric(df["direct_normal_radiation"], errors='coerce')
249
+ df["diffuse_horizontal_radiation"] = pd.to_numeric(df["diffuse_horizontal_radiation"], errors='coerce')
250
+ df["wind_speed"] = pd.to_numeric(df["wind_speed"], errors='coerce')
251
+ df["wind_direction"] = pd.to_numeric(df["wind_direction"], errors='coerce')
252
+
253
+ # Convert to integers for month, day, hour
254
+ df["month"] = pd.to_numeric(df["month"], errors='coerce').astype('Int64')
255
+ df["day"] = pd.to_numeric(df["day"], errors='coerce').astype('Int64')
256
+ df["hour"] = pd.to_numeric(df["hour"], errors='coerce').astype('Int64')
257
+
258
+ # Process each row
259
+ for _, row in df.iterrows():
260
+ if pd.isna(row["month"]) or pd.isna(row["day"]) or pd.isna(row["hour"]) or pd.isna(row["dry_bulb_temp"]):
261
+ continue # Skip rows with missing critical data
262
+
263
+ record = {
264
+ "month": int(row["month"]),
265
+ "day": int(row["day"]),
266
+ "hour": int(row["hour"]),
267
+ "dry_bulb": float(row["dry_bulb_temp"]) if not pd.isna(row["dry_bulb_temp"]) else 20.0,
268
+ "relative_humidity": float(row["relative_humidity"]) if not pd.isna(row["relative_humidity"]) else 50.0,
269
+ "atmospheric_pressure": float(row["atmospheric_pressure"]) if not pd.isna(row["atmospheric_pressure"]) else 101325.0,
270
+ "global_horizontal_radiation": float(row["global_horizontal_radiation"]) if not pd.isna(row["global_horizontal_radiation"]) else 0.0,
271
+ "direct_normal_radiation": float(row["direct_normal_radiation"]) if not pd.isna(row["direct_normal_radiation"]) else 0.0,
272
+ "diffuse_horizontal_radiation": float(row["diffuse_horizontal_radiation"]) if not pd.isna(row["diffuse_horizontal_radiation"]) else 0.0,
273
+ "wind_speed": float(row["wind_speed"]) if not pd.isna(row["wind_speed"]) else 0.0,
274
+ "wind_direction": float(row["wind_direction"]) if not pd.isna(row["wind_direction"]) else 0.0
275
+ }
276
+ hourly_data.append(record)
277
+
278
+ # Check if we have the expected number of records (8760 hours in a year)
279
+ if len(hourly_data) < 8700: # Allow for some missing data
280
+ logger.warning(f"Hourly data has {len(hourly_data)} records instead of 8760. Some records may be missing.")
281
+
282
+ return hourly_data
283
+
284
+ except Exception as e:
285
+ logger.error(f"Error processing hourly data: {str(e)}")
286
+ return []
287
+
288
+ def _determine_climate_zone(self, hdd: float, cdd: float) -> str:
289
+ """
290
+ Determine ASHRAE climate zone based on heating and cooling degree days.
291
+
292
+ Args:
293
+ hdd: Heating degree days (base 18°C)
294
+ cdd: Cooling degree days (base 18°C)
295
+
296
+ Returns:
297
+ ASHRAE climate zone designation
298
+ """
299
+ # Simplified climate zone determination based on ASHRAE 169
300
+ if hdd >= 7000:
301
+ return "8"
302
+ elif hdd >= 5400:
303
+ return "7"
304
+ elif hdd >= 3900:
305
+ return "6A" if cdd <= 450 else "6B"
306
+ elif hdd >= 2700:
307
+ return "5A" if cdd <= 900 else ("5B" if cdd <= 1800 else "5C")
308
+ elif hdd >= 1800:
309
+ return "4A" if cdd <= 1800 else ("4B" if cdd <= 2700 else "4C")
310
+ elif hdd >= 900:
311
+ return "3A" if cdd <= 2700 else ("3B" if cdd <= 3600 else "3C")
312
+ elif hdd >= 0:
313
+ return "2A" if cdd <= 3600 else "2B"
314
+ else:
315
+ return "1A" if cdd <= 4500 else "1B"
316
+
317
+ @staticmethod
318
+ def _calculate_wet_bulb(dry_bulb: np.ndarray, relative_humidity: np.ndarray) -> np.ndarray:
319
+ """
320
+ Calculate wet-bulb temperature using a simplified formula.
321
+
322
+ Args:
323
+ dry_bulb: Dry-bulb temperature in °C
324
+ relative_humidity: Relative humidity in %
325
+
326
+ Returns:
327
+ Wet-bulb temperature in °C
328
+ """
329
+ # Simplified formula for wet-bulb temperature
330
+ wet_bulb = dry_bulb * np.arctan(0.151977 * np.sqrt(relative_humidity + 8.313659)) + \
331
+ np.arctan(dry_bulb + relative_humidity) - np.arctan(relative_humidity - 1.676331) + \
332
+ 0.00391838 * (relative_humidity)**(3/2) * np.arctan(0.023101 * relative_humidity) - 4.686035
333
+
334
+ # Ensure wet-bulb is not higher than dry-bulb
335
+ wet_bulb = np.minimum(wet_bulb, dry_bulb)
336
+
337
+ return wet_bulb
338
+
339
+ def display_climate_page():
340
+ """
341
+ Display the climate data page.
342
+ This is the main function called by main.py when the Climate Data page is selected.
343
+ """
344
+ st.title("Climate Data and Design Requirements")
345
+
346
+ # Display help information in an expandable section
347
+ with st.expander("Help & Information"):
348
+ display_climate_help()
349
+
350
+ # Initialize climate data manager
351
+ climate_manager = ClimateDataManager()
352
+
353
+ # Create tabs for different sections
354
+ tab1, tab2 = st.tabs(["EPW Data Input", "Climate Summary"])
355
+
356
+ # EPW Data Input tab
357
+ with tab1:
358
+ st.subheader("Upload EPW Weather File")
359
+
360
+ # File uploader for EPW files
361
+ uploaded_file = st.file_uploader(
362
+ "Upload EPW File",
363
+ type=["epw"],
364
+ help="Upload an EnergyPlus Weather (EPW) file for your location."
365
+ )
366
+
367
+ if uploaded_file is not None:
368
+ try:
369
+ # Process the uploaded EPW file
370
+ with st.spinner("Processing EPW file..."):
371
+ climate_data = climate_manager.load_epw(uploaded_file)
372
+
373
+ # Store climate data in session state
374
+ st.session_state.project_data["climate_data"] = climate_data
375
+
376
+ # Show success message
377
+ st.success(f"EPW file processed successfully: {uploaded_file.name}")
378
+
379
+ # Display basic location information
380
+ location = climate_data["location"]
381
+ st.subheader("Location Information")
382
+
383
+ col1, col2 = st.columns(2)
384
+ with col1:
385
+ st.write(f"**City:** {location['city']}")
386
+ st.write(f"**State/Province:** {location['state_province']}")
387
+ st.write(f"**Country:** {location['country']}")
388
+
389
+ with col2:
390
+ st.write(f"**Latitude:** {location['latitude']}°")
391
+ st.write(f"**Longitude:** {location['longitude']}°")
392
+ st.write(f"**Elevation:** {location['elevation']} m")
393
+
394
+ # Automatically switch to Climate Summary tab
395
+ st.button("View Climate Summary", on_click=lambda: st.session_state.update({"climate_tab": "Climate Summary"}))
396
+
397
+ except Exception as e:
398
+ st.error(f"Error processing EPW file: {str(e)}")
399
+ logger.error(f"Error processing EPW file: {str(e)}")
400
+
401
+ # Climate Summary tab
402
+ with tab2:
403
+ if "climate_data" in st.session_state.project_data and st.session_state.project_data["climate_data"]:
404
+ display_climate_summary(st.session_state.project_data["climate_data"])
405
+ else:
406
+ st.info("Please upload an EPW file in the 'EPW Data Input' tab to view climate summary.")
407
+
408
+ # Navigation buttons
409
+ col1, col2 = st.columns(2)
410
+
411
+ with col1:
412
+ if st.button("Back to Building Information", key="back_to_building"):
413
+ st.session_state.current_page = "Building Information"
414
+ st.rerun()
415
+
416
+ with col2:
417
+ if st.button("Continue to Material Library", key="continue_to_material"):
418
+ # Check if climate data is available before proceeding
419
+ if "climate_data" not in st.session_state.project_data or not st.session_state.project_data["climate_data"]:
420
+ st.warning("Please upload an EPW file before continuing.")
421
+ else:
422
+ st.session_state.current_page = "Material Library"
423
+ st.rerun()
424
+
425
+ def display_climate_summary(climate_data: Dict[str, Any]):
426
+ """
427
+ Display climate summary information.
428
+
429
+ Args:
430
+ climate_data: Dictionary containing climate data
431
+ """
432
+ st.subheader("Climate Summary")
433
+
434
+ # Extract design conditions
435
+ design = climate_data["design_conditions"]
436
+ location = climate_data["location"]
437
+
438
+ # Display climate zone
439
+ st.markdown(f"### ASHRAE Climate Zone: {climate_data['climate_zone']}")
440
+
441
+ # Create columns for layout
442
+ col1, col2 = st.columns(2)
443
+
444
+ # Design temperatures
445
+ with col1:
446
+ st.subheader("Design Temperatures")
447
+ st.write(f"**Winter Design Temperature:** {design['winter_design_temp']}°C")
448
+ st.write(f"**Summer Design Temperature (DB):** {design['summer_design_temp_db']}°C")
449
+ st.write(f"**Summer Design Temperature (WB):** {design['summer_design_temp_wb']}°C")
450
+ st.write(f"**Summer Daily Temperature Range:** {design['summer_daily_range']}°C")
451
+
452
+ # Degree days
453
+ with col2:
454
+ st.subheader("Degree Days")
455
+ st.write(f"**Heating Degree Days (Base 18°C):** {design['heating_degree_days']}")
456
+ st.write(f"**Cooling Degree Days (Base 18°C):** {design['cooling_degree_days']}")
457
+ st.write(f"**Average Wind Speed:** {design['wind_speed']} m/s")
458
+ st.write(f"**Average Atmospheric Pressure:** {design['pressure']} Pa")
459
+
460
+ # Monthly temperature chart
461
+ st.subheader("Monthly Average Temperatures")
462
+
463
+ fig_temp = go.Figure()
464
+ fig_temp.add_trace(go.Scatter(
465
+ x=MONTHS,
466
+ y=design["monthly_average_temps"],
467
+ mode='lines+markers',
468
+ name='Temperature',
469
+ line=dict(color='firebrick', width=2),
470
+ marker=dict(size=8)
471
+ ))
472
+
473
+ fig_temp.update_layout(
474
+ xaxis_title="Month",
475
+ yaxis_title="Temperature (°C)",
476
+ height=400,
477
+ margin=dict(l=20, r=20, t=30, b=20),
478
+ )
479
+
480
+ st.plotly_chart(fig_temp, use_container_width=True)
481
+
482
+ # Monthly radiation chart
483
+ st.subheader("Monthly Average Solar Radiation")
484
+
485
+ fig_rad = go.Figure()
486
+ fig_rad.add_trace(go.Bar(
487
+ x=MONTHS,
488
+ y=design["monthly_average_radiation"],
489
+ name='Global Horizontal Radiation',
490
+ marker_color='gold'
491
+ ))
492
+
493
+ fig_rad.update_layout(
494
+ xaxis_title="Month",
495
+ yaxis_title="Radiation (W/m²)",
496
+ height=400,
497
+ margin=dict(l=20, r=20, t=30, b=20),
498
+ )
499
+
500
+ st.plotly_chart(fig_rad, use_container_width=True)
501
+
502
+ # Display hourly data statistics
503
+ st.subheader("Hourly Data Statistics")
504
+
505
+ if "hourly_data" in climate_data and climate_data["hourly_data"]:
506
+ hourly_count = len(climate_data["hourly_data"])
507
+ st.write(f"**Number of Hourly Records:** {hourly_count}")
508
+
509
+ if hourly_count < 8760:
510
+ st.warning(f"Expected 8760 hourly records for a full year, but found {hourly_count}. Some data may be missing.")
511
+ else:
512
+ st.warning("No hourly data available.")
513
+
514
+ def display_climate_help():
515
+ """Display help information for the climate data page."""
516
+ st.markdown("""
517
+ ### Climate Data Help
518
+
519
+ This section allows you to upload and process weather data for your location, which is essential for accurate HVAC load calculations.
520
+
521
+ **EPW Files:**
522
+
523
+ EPW (EnergyPlus Weather) files contain hourly weather data for a specific location, including:
524
+
525
+ * Dry-bulb temperature
526
+ * Relative humidity
527
+ * Solar radiation (direct and diffuse)
528
+ * Wind speed and direction
529
+ * Atmospheric pressure
530
+
531
+ **Where to Find EPW Files:**
532
+
533
+ * [EnergyPlus Weather Data](https://energyplus.net/weather)
534
+ * [Climate.OneBuilding.Org](https://climate.onebuilding.org/)
535
+ * [ASHRAE International Weather for Energy Calculations (IWEC)](https://www.ashrae.org/technical-resources/bookstore/ashrae-international-weather-files-for-energy-calculations-2-0-iwec2)
536
+
537
+ **Climate Summary:**
538
+
539
+ After uploading an EPW file, the Climate Summary tab will display:
540
+
541
+ * ASHRAE Climate Zone
542
+ * Design temperatures for heating and cooling
543
+ * Heating and cooling degree days
544
+ * Monthly average temperatures and solar radiation
545
+ * Hourly data statistics
546
+
547
+ This information will be used throughout the HVAC load calculation process.
548
+ """)
app/components.py ADDED
@@ -0,0 +1,504 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ HVAC Load Calculator - Building Components Module
3
+
4
+ This module handles the definition of building envelope components (walls, roofs, floors,
5
+ windows, doors, skylights) for the HVAC Load Calculator application. It allows users to
6
+ assign constructions and fenestrations to specific components, define their areas,
7
+ orientations, and other relevant properties.
8
+
9
+ Developed by: Dr Majed Abuseif, Deakin University
10
+ © 2025
11
+ """
12
+
13
+ import streamlit as st
14
+ import pandas as pd
15
+ import numpy as np
16
+ import json
17
+ import logging
18
+ import uuid
19
+ from typing import Dict, List, Any, Optional, Tuple, Union
20
+
21
+ # Configure logging
22
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
23
+ logger = logging.getLogger(__name__)
24
+
25
+ # Define constants
26
+ COMPONENT_TYPES = ["Wall", "Roof", "Floor", "Window", "Door", "Skylight"]
27
+ ORIENTATION_OPTIONS = ["A (North)", "B (South)", "C (East)", "D (West)", "Horizontal"]
28
+
29
+ def display_components_page():
30
+ """
31
+ Display the building components page.
32
+ This is the main function called by main.py when the Building Components page is selected.
33
+ """
34
+ st.title("Building Components")
35
+
36
+ # Display help information in an expandable section
37
+ with st.expander("Help & Information"):
38
+ display_components_help()
39
+
40
+ # Initialize components in session state if not present
41
+ initialize_components()
42
+
43
+ # Create tabs for different component types
44
+ tabs = st.tabs(COMPONENT_TYPES)
45
+
46
+ for i, component_type in enumerate(COMPONENT_TYPES):
47
+ with tabs[i]:
48
+ display_component_tab(component_type)
49
+
50
+ # Navigation buttons
51
+ col1, col2 = st.columns(2)
52
+
53
+ with col1:
54
+ if st.button("Back to Construction", key="back_to_construction"):
55
+ st.session_state.current_page = "Construction"
56
+ st.rerun()
57
+
58
+ with col2:
59
+ if st.button("Continue to Internal Loads", key="continue_to_internal_loads"):
60
+ st.session_state.current_page = "Internal Loads"
61
+ st.rerun()
62
+
63
+ def initialize_components():
64
+ """Initialize components in session state if not present."""
65
+ if "components" not in st.session_state.project_data:
66
+ st.session_state.project_data["components"] = {
67
+ "walls": [],
68
+ "roofs": [],
69
+ "floors": [],
70
+ "windows": [],
71
+ "doors": [],
72
+ "skylights": []
73
+ }
74
+
75
+ # Initialize component editor state
76
+ if "component_editor" not in st.session_state:
77
+ st.session_state.component_editor = {
78
+ "type": COMPONENT_TYPES[0],
79
+ "name": "",
80
+ "construction": "",
81
+ "fenestration": "",
82
+ "area": 10.0,
83
+ "orientation": ORIENTATION_OPTIONS[0],
84
+ "tilt": 90.0,
85
+ "parent_component": "",
86
+ "edit_mode": False,
87
+ "original_id": ""
88
+ }
89
+
90
+ def display_component_tab(component_type: str):
91
+ """
92
+ Display the content for a specific component type tab.
93
+
94
+ Args:
95
+ component_type: The type of component (e.g., "Wall", "Window")
96
+ """
97
+ st.subheader(f"{component_type}s")
98
+
99
+ # Get components of this type
100
+ component_key = component_type.lower() + "s"
101
+ components = st.session_state.project_data["components"].get(component_key, [])
102
+
103
+ # Display existing components
104
+ if components:
105
+ display_component_list(component_type, components)
106
+ else:
107
+ st.info(f"No {component_type.lower()}s added yet. Use the editor below to add components.")
108
+
109
+ # Component Editor
110
+ st.markdown("---")
111
+ st.subheader(f"{component_type} Editor")
112
+ display_component_editor(component_type)
113
+
114
+ def display_component_list(component_type: str, components: List[Dict[str, Any]]):
115
+ """
116
+ Display the list of existing components for a given type.
117
+
118
+ Args:
119
+ component_type: The type of component
120
+ components: List of component dictionaries
121
+ """
122
+ # Create a DataFrame for display
123
+ data = []
124
+ for i, comp in enumerate(components):
125
+ record = {
126
+ "#": i + 1,
127
+ "Name": comp["name"],
128
+ "Area (m²)": comp["area"],
129
+ "Orientation": comp["orientation"],
130
+ "Tilt (°) ": comp["tilt"]
131
+ }
132
+
133
+ if component_type in ["Wall", "Roof", "Floor"]:
134
+ record["Construction"] = comp["construction"]
135
+ else:
136
+ record["Fenestration"] = comp["fenestration"]
137
+ record["Parent Component"] = comp.get("parent_component", "N/A")
138
+
139
+ data.append(record)
140
+
141
+ df = pd.DataFrame(data)
142
+ st.dataframe(df, use_container_width=True, hide_index=True)
143
+
144
+ # Edit and delete options
145
+ col1, col2 = st.columns(2)
146
+
147
+ with col1:
148
+ selected_index = st.selectbox(
149
+ f"Select {component_type} # to Edit",
150
+ range(1, len(components) + 1),
151
+ key=f"edit_{component_type}_selector"
152
+ )
153
+
154
+ if st.button(f"Edit {component_type}", key=f"edit_{component_type}_button"):
155
+ # Load component data into editor
156
+ component_data = components[selected_index - 1]
157
+ st.session_state.component_editor = {
158
+ "type": component_type,
159
+ "name": component_data["name"],
160
+ "construction": component_data.get("construction", ""),
161
+ "fenestration": component_data.get("fenestration", ""),
162
+ "area": component_data["area"],
163
+ "orientation": component_data["orientation"],
164
+ "tilt": component_data["tilt"],
165
+ "parent_component": component_data.get("parent_component", ""),
166
+ "edit_mode": True,
167
+ "original_id": component_data["id"]
168
+ }
169
+ st.success(f"{component_type} \'{component_data['name']}\' loaded for editing.")
170
+ st.rerun()
171
+
172
+ with col2:
173
+ selected_index_delete = st.selectbox(
174
+ f"Select {component_type} # to Delete",
175
+ range(1, len(components) + 1),
176
+ key=f"delete_{component_type}_selector"
177
+ )
178
+
179
+ if st.button(f"Delete {component_type}", key=f"delete_{component_type}_button"):
180
+ # Delete component
181
+ component_key = component_type.lower() + "s"
182
+ deleted_component = st.session_state.project_data["components"][component_key].pop(selected_index_delete - 1)
183
+ st.success(f"{component_type} \'{deleted_component['name']}\' deleted.")
184
+ logger.info(f"Deleted {component_type} \'{deleted_component['name']}\'")
185
+ st.rerun()
186
+
187
+ def display_component_editor(component_type: str):
188
+ """
189
+ Display the editor form for a specific component type.
190
+
191
+ Args:
192
+ component_type: The type of component
193
+ """
194
+ # Get available constructions and fenestrations
195
+ available_constructions = get_available_constructions()
196
+ available_fenestrations = get_available_fenestrations()
197
+ available_walls = get_available_walls()
198
+
199
+ # Check if the editor is currently set to this component type
200
+ if st.session_state.component_editor["type"] != component_type and not st.session_state.component_editor["edit_mode"]:
201
+ reset_component_editor(component_type)
202
+
203
+ with st.form(f"{component_type}_editor_form"):
204
+ # Component name
205
+ name = st.text_input(
206
+ "Component Name",
207
+ value=st.session_state.component_editor["name"],
208
+ help="Enter a unique name for this component."
209
+ )
210
+
211
+ # Create two columns for layout
212
+ col1, col2 = st.columns(2)
213
+
214
+ with col1:
215
+ # Construction or Fenestration selection
216
+ if component_type in ["Wall", "Roof", "Floor"]:
217
+ construction = st.selectbox(
218
+ "Construction",
219
+ list(available_constructions.keys()),
220
+ index=list(available_constructions.keys()).index(st.session_state.component_editor["construction"]) if st.session_state.component_editor["construction"] in available_constructions else 0,
221
+ help="Select the construction assembly for this component."
222
+ )
223
+ fenestration = ""
224
+ else:
225
+ fenestration = st.selectbox(
226
+ "Fenestration",
227
+ list(available_fenestrations.keys()),
228
+ index=list(available_fenestrations.keys()).index(st.session_state.component_editor["fenestration"]) if st.session_state.component_editor["fenestration"] in available_fenestrations else 0,
229
+ help="Select the fenestration type for this component."
230
+ )
231
+ construction = ""
232
+
233
+ # Area
234
+ area = st.number_input(
235
+ "Area (m²)",
236
+ min_value=0.1,
237
+ max_value=10000.0,
238
+ value=float(st.session_state.component_editor["area"]),
239
+ format="%.2f",
240
+ help="Surface area of the component in square meters."
241
+ )
242
+
243
+ with col2:
244
+ # Orientation
245
+ orientation = st.selectbox(
246
+ "Orientation",
247
+ ORIENTATION_OPTIONS,
248
+ index=ORIENTATION_OPTIONS.index(st.session_state.component_editor["orientation"]) if st.session_state.component_editor["orientation"] in ORIENTATION_OPTIONS else 0,
249
+ help="Orientation of the component relative to the building orientation angle."
250
+ )
251
+
252
+ # Tilt
253
+ tilt = st.number_input(
254
+ "Tilt (°) ",
255
+ min_value=0.0,
256
+ max_value=180.0,
257
+ value=float(st.session_state.component_editor["tilt"]),
258
+ format="%.1f",
259
+ help="Tilt angle of the component relative to horizontal (0° = horizontal, 90° = vertical)."
260
+ )
261
+
262
+ # Parent component (for windows, doors, skylights)
263
+ parent_component = ""
264
+ if component_type in ["Window", "Door", "Skylight"]:
265
+ parent_component = st.selectbox(
266
+ "Parent Component (Wall/Roof)",
267
+ list(available_walls.keys()),
268
+ index=list(available_walls.keys()).index(st.session_state.component_editor["parent_component"]) if st.session_state.component_editor["parent_component"] in available_walls else 0,
269
+ help="Select the wall or roof component this fenestration belongs to."
270
+ )
271
+
272
+ # Form submission buttons
273
+ col1, col2 = st.columns(2)
274
+
275
+ with col1:
276
+ submit_button = st.form_submit_button("Save Component")
277
+
278
+ with col2:
279
+ clear_button = st.form_submit_button("Clear Form")
280
+
281
+ # Handle form submission
282
+ if submit_button:
283
+ # Validate inputs
284
+ validation_errors = validate_component(
285
+ component_type, name, construction, fenestration, area, orientation, tilt,
286
+ parent_component, st.session_state.component_editor["edit_mode"],
287
+ st.session_state.component_editor["original_id"]
288
+ )
289
+
290
+ if validation_errors:
291
+ # Display validation errors
292
+ for error in validation_errors:
293
+ st.error(error)
294
+ else:
295
+ # Create component data
296
+ component_data = {
297
+ "id": st.session_state.component_editor["original_id"] if st.session_state.component_editor["edit_mode"] else str(uuid.uuid4()),
298
+ "name": name,
299
+ "type": component_type,
300
+ "area": area,
301
+ "orientation": orientation,
302
+ "tilt": tilt
303
+ }
304
+
305
+ if component_type in ["Wall", "Roof", "Floor"]:
306
+ component_data["construction"] = construction
307
+ else:
308
+ component_data["fenestration"] = fenestration
309
+ component_data["parent_component"] = parent_component
310
+
311
+ # Handle edit mode
312
+ component_key = component_type.lower() + "s"
313
+ if st.session_state.component_editor["edit_mode"]:
314
+ # Find and update the component
315
+ components = st.session_state.project_data["components"][component_key]
316
+ for i, comp in enumerate(components):
317
+ if comp["id"] == st.session_state.component_editor["original_id"]:
318
+ components[i] = component_data
319
+ break
320
+ st.success(f"{component_type} \'{name}\' updated successfully.")
321
+ logger.info(f"Updated {component_type} \'{name}\'")
322
+ else:
323
+ # Add new component
324
+ st.session_state.project_data["components"][component_key].append(component_data)
325
+ st.success(f"{component_type} \'{name}\' added successfully.")
326
+ logger.info(f"Added new {component_type} \'{name}\'")
327
+
328
+ # Reset editor
329
+ reset_component_editor(component_type)
330
+ st.rerun()
331
+
332
+ # Handle clear button
333
+ if clear_button:
334
+ reset_component_editor(component_type)
335
+ st.rerun()
336
+
337
+ def get_available_constructions() -> Dict[str, Any]:
338
+ """
339
+ Get all available constructions from both library and project.
340
+
341
+ Returns:
342
+ Dict of construction name to construction properties
343
+ """
344
+ available_constructions = {}
345
+
346
+ # Add library constructions
347
+ if "constructions" in st.session_state.project_data and "library" in st.session_state.project_data["constructions"]:
348
+ available_constructions.update(st.session_state.project_data["constructions"]["library"])
349
+
350
+ # Add project constructions
351
+ if "constructions" in st.session_state.project_data and "project" in st.session_state.project_data["constructions"]:
352
+ available_constructions.update(st.session_state.project_data["constructions"]["project"])
353
+
354
+ return available_constructions
355
+
356
+ def get_available_fenestrations() -> Dict[str, Any]:
357
+ """
358
+ Get all available fenestrations from both library and project.
359
+
360
+ Returns:
361
+ Dict of fenestration name to fenestration properties
362
+ """
363
+ available_fenestrations = {}
364
+
365
+ # Add library fenestrations
366
+ if "fenestrations" in st.session_state.project_data and "library" in st.session_state.project_data["fenestrations"]:
367
+ available_fenestrations.update(st.session_state.project_data["fenestrations"]["library"])
368
+
369
+ # Add project fenestrations
370
+ if "fenestrations" in st.session_state.project_data and "project" in st.session_state.project_data["fenestrations"]:
371
+ available_fenestrations.update(st.session_state.project_data["fenestrations"]["project"])
372
+
373
+ return available_fenestrations
374
+
375
+ def get_available_walls() -> Dict[str, Any]:
376
+ """
377
+ Get all available wall components.
378
+
379
+ Returns:
380
+ Dict of wall name to wall properties
381
+ """
382
+ available_walls = {}
383
+ if "components" in st.session_state.project_data and "walls" in st.session_state.project_data["components"]:
384
+ for wall in st.session_state.project_data["components"]["walls"]:
385
+ available_walls[wall["name"]] = wall
386
+ return available_walls
387
+
388
+ def validate_component(
389
+ component_type: str, name: str, construction: str, fenestration: str, area: float,
390
+ orientation: str, tilt: float, parent_component: str, edit_mode: bool, original_id: str
391
+ ) -> List[str]:
392
+ """
393
+ Validate component inputs.
394
+
395
+ Args:
396
+ component_type: Type of component
397
+ name: Component name
398
+ construction: Selected construction name
399
+ fenestration: Selected fenestration name
400
+ area: Component area
401
+ orientation: Component orientation
402
+ tilt: Component tilt angle
403
+ parent_component: Parent component name (for fenestrations)
404
+ edit_mode: Whether in edit mode
405
+ original_id: Original ID if in edit mode
406
+
407
+ Returns:
408
+ List of validation error messages, empty if all inputs are valid
409
+ """
410
+ errors = []
411
+
412
+ # Validate name
413
+ if not name or name.strip() == "":
414
+ errors.append("Component name is required.")
415
+
416
+ # Check for name uniqueness within the same component type
417
+ component_key = component_type.lower() + "s"
418
+ components = st.session_state.project_data["components"].get(component_key, [])
419
+
420
+ for comp in components:
421
+ if comp["name"] == name and (not edit_mode or comp["id"] != original_id):
422
+ errors.append(f"{component_type} name \'{name}\' already exists.")
423
+ break
424
+
425
+ # Validate construction or fenestration selection
426
+ if component_type in ["Wall", "Roof", "Floor"] and not construction:
427
+ errors.append("Construction selection is required.")
428
+ elif component_type in ["Window", "Door", "Skylight"] and not fenestration:
429
+ errors.append("Fenestration selection is required.")
430
+
431
+ # Validate area
432
+ if area <= 0:
433
+ errors.append("Area must be greater than zero.")
434
+
435
+ # Validate orientation
436
+ if orientation not in ORIENTATION_OPTIONS:
437
+ errors.append("Please select a valid orientation.")
438
+
439
+ # Validate tilt
440
+ if tilt < 0 or tilt > 180:
441
+ errors.append("Tilt angle must be between 0° and 180°.")
442
+
443
+ # Validate parent component for fenestrations
444
+ if component_type in ["Window", "Door", "Skylight"] and not parent_component:
445
+ errors.append("Parent component selection is required for fenestrations.")
446
+
447
+ return errors
448
+
449
+ def reset_component_editor(component_type: str):
450
+ """
451
+ Reset the component editor to default values for the given type.
452
+
453
+ Args:
454
+ component_type: The type of component
455
+ """
456
+ st.session_state.component_editor = {
457
+ "type": component_type,
458
+ "name": "",
459
+ "construction": "",
460
+ "fenestration": "",
461
+ "area": 10.0,
462
+ "orientation": ORIENTATION_OPTIONS[0],
463
+ "tilt": 90.0 if component_type == "Wall" else 0.0,
464
+ "parent_component": "",
465
+ "edit_mode": False,
466
+ "original_id": ""
467
+ }
468
+
469
+ def display_components_help():
470
+ """
471
+ Display help information for the building components page.
472
+ """
473
+ st.markdown("""
474
+ ### Building Components Help
475
+
476
+ This section allows you to define the individual components of your building envelope, such as walls, roofs, floors, windows, doors, and skylights.
477
+
478
+ **Key Concepts:**
479
+
480
+ * **Component**: A specific part of the building envelope (e.g., "North Wall", "Living Room Window").
481
+ * **Construction**: The multi-layer assembly assigned to opaque components (walls, roofs, floors).
482
+ * **Fenestration**: The glazing or door system assigned to transparent or operable components (windows, doors, skylights).
483
+ * **Area**: The surface area of the component in square meters.
484
+ * **Orientation**: The direction the component faces relative to the building orientation angle (A=North, B=South, C=East, D=West, Horizontal).
485
+ * **Tilt**: The angle of the component relative to horizontal (0° = horizontal, 90° = vertical).
486
+ * **Parent Component**: For fenestrations, the wall or roof component they are part of.
487
+
488
+ **Workflow:**
489
+
490
+ 1. Select the tab for the component type you want to define (e.g., "Walls").
491
+ 2. Use the editor to add new components:
492
+ * Give the component a unique name.
493
+ * Select the appropriate construction (for walls, roofs, floors) or fenestration (for windows, doors, skylights) from your project library.
494
+ * Enter the area, orientation, and tilt.
495
+ * For fenestrations, select the parent wall or roof component.
496
+ 3. Save the component.
497
+ 4. Repeat for all building envelope components.
498
+ 5. Edit or delete components as needed using the controls above the editor.
499
+
500
+ **Important:**
501
+
502
+ * Ensure all constructions and fenestrations you need are defined in the Material Library and Construction pages before defining components.
503
+ * The accuracy of your load calculations depends heavily on the correct definition of these components.
504
+ """)
app/construction.py ADDED
@@ -0,0 +1,823 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ HVAC Load Calculator - Construction Module
3
+
4
+ This module handles the construction assembly functionality of the HVAC Load Calculator application,
5
+ allowing users to create and manage multi-layer constructions for walls, roofs, and floors.
6
+ It integrates with the material library to select materials for each layer and calculates
7
+ overall thermal properties.
8
+
9
+ Developed by: Dr Majed Abuseif, Deakin University
10
+ © 2025
11
+ """
12
+
13
+ import streamlit as st
14
+ import pandas as pd
15
+ import numpy as np
16
+ import json
17
+ import logging
18
+ import uuid
19
+ from typing import Dict, List, Any, Optional, Tuple, Union
20
+
21
+ # Configure logging
22
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
23
+ logger = logging.getLogger(__name__)
24
+
25
+ # Define constants
26
+ CONSTRUCTION_TYPES = ["Wall", "Roof", "Floor"]
27
+
28
+ # Default library constructions
29
+ DEFAULT_CONSTRUCTIONS = {
30
+ "Brick Cavity Wall": {
31
+ "type": "Wall",
32
+ "layers": [
33
+ {"material": "Brick", "thickness": 0.1},
34
+ {"material": "Air Gap", "thickness": 0.05},
35
+ {"material": "Mineral Wool", "thickness": 0.1},
36
+ {"material": "Concrete Block", "thickness": 0.1},
37
+ {"material": "Gypsum Board", "thickness": 0.0125}
38
+ ],
39
+ "u_value": 0.35, # W/m²·K
40
+ "r_value": 2.86, # m²·K/W
41
+ "thermal_mass": 220.0, # kJ/m²·K
42
+ "embodied_carbon": 120.0, # kg CO2e/m²
43
+ "cost": 180.0 # USD/m²
44
+ },
45
+ "Insulated Concrete Wall": {
46
+ "type": "Wall",
47
+ "layers": [
48
+ {"material": "Concrete", "thickness": 0.15},
49
+ {"material": "EPS Insulation", "thickness": 0.1},
50
+ {"material": "Gypsum Board", "thickness": 0.0125}
51
+ ],
52
+ "u_value": 0.32,
53
+ "r_value": 3.13,
54
+ "thermal_mass": 360.0,
55
+ "embodied_carbon": 95.0,
56
+ "cost": 150.0
57
+ },
58
+ "Timber Frame Wall": {
59
+ "type": "Wall",
60
+ "layers": [
61
+ {"material": "Wood (Pine)", "thickness": 0.02},
62
+ {"material": "Glass Fiber Insulation", "thickness": 0.15},
63
+ {"material": "Plywood", "thickness": 0.012},
64
+ {"material": "Gypsum Board", "thickness": 0.0125}
65
+ ],
66
+ "u_value": 0.25,
67
+ "r_value": 4.0,
68
+ "thermal_mass": 45.0,
69
+ "embodied_carbon": 35.0,
70
+ "cost": 120.0
71
+ },
72
+ "Concrete Flat Roof": {
73
+ "type": "Roof",
74
+ "layers": [
75
+ {"material": "Concrete", "thickness": 0.15},
76
+ {"material": "Polyurethane Insulation", "thickness": 0.15},
77
+ {"material": "Waterproofing Membrane", "thickness": 0.005}
78
+ ],
79
+ "u_value": 0.18,
80
+ "r_value": 5.56,
81
+ "thermal_mass": 360.0,
82
+ "embodied_carbon": 110.0,
83
+ "cost": 200.0
84
+ },
85
+ "Timber Pitched Roof": {
86
+ "type": "Roof",
87
+ "layers": [
88
+ {"material": "Roof Tiles", "thickness": 0.02},
89
+ {"material": "Waterproofing Membrane", "thickness": 0.002},
90
+ {"material": "Wood (Pine)", "thickness": 0.025},
91
+ {"material": "Glass Fiber Insulation", "thickness": 0.2},
92
+ {"material": "Gypsum Board", "thickness": 0.0125}
93
+ ],
94
+ "u_value": 0.16,
95
+ "r_value": 6.25,
96
+ "thermal_mass": 40.0,
97
+ "embodied_carbon": 45.0,
98
+ "cost": 160.0
99
+ },
100
+ "Concrete Floor Slab": {
101
+ "type": "Floor",
102
+ "layers": [
103
+ {"material": "Ceramic Tile", "thickness": 0.01},
104
+ {"material": "Concrete", "thickness": 0.1},
105
+ {"material": "EPS Insulation", "thickness": 0.1},
106
+ {"material": "Damp Proof Membrane", "thickness": 0.002},
107
+ {"material": "Gravel", "thickness": 0.15}
108
+ ],
109
+ "u_value": 0.25,
110
+ "r_value": 4.0,
111
+ "thermal_mass": 240.0,
112
+ "embodied_carbon": 85.0,
113
+ "cost": 140.0
114
+ },
115
+ "Timber Floor": {
116
+ "type": "Floor",
117
+ "layers": [
118
+ {"material": "Wood (Pine)", "thickness": 0.02},
119
+ {"material": "Air Gap", "thickness": 0.05},
120
+ {"material": "Glass Fiber Insulation", "thickness": 0.15},
121
+ {"material": "Plywood", "thickness": 0.018}
122
+ ],
123
+ "u_value": 0.22,
124
+ "r_value": 4.55,
125
+ "thermal_mass": 30.0,
126
+ "embodied_carbon": 25.0,
127
+ "cost": 110.0
128
+ }
129
+ }
130
+
131
+ # Additional materials needed for constructions but not in material library
132
+ ADDITIONAL_MATERIALS = {
133
+ "Air Gap": {
134
+ "category": "Sub-Structural Materials",
135
+ "thermal_conductivity": 0.026,
136
+ "density": 1.2,
137
+ "specific_heat": 1000.0,
138
+ "thickness_range": [0.01, 0.1],
139
+ "default_thickness": 0.05,
140
+ "embodied_carbon": 0.0,
141
+ "cost": 0.0
142
+ },
143
+ "Waterproofing Membrane": {
144
+ "category": "Finishing Materials",
145
+ "thermal_conductivity": 0.17,
146
+ "density": 1100.0,
147
+ "specific_heat": 1000.0,
148
+ "thickness_range": [0.001, 0.01],
149
+ "default_thickness": 0.002,
150
+ "embodied_carbon": 4.2,
151
+ "cost": 15.0
152
+ },
153
+ "Damp Proof Membrane": {
154
+ "category": "Sub-Structural Materials",
155
+ "thermal_conductivity": 0.17,
156
+ "density": 1100.0,
157
+ "specific_heat": 1000.0,
158
+ "thickness_range": [0.001, 0.005],
159
+ "default_thickness": 0.002,
160
+ "embodied_carbon": 4.0,
161
+ "cost": 12.0
162
+ },
163
+ "Roof Tiles": {
164
+ "category": "Finishing Materials",
165
+ "thermal_conductivity": 1.0,
166
+ "density": 1900.0,
167
+ "specific_heat": 800.0,
168
+ "thickness_range": [0.01, 0.03],
169
+ "default_thickness": 0.02,
170
+ "embodied_carbon": 110.0,
171
+ "cost": 75.0
172
+ },
173
+ "Gravel": {
174
+ "category": "Sub-Structural Materials",
175
+ "thermal_conductivity": 0.7,
176
+ "density": 1800.0,
177
+ "specific_heat": 840.0,
178
+ "thickness_range": [0.05, 0.3],
179
+ "default_thickness": 0.15,
180
+ "embodied_carbon": 1.5,
181
+ "cost": 30.0
182
+ }
183
+ }
184
+
185
+ def display_construction_page():
186
+ """
187
+ Display the construction page.
188
+ This is the main function called by main.py when the Construction page is selected.
189
+ """
190
+ st.title("Construction Library")
191
+
192
+ # Display help information in an expandable section
193
+ with st.expander("Help & Information"):
194
+ display_construction_help()
195
+
196
+ # Initialize constructions in session state if not present
197
+ initialize_constructions()
198
+
199
+ # Create columns for library and project constructions
200
+ col1, col2 = st.columns(2)
201
+
202
+ # Library Constructions
203
+ with col1:
204
+ st.subheader("Library Constructions")
205
+ display_library_constructions()
206
+
207
+ # Project Constructions
208
+ with col2:
209
+ st.subheader("Project Constructions")
210
+ display_project_constructions()
211
+
212
+ # Construction Editor
213
+ st.markdown("---")
214
+ st.subheader("Construction Editor")
215
+ display_construction_editor()
216
+
217
+ # Navigation buttons
218
+ col1, col2 = st.columns(2)
219
+
220
+ with col1:
221
+ if st.button("Back to Material Library", key="back_to_materials"):
222
+ st.session_state.current_page = "Material Library"
223
+ st.rerun()
224
+
225
+ with col2:
226
+ if st.button("Continue to Building Components", key="continue_to_components"):
227
+ st.session_state.current_page = "Building Components"
228
+ st.rerun()
229
+
230
+ def initialize_constructions():
231
+ """Initialize constructions in session state if not present."""
232
+ if "constructions" not in st.session_state.project_data:
233
+ st.session_state.project_data["constructions"] = {
234
+ "library": {},
235
+ "project": {}
236
+ }
237
+
238
+ # Initialize library constructions if empty
239
+ if not st.session_state.project_data["constructions"]["library"]:
240
+ st.session_state.project_data["constructions"]["library"] = DEFAULT_CONSTRUCTIONS.copy()
241
+
242
+ # Add additional materials to library if not present
243
+ for material_name, material_data in ADDITIONAL_MATERIALS.items():
244
+ if material_name not in st.session_state.project_data["materials"]["library"]:
245
+ st.session_state.project_data["materials"]["library"][material_name] = material_data
246
+
247
+ # Initialize construction editor state
248
+ if "construction_editor" not in st.session_state:
249
+ st.session_state.construction_editor = {
250
+ "name": "",
251
+ "type": CONSTRUCTION_TYPES[0],
252
+ "layers": [],
253
+ "edit_mode": False,
254
+ "original_name": ""
255
+ }
256
+
257
+ def display_library_constructions():
258
+ """Display the library constructions section."""
259
+ # Filter options
260
+ type_filter = st.selectbox(
261
+ "Filter by Type",
262
+ ["All"] + CONSTRUCTION_TYPES,
263
+ key="library_construction_type_filter"
264
+ )
265
+
266
+ # Get library constructions
267
+ library_constructions = st.session_state.project_data["constructions"]["library"]
268
+
269
+ # Apply filter
270
+ if type_filter != "All":
271
+ filtered_constructions = {
272
+ name: props for name, props in library_constructions.items()
273
+ if props["type"] == type_filter
274
+ }
275
+ else:
276
+ filtered_constructions = library_constructions
277
+
278
+ # Display constructions in a table
279
+ if filtered_constructions:
280
+ # Create a DataFrame for display
281
+ data = []
282
+ for name, props in filtered_constructions.items():
283
+ data.append({
284
+ "Name": name,
285
+ "Type": props["type"],
286
+ "U-Value (W/m²·K)": props["u_value"],
287
+ "R-Value (m²·K/W)": props["r_value"],
288
+ "Thermal Mass (kJ/m²·K)": props["thermal_mass"],
289
+ "Layers": len(props["layers"])
290
+ })
291
+
292
+ df = pd.DataFrame(data)
293
+ st.dataframe(df, use_container_width=True, hide_index=True)
294
+
295
+ # Add to project button
296
+ selected_construction = st.selectbox(
297
+ "Select Construction to Add to Project",
298
+ list(filtered_constructions.keys()),
299
+ key="library_construction_selector"
300
+ )
301
+
302
+ # Display selected construction details
303
+ if selected_construction:
304
+ st.subheader(f"Details: {selected_construction}")
305
+ display_construction_details(filtered_constructions[selected_construction])
306
+
307
+ if st.button("Add to Project", key="add_library_construction_to_project"):
308
+ # Check if construction already exists in project
309
+ if selected_construction in st.session_state.project_data["constructions"]["project"]:
310
+ st.warning(f"Construction '{selected_construction}' already exists in your project.")
311
+ else:
312
+ # Add to project constructions
313
+ st.session_state.project_data["constructions"]["project"][selected_construction] = \
314
+ st.session_state.project_data["constructions"]["library"][selected_construction].copy()
315
+ st.success(f"Construction '{selected_construction}' added to your project.")
316
+ logger.info(f"Added library construction '{selected_construction}' to project")
317
+ else:
318
+ st.info("No constructions found in the selected type.")
319
+
320
+ def display_project_constructions():
321
+ """Display the project constructions section."""
322
+ # Get project constructions
323
+ project_constructions = st.session_state.project_data["constructions"]["project"]
324
+
325
+ if project_constructions:
326
+ # Create a DataFrame for display
327
+ data = []
328
+ for name, props in project_constructions.items():
329
+ data.append({
330
+ "Name": name,
331
+ "Type": props["type"],
332
+ "U-Value (W/m²·K)": props["u_value"],
333
+ "R-Value (m²·K/W)": props["r_value"],
334
+ "Thermal Mass (kJ/m²·K)": props["thermal_mass"],
335
+ "Layers": len(props["layers"])
336
+ })
337
+
338
+ df = pd.DataFrame(data)
339
+ st.dataframe(df, use_container_width=True, hide_index=True)
340
+
341
+ # Select construction to view details
342
+ selected_construction = st.selectbox(
343
+ "Select Construction to View Details",
344
+ list(project_constructions.keys()),
345
+ key="project_construction_selector"
346
+ )
347
+
348
+ # Display selected construction details
349
+ if selected_construction:
350
+ st.subheader(f"Details: {selected_construction}")
351
+ display_construction_details(project_constructions[selected_construction])
352
+
353
+ # Edit and delete options
354
+ col1, col2 = st.columns(2)
355
+
356
+ with col1:
357
+ selected_construction_edit = st.selectbox(
358
+ "Select Construction to Edit",
359
+ list(project_constructions.keys()),
360
+ key="project_construction_edit_selector"
361
+ )
362
+
363
+ if st.button("Edit Construction", key="edit_project_construction"):
364
+ # Load construction data into editor
365
+ construction_data = project_constructions[selected_construction_edit]
366
+ st.session_state.construction_editor = {
367
+ "name": selected_construction_edit,
368
+ "type": construction_data["type"],
369
+ "layers": construction_data["layers"].copy(),
370
+ "edit_mode": True,
371
+ "original_name": selected_construction_edit
372
+ }
373
+ st.success(f"Construction '{selected_construction_edit}' loaded for editing.")
374
+ st.rerun()
375
+
376
+ with col2:
377
+ selected_construction_delete = st.selectbox(
378
+ "Select Construction to Delete",
379
+ list(project_constructions.keys()),
380
+ key="project_construction_delete_selector"
381
+ )
382
+
383
+ if st.button("Delete Construction", key="delete_project_construction"):
384
+ # Check if construction is in use
385
+ is_in_use = check_construction_in_use(selected_construction_delete)
386
+
387
+ if is_in_use:
388
+ st.error(f"Cannot delete construction '{selected_construction_delete}' because it is in use in building components.")
389
+ else:
390
+ # Delete construction
391
+ del st.session_state.project_data["constructions"]["project"][selected_construction_delete]
392
+ st.success(f"Construction '{selected_construction_delete}' deleted from your project.")
393
+ logger.info(f"Deleted construction '{selected_construction_delete}' from project")
394
+ else:
395
+ st.info("No constructions in your project. Add constructions from the library or create custom constructions.")
396
+
397
+ def display_construction_details(construction: Dict[str, Any]):
398
+ """
399
+ Display detailed information about a construction.
400
+
401
+ Args:
402
+ construction: Construction data dictionary
403
+ """
404
+ # Display basic properties
405
+ col1, col2, col3 = st.columns(3)
406
+
407
+ with col1:
408
+ st.write(f"**Type:** {construction['type']}")
409
+ st.write(f"**U-Value:** {construction['u_value']} W/m²·K")
410
+
411
+ with col2:
412
+ st.write(f"**R-Value:** {construction['r_value']} m²·K/W")
413
+ st.write(f"**Thermal Mass:** {construction['thermal_mass']} kJ/m²·K")
414
+
415
+ with col3:
416
+ st.write(f"**Embodied Carbon:** {construction['embodied_carbon']} kg CO₂e/m²")
417
+ st.write(f"**Cost:** ${construction['cost']}/m²")
418
+
419
+ # Display layers
420
+ st.write("**Layers (from outside to inside):**")
421
+
422
+ # Create a DataFrame for layers
423
+ layers_data = []
424
+ for i, layer in enumerate(construction["layers"]):
425
+ layers_data.append({
426
+ "Layer": i + 1,
427
+ "Material": layer["material"],
428
+ "Thickness (m)": layer["thickness"]
429
+ })
430
+
431
+ layers_df = pd.DataFrame(layers_data)
432
+ st.dataframe(layers_df, use_container_width=True, hide_index=True)
433
+
434
+ def display_construction_editor():
435
+ """Display the construction editor form."""
436
+ # Get available materials
437
+ available_materials = get_available_materials()
438
+
439
+ with st.form("construction_editor_form"):
440
+ # Construction name
441
+ name = st.text_input(
442
+ "Construction Name",
443
+ value=st.session_state.construction_editor["name"],
444
+ help="Enter a unique name for the construction."
445
+ )
446
+
447
+ # Construction type
448
+ construction_type = st.selectbox(
449
+ "Construction Type",
450
+ CONSTRUCTION_TYPES,
451
+ index=CONSTRUCTION_TYPES.index(st.session_state.construction_editor["type"]) if st.session_state.construction_editor["type"] in CONSTRUCTION_TYPES else 0,
452
+ help="Select the construction type."
453
+ )
454
+
455
+ # Layers section
456
+ st.subheader("Layers (from outside to inside)")
457
+
458
+ # Display current layers
459
+ if st.session_state.construction_editor["layers"]:
460
+ layers_data = []
461
+ for i, layer in enumerate(st.session_state.construction_editor["layers"]):
462
+ layers_data.append({
463
+ "Layer": i + 1,
464
+ "Material": layer["material"],
465
+ "Thickness (m)": layer["thickness"]
466
+ })
467
+
468
+ layers_df = pd.DataFrame(layers_data)
469
+ st.dataframe(layers_df, use_container_width=True, hide_index=True)
470
+ else:
471
+ st.info("No layers added yet. Use the controls below to add layers.")
472
+
473
+ # Layer controls
474
+ st.subheader("Add/Edit Layer")
475
+
476
+ col1, col2 = st.columns(2)
477
+
478
+ with col1:
479
+ layer_material = st.selectbox(
480
+ "Material",
481
+ list(available_materials.keys()),
482
+ key="layer_material_selector",
483
+ help="Select a material for this layer."
484
+ )
485
+
486
+ # Get material properties
487
+ if layer_material in available_materials:
488
+ material_props = available_materials[layer_material]
489
+ min_thickness = material_props["thickness_range"][0]
490
+ max_thickness = material_props["thickness_range"][1]
491
+ default_thickness = material_props["default_thickness"]
492
+ else:
493
+ min_thickness = 0.001
494
+ max_thickness = 0.5
495
+ default_thickness = 0.05
496
+
497
+ with col2:
498
+ layer_thickness = st.number_input(
499
+ "Thickness (m)",
500
+ min_value=float(min_thickness),
501
+ max_value=float(max_thickness),
502
+ value=float(default_thickness),
503
+ format="%.3f",
504
+ help=f"Enter the thickness for this layer (range: {min_thickness}-{max_thickness} m)."
505
+ )
506
+
507
+ # Layer action buttons
508
+ col1, col2, col3 = st.columns(3)
509
+
510
+ with col1:
511
+ add_layer = st.form_submit_button("Add Layer")
512
+
513
+ with col2:
514
+ layer_index_to_remove = st.number_input(
515
+ "Layer # to Remove",
516
+ min_value=1,
517
+ max_value=max(1, len(st.session_state.construction_editor["layers"])),
518
+ value=len(st.session_state.construction_editor["layers"]) if st.session_state.construction_editor["layers"] else 1,
519
+ step=1,
520
+ help="Enter the layer number to remove."
521
+ )
522
+
523
+ with col3:
524
+ remove_layer = st.form_submit_button("Remove Layer")
525
+
526
+ # Calculate button and results
527
+ st.subheader("Thermal Properties")
528
+ calculate = st.form_submit_button("Calculate Properties")
529
+
530
+ # Form submission buttons
531
+ col1, col2, col3 = st.columns(3)
532
+
533
+ with col1:
534
+ save_button = st.form_submit_button("Save Construction")
535
+
536
+ with col2:
537
+ clear_button = st.form_submit_button("Clear Form")
538
+
539
+ with col3:
540
+ cancel_button = st.form_submit_button("Cancel Edit")
541
+
542
+ # Handle form actions outside the form
543
+ if add_layer:
544
+ # Add new layer
545
+ new_layer = {
546
+ "material": layer_material,
547
+ "thickness": layer_thickness
548
+ }
549
+ st.session_state.construction_editor["layers"].append(new_layer)
550
+ st.success(f"Layer added: {layer_material} ({layer_thickness} m)")
551
+ st.rerun()
552
+
553
+ if remove_layer and st.session_state.construction_editor["layers"]:
554
+ # Remove layer
555
+ if 1 <= layer_index_to_remove <= len(st.session_state.construction_editor["layers"]):
556
+ removed_layer = st.session_state.construction_editor["layers"].pop(layer_index_to_remove - 1)
557
+ st.success(f"Layer {layer_index_to_remove} removed: {removed_layer['material']} ({removed_layer['thickness']} m)")
558
+ st.rerun()
559
+ else:
560
+ st.error("Invalid layer index.")
561
+
562
+ if calculate:
563
+ # Calculate thermal properties
564
+ if st.session_state.construction_editor["layers"]:
565
+ u_value, r_value, thermal_mass, embodied_carbon, cost = calculate_construction_properties(
566
+ st.session_state.construction_editor["layers"],
567
+ available_materials
568
+ )
569
+
570
+ col1, col2, col3 = st.columns(3)
571
+
572
+ with col1:
573
+ st.metric("U-Value", f"{u_value:.3f} W/m²·K")
574
+ st.metric("R-Value", f"{r_value:.3f} m²·K/W")
575
+
576
+ with col2:
577
+ st.metric("Thermal Mass", f"{thermal_mass:.1f} kJ/m²·K")
578
+ st.metric("Total Thickness", f"{sum(layer['thickness'] for layer in st.session_state.construction_editor['layers']):.3f} m")
579
+
580
+ with col3:
581
+ st.metric("Embodied Carbon", f"{embodied_carbon:.1f} kg CO₂e/m²")
582
+ st.metric("Cost", f"${cost:.2f}/m²")
583
+ else:
584
+ st.warning("Add layers to calculate thermal properties.")
585
+
586
+ if save_button:
587
+ # Validate inputs
588
+ validation_errors = validate_construction(
589
+ name, construction_type, st.session_state.construction_editor["layers"],
590
+ st.session_state.construction_editor["edit_mode"], st.session_state.construction_editor["original_name"]
591
+ )
592
+
593
+ if validation_errors:
594
+ # Display validation errors
595
+ for error in validation_errors:
596
+ st.error(error)
597
+ else:
598
+ # Calculate properties
599
+ u_value, r_value, thermal_mass, embodied_carbon, cost = calculate_construction_properties(
600
+ st.session_state.construction_editor["layers"],
601
+ available_materials
602
+ )
603
+
604
+ # Create construction data
605
+ construction_data = {
606
+ "type": construction_type,
607
+ "layers": st.session_state.construction_editor["layers"].copy(),
608
+ "u_value": u_value,
609
+ "r_value": r_value,
610
+ "thermal_mass": thermal_mass,
611
+ "embodied_carbon": embodied_carbon,
612
+ "cost": cost
613
+ }
614
+
615
+ # Handle edit mode
616
+ if st.session_state.construction_editor["edit_mode"]:
617
+ original_name = st.session_state.construction_editor["original_name"]
618
+
619
+ # If name changed, delete old entry and create new one
620
+ if original_name != name:
621
+ del st.session_state.project_data["constructions"]["project"][original_name]
622
+
623
+ # Update construction
624
+ st.session_state.project_data["constructions"]["project"][name] = construction_data
625
+ st.success(f"Construction '{name}' updated successfully.")
626
+ logger.info(f"Updated construction '{name}' in project")
627
+ else:
628
+ # Add new construction
629
+ st.session_state.project_data["constructions"]["project"][name] = construction_data
630
+ st.success(f"Construction '{name}' added to your project.")
631
+ logger.info(f"Added new construction '{name}' to project")
632
+
633
+ # Reset editor
634
+ reset_construction_editor()
635
+ st.rerun()
636
+
637
+ if clear_button:
638
+ reset_construction_editor()
639
+ st.rerun()
640
+
641
+ if cancel_button and st.session_state.construction_editor["edit_mode"]:
642
+ reset_construction_editor()
643
+ st.success("Edit cancelled.")
644
+ st.rerun()
645
+
646
+ def get_available_materials() -> Dict[str, Any]:
647
+ """
648
+ Get all available materials from both library and project.
649
+
650
+ Returns:
651
+ Dict of material name to material properties
652
+ """
653
+ available_materials = {}
654
+
655
+ # Add library materials
656
+ if "materials" in st.session_state.project_data and "library" in st.session_state.project_data["materials"]:
657
+ available_materials.update(st.session_state.project_data["materials"]["library"])
658
+
659
+ # Add project materials
660
+ if "materials" in st.session_state.project_data and "project" in st.session_state.project_data["materials"]:
661
+ available_materials.update(st.session_state.project_data["materials"]["project"])
662
+
663
+ return available_materials
664
+
665
+ def calculate_construction_properties(
666
+ layers: List[Dict[str, Any]],
667
+ available_materials: Dict[str, Any]
668
+ ) -> Tuple[float, float, float, float, float]:
669
+ """
670
+ Calculate thermal properties of a construction.
671
+
672
+ Args:
673
+ layers: List of layer dictionaries with material and thickness
674
+ available_materials: Dictionary of available materials
675
+
676
+ Returns:
677
+ Tuple of (u_value, r_value, thermal_mass, embodied_carbon, cost)
678
+ """
679
+ # Initialize values
680
+ r_value_total = 0.0 # m²·K/W
681
+ thermal_mass_total = 0.0 # kJ/m²·K
682
+ embodied_carbon_total = 0.0 # kg CO₂e/m²
683
+ cost_total = 0.0 # USD/m²
684
+
685
+ # Add standard surface resistances
686
+ r_value_total += 0.13 # Interior surface resistance
687
+ r_value_total += 0.04 # Exterior surface resistance
688
+
689
+ # Calculate properties for each layer
690
+ for layer in layers:
691
+ material_name = layer["material"]
692
+ thickness = layer["thickness"]
693
+
694
+ if material_name in available_materials:
695
+ material = available_materials[material_name]
696
+
697
+ # Calculate R-value for this layer
698
+ r_value_layer = thickness / material["thermal_conductivity"]
699
+ r_value_total += r_value_layer
700
+
701
+ # Calculate thermal mass for this layer
702
+ thermal_mass_layer = material["density"] * material["specific_heat"] * thickness / 1000 # Convert J to kJ
703
+ thermal_mass_total += thermal_mass_layer
704
+
705
+ # Calculate embodied carbon for this layer
706
+ embodied_carbon_layer = material["embodied_carbon"] * thickness
707
+ embodied_carbon_total += embodied_carbon_layer
708
+
709
+ # Calculate cost for this layer
710
+ cost_layer = material["cost"] * thickness
711
+ cost_total += cost_layer
712
+
713
+ # Calculate U-value from R-value
714
+ u_value = 1.0 / r_value_total if r_value_total > 0 else float('inf')
715
+
716
+ return u_value, r_value_total, thermal_mass_total, embodied_carbon_total, cost_total
717
+
718
+ def validate_construction(
719
+ name: str, construction_type: str, layers: List[Dict[str, Any]],
720
+ edit_mode: bool, original_name: str
721
+ ) -> List[str]:
722
+ """
723
+ Validate construction inputs.
724
+
725
+ Args:
726
+ name: Construction name
727
+ construction_type: Construction type
728
+ layers: List of layer dictionaries
729
+ edit_mode: Whether in edit mode
730
+ original_name: Original name if in edit mode
731
+
732
+ Returns:
733
+ List of validation error messages, empty if all inputs are valid
734
+ """
735
+ errors = []
736
+
737
+ # Validate name
738
+ if not name or name.strip() == "":
739
+ errors.append("Construction name is required.")
740
+
741
+ # Check for name uniqueness if not in edit mode or if name changed
742
+ if not edit_mode or (edit_mode and name != original_name):
743
+ if name in st.session_state.project_data["constructions"]["project"]:
744
+ errors.append(f"Construction name '{name}' already exists in your project.")
745
+
746
+ # Validate construction type
747
+ if construction_type not in CONSTRUCTION_TYPES:
748
+ errors.append("Please select a valid construction type.")
749
+
750
+ # Validate layers
751
+ if not layers:
752
+ errors.append("At least one layer is required.")
753
+
754
+ return errors
755
+
756
+ def reset_construction_editor():
757
+ """Reset the construction editor to default values."""
758
+ st.session_state.construction_editor = {
759
+ "name": "",
760
+ "type": CONSTRUCTION_TYPES[0],
761
+ "layers": [],
762
+ "edit_mode": False,
763
+ "original_name": ""
764
+ }
765
+
766
+ def check_construction_in_use(construction_name: str) -> bool:
767
+ """
768
+ Check if a construction is in use in any building components.
769
+
770
+ Args:
771
+ construction_name: Name of the construction to check
772
+
773
+ Returns:
774
+ True if the construction is in use, False otherwise
775
+ """
776
+ # This is a placeholder function that will be implemented when components are added
777
+ # For now, we'll assume constructions are not in use
778
+ return False
779
+
780
+ def display_construction_help():
781
+ """Display help information for the construction page."""
782
+ st.markdown("""
783
+ ### Construction Library Help
784
+
785
+ This section allows you to create and manage multi-layer constructions for your building envelope.
786
+
787
+ **Key Concepts:**
788
+
789
+ * **Construction**: A multi-layer assembly of materials used for walls, roofs, or floors.
790
+ * **Layers**: Individual material layers that make up a construction, defined from outside to inside.
791
+ * **U-Value**: Overall heat transfer coefficient (W/m²·K). Lower values indicate better insulation.
792
+ * **R-Value**: Thermal resistance (m²·K/W). Higher values indicate better insulation.
793
+ * **Thermal Mass**: Ability to store heat (kJ/m²·K). Higher values indicate better heat storage capacity.
794
+
795
+ **Library Constructions:**
796
+
797
+ The library contains pre-defined constructions with standard thermal properties. You can:
798
+ * Browse constructions by type (Wall, Roof, Floor)
799
+ * View detailed information about each construction
800
+ * Add library constructions to your project
801
+
802
+ **Project Constructions:**
803
+
804
+ These are constructions you've added to your project from the library or created custom. You can:
805
+ * View detailed information about each construction
806
+ * Edit existing constructions
807
+ * Delete constructions (if not in use)
808
+
809
+ **Construction Editor:**
810
+
811
+ The editor allows you to create new constructions or modify existing ones:
812
+ * Add layers from outside to inside
813
+ * Select materials from your material library
814
+ * Specify thickness for each layer
815
+ * Calculate overall thermal properties
816
+
817
+ **Workflow:**
818
+
819
+ 1. Browse the library constructions
820
+ 2. Add constructions to your project or create custom ones
821
+ 3. Edit properties as needed for your specific project
822
+ 4. Continue to the Building Components page to use these constructions
823
+ """)
app/embodied_energy.py ADDED
@@ -0,0 +1,997 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ HVAC Load Calculator - Embodied Energy Module
3
+
4
+ This module handles the embodied carbon calculations for building materials,
5
+ life cycle assessment integration, carbon payback period analysis,
6
+ and embodied vs. operational carbon visualization.
7
+
8
+ Developed by: Dr Majed Abuseif, Deakin University
9
+ © 2025
10
+ """
11
+
12
+ import streamlit as st
13
+ import pandas as pd
14
+ import numpy as np
15
+ import json
16
+ import logging
17
+ import plotly.graph_objects as go
18
+ import plotly.express as px
19
+ from typing import Dict, List, Any, Optional, Tuple, Union
20
+ from datetime import datetime
21
+
22
+ # Configure logging
23
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
24
+ logger = logging.getLogger(__name__)
25
+
26
+ # Constants
27
+ YEARS_FOR_ANALYSIS = 60 # Standard building lifecycle for embodied carbon analysis
28
+ REPLACEMENT_CYCLES = {
29
+ "Structure": 60, # Years before replacement (typically building lifetime)
30
+ "Envelope": 30,
31
+ "Finishes": 15,
32
+ "MEP Systems": 20,
33
+ "Furniture": 10
34
+ }
35
+
36
+ # Default embodied carbon factors (kg CO2e/kg)
37
+ DEFAULT_EMBODIED_CARBON = {
38
+ "Concrete": 0.12,
39
+ "Steel": 1.46,
40
+ "Timber": 0.42,
41
+ "Brick": 0.24,
42
+ "Glass": 0.86,
43
+ "Aluminum": 8.24,
44
+ "Insulation (mineral wool)": 1.28,
45
+ "Insulation (EPS)": 3.29,
46
+ "Gypsum board": 0.39,
47
+ "Carpet": 3.89,
48
+ "Ceramic tile": 0.78,
49
+ "PVC": 3.10,
50
+ "Paint": 2.54,
51
+ "HVAC equipment": 3.62,
52
+ "Electrical equipment": 4.26,
53
+ "Plumbing fixtures": 2.11
54
+ }
55
+
56
+ def display_embodied_energy_page():
57
+ """
58
+ Display the embodied energy page.
59
+ This is the main function called by main.py when the Embodied Energy page is selected.
60
+ """
61
+ st.title("Embodied Energy Analysis")
62
+
63
+ # Display help information in an expandable section
64
+ with st.expander("Help & Information"):
65
+ display_embodied_energy_help()
66
+
67
+ # Check if building components have been defined
68
+ if "components" not in st.session_state.project_data or not st.session_state.project_data["components"]:
69
+ st.warning("Please define building components before proceeding to Embodied Energy analysis.")
70
+
71
+ # Navigation buttons
72
+ col1, col2 = st.columns(2)
73
+ with col1:
74
+ if st.button("Back to Building Components", key="back_to_components_ee"):
75
+ st.session_state.current_page = "Building Components"
76
+ st.rerun()
77
+ return
78
+
79
+ # Initialize embodied energy data if not present
80
+ initialize_embodied_energy_data()
81
+
82
+ # Create tabs for different aspects of embodied energy analysis
83
+ tabs = st.tabs(["Material Inventory", "Embodied Carbon", "Carbon Payback"])
84
+
85
+ with tabs[0]:
86
+ display_material_inventory_tab()
87
+
88
+ with tabs[1]:
89
+ display_embodied_carbon_tab()
90
+
91
+ with tabs[2]:
92
+ display_carbon_payback_tab()
93
+
94
+ # Navigation buttons
95
+ col1, col2 = st.columns(2)
96
+
97
+ with col1:
98
+ if st.button("Back to Renewable Energy", key="back_to_renewable_energy"):
99
+ st.session_state.current_page = "Renewable Energy"
100
+ st.rerun()
101
+
102
+ with col2:
103
+ if st.button("Continue to Materials Cost", key="continue_to_materials_cost"):
104
+ st.session_state.current_page = "Materials Cost"
105
+ st.rerun()
106
+
107
+ def initialize_embodied_energy_data():
108
+ """Initialize embodied energy data in session state if not present."""
109
+ if "embodied_energy" not in st.session_state.project_data:
110
+ st.session_state.project_data["embodied_energy"] = {
111
+ "material_inventory": [],
112
+ "embodied_carbon_factors": DEFAULT_EMBODIED_CARBON.copy(),
113
+ "results": None
114
+ }
115
+
116
+ # Initialize material inventory if empty
117
+ if not st.session_state.project_data["embodied_energy"]["material_inventory"]:
118
+ # Generate initial material inventory from building components
119
+ material_inventory = generate_material_inventory_from_components()
120
+ st.session_state.project_data["embodied_energy"]["material_inventory"] = material_inventory
121
+
122
+ def generate_material_inventory_from_components() -> List[Dict[str, Any]]:
123
+ """
124
+ Generate material inventory from building components.
125
+
126
+ Returns:
127
+ List of material items with quantities and properties.
128
+ """
129
+ logger.info("Generating material inventory from building components...")
130
+
131
+ material_inventory = []
132
+
133
+ # Get building components
134
+ components = st.session_state.project_data["components"]
135
+
136
+ # Get constructions
137
+ constructions = st.session_state.project_data["constructions"]
138
+
139
+ # Get materials
140
+ materials = st.session_state.project_data["materials"]
141
+
142
+ # Process opaque components (walls, roofs, floors)
143
+ for comp_type in ["walls", "roofs", "floors"]:
144
+ for comp in components.get(comp_type, []):
145
+ # Get construction details
146
+ construction_id = comp.get("construction_id")
147
+ if not construction_id:
148
+ continue
149
+
150
+ construction = next((c for c in constructions if c["id"] == construction_id), None)
151
+ if not construction:
152
+ continue
153
+
154
+ # Calculate component area
155
+ area = comp.get("area", 0)
156
+
157
+ # Process each layer in the construction
158
+ for layer in construction.get("layers", []):
159
+ material_id = layer.get("material_id")
160
+ if not material_id:
161
+ continue
162
+
163
+ material = next((m for m in materials if m["id"] == material_id), None)
164
+ if not material:
165
+ continue
166
+
167
+ # Calculate material quantity
168
+ thickness = layer.get("thickness", 0) # m
169
+ density = material.get("density", 0) # kg/m³
170
+ volume = area * thickness # m³
171
+ mass = volume * density # kg
172
+
173
+ # Get material category
174
+ category = get_material_category(material.get("name", ""))
175
+
176
+ # Create material inventory item
177
+ item = {
178
+ "component_type": comp_type,
179
+ "component_name": comp.get("name", ""),
180
+ "material_name": material.get("name", ""),
181
+ "material_category": category,
182
+ "quantity": mass, # kg
183
+ "unit": "kg",
184
+ "volume": volume, # m³
185
+ "area": area, # m²
186
+ "thickness": thickness, # m
187
+ "density": density, # kg/m³
188
+ "replacement_cycle": REPLACEMENT_CYCLES.get(category, YEARS_FOR_ANALYSIS)
189
+ }
190
+
191
+ material_inventory.append(item)
192
+
193
+ # Process fenestration components (windows, doors, skylights)
194
+ for comp_type in ["windows", "doors", "skylights"]:
195
+ for comp in components.get(comp_type, []):
196
+ # Get fenestration details
197
+ fenestration_id = comp.get("fenestration_id")
198
+ if not fenestration_id:
199
+ continue
200
+
201
+ fenestration = next((f for f in materials if f["id"] == fenestration_id), None)
202
+ if not fenestration:
203
+ continue
204
+
205
+ # Calculate component area
206
+ area = comp.get("area", 0)
207
+
208
+ # Estimate material quantities based on fenestration type
209
+ if comp_type == "windows":
210
+ # Typical window composition
211
+ glass_mass = area * 2.5 * 2500 # 2.5 cm thick glass at 2500 kg/m³
212
+ frame_mass = area * 0.1 * 2700 # 10% frame area, aluminum at 2700 kg/m³
213
+
214
+ # Add glass
215
+ material_inventory.append({
216
+ "component_type": comp_type,
217
+ "component_name": comp.get("name", ""),
218
+ "material_name": "Glass",
219
+ "material_category": "Envelope",
220
+ "quantity": glass_mass,
221
+ "unit": "kg",
222
+ "volume": area * 0.025,
223
+ "area": area,
224
+ "thickness": 0.025,
225
+ "density": 2500,
226
+ "replacement_cycle": REPLACEMENT_CYCLES.get("Envelope", YEARS_FOR_ANALYSIS)
227
+ })
228
+
229
+ # Add frame
230
+ material_inventory.append({
231
+ "component_type": comp_type,
232
+ "component_name": comp.get("name", ""),
233
+ "material_name": "Aluminum",
234
+ "material_category": "Envelope",
235
+ "quantity": frame_mass,
236
+ "unit": "kg",
237
+ "volume": area * 0.1 * 0.05,
238
+ "area": area * 0.1,
239
+ "thickness": 0.05,
240
+ "density": 2700,
241
+ "replacement_cycle": REPLACEMENT_CYCLES.get("Envelope", YEARS_FOR_ANALYSIS)
242
+ })
243
+
244
+ elif comp_type == "doors":
245
+ # Typical door composition
246
+ if "glass" in fenestration.get("name", "").lower():
247
+ # Glass door
248
+ glass_mass = area * 1.2 * 2500 # 1.2 cm thick glass at 2500 kg/m³
249
+ frame_mass = area * 0.15 * 2700 # 15% frame area, aluminum at 2700 kg/m³
250
+
251
+ material_inventory.append({
252
+ "component_type": comp_type,
253
+ "component_name": comp.get("name", ""),
254
+ "material_name": "Glass",
255
+ "material_category": "Envelope",
256
+ "quantity": glass_mass,
257
+ "unit": "kg",
258
+ "volume": area * 0.012,
259
+ "area": area,
260
+ "thickness": 0.012,
261
+ "density": 2500,
262
+ "replacement_cycle": REPLACEMENT_CYCLES.get("Envelope", YEARS_FOR_ANALYSIS)
263
+ })
264
+
265
+ material_inventory.append({
266
+ "component_type": comp_type,
267
+ "component_name": comp.get("name", ""),
268
+ "material_name": "Aluminum",
269
+ "material_category": "Envelope",
270
+ "quantity": frame_mass,
271
+ "unit": "kg",
272
+ "volume": area * 0.15 * 0.05,
273
+ "area": area * 0.15,
274
+ "thickness": 0.05,
275
+ "density": 2700,
276
+ "replacement_cycle": REPLACEMENT_CYCLES.get("Envelope", YEARS_FOR_ANALYSIS)
277
+ })
278
+ else:
279
+ # Solid door
280
+ door_mass = area * 0.04 * 700 # 4 cm thick wood at 700 kg/m³
281
+
282
+ material_inventory.append({
283
+ "component_type": comp_type,
284
+ "component_name": comp.get("name", ""),
285
+ "material_name": "Timber",
286
+ "material_category": "Envelope",
287
+ "quantity": door_mass,
288
+ "unit": "kg",
289
+ "volume": area * 0.04,
290
+ "area": area,
291
+ "thickness": 0.04,
292
+ "density": 700,
293
+ "replacement_cycle": REPLACEMENT_CYCLES.get("Envelope", YEARS_FOR_ANALYSIS)
294
+ })
295
+
296
+ elif comp_type == "skylights":
297
+ # Typical skylight composition
298
+ glass_mass = area * 2.0 * 2500 # 2.0 cm thick glass at 2500 kg/m³
299
+ frame_mass = area * 0.12 * 2700 # 12% frame area, aluminum at 2700 kg/m³
300
+
301
+ material_inventory.append({
302
+ "component_type": comp_type,
303
+ "component_name": comp.get("name", ""),
304
+ "material_name": "Glass",
305
+ "material_category": "Envelope",
306
+ "quantity": glass_mass,
307
+ "unit": "kg",
308
+ "volume": area * 0.02,
309
+ "area": area,
310
+ "thickness": 0.02,
311
+ "density": 2500,
312
+ "replacement_cycle": REPLACEMENT_CYCLES.get("Envelope", YEARS_FOR_ANALYSIS)
313
+ })
314
+
315
+ material_inventory.append({
316
+ "component_type": comp_type,
317
+ "component_name": comp.get("name", ""),
318
+ "material_name": "Aluminum",
319
+ "material_category": "Envelope",
320
+ "quantity": frame_mass,
321
+ "unit": "kg",
322
+ "volume": area * 0.12 * 0.05,
323
+ "area": area * 0.12,
324
+ "thickness": 0.05,
325
+ "density": 2700,
326
+ "replacement_cycle": REPLACEMENT_CYCLES.get("Envelope", YEARS_FOR_ANALYSIS)
327
+ })
328
+
329
+ # Add HVAC equipment (estimated based on building size)
330
+ building_info = st.session_state.project_data["building_info"]
331
+ floor_area = building_info.get("floor_area", 0)
332
+
333
+ # HVAC equipment (estimated at 15 kg/m²)
334
+ hvac_mass = floor_area * 15
335
+
336
+ material_inventory.append({
337
+ "component_type": "systems",
338
+ "component_name": "HVAC System",
339
+ "material_name": "HVAC equipment",
340
+ "material_category": "MEP Systems",
341
+ "quantity": hvac_mass,
342
+ "unit": "kg",
343
+ "volume": 0,
344
+ "area": 0,
345
+ "thickness": 0,
346
+ "density": 0,
347
+ "replacement_cycle": REPLACEMENT_CYCLES.get("MEP Systems", YEARS_FOR_ANALYSIS)
348
+ })
349
+
350
+ # Electrical equipment (estimated at 5 kg/m²)
351
+ electrical_mass = floor_area * 5
352
+
353
+ material_inventory.append({
354
+ "component_type": "systems",
355
+ "component_name": "Electrical System",
356
+ "material_name": "Electrical equipment",
357
+ "material_category": "MEP Systems",
358
+ "quantity": electrical_mass,
359
+ "unit": "kg",
360
+ "volume": 0,
361
+ "area": 0,
362
+ "thickness": 0,
363
+ "density": 0,
364
+ "replacement_cycle": REPLACEMENT_CYCLES.get("MEP Systems", YEARS_FOR_ANALYSIS)
365
+ })
366
+
367
+ # Plumbing fixtures (estimated at 3 kg/m²)
368
+ plumbing_mass = floor_area * 3
369
+
370
+ material_inventory.append({
371
+ "component_type": "systems",
372
+ "component_name": "Plumbing System",
373
+ "material_name": "Plumbing fixtures",
374
+ "material_category": "MEP Systems",
375
+ "quantity": plumbing_mass,
376
+ "unit": "kg",
377
+ "volume": 0,
378
+ "area": 0,
379
+ "thickness": 0,
380
+ "density": 0,
381
+ "replacement_cycle": REPLACEMENT_CYCLES.get("MEP Systems", YEARS_FOR_ANALYSIS)
382
+ })
383
+
384
+ logger.info(f"Generated material inventory with {len(material_inventory)} items.")
385
+ return material_inventory
386
+
387
+ def get_material_category(material_name: str) -> str:
388
+ """
389
+ Determine the material category based on the material name.
390
+
391
+ Args:
392
+ material_name: Name of the material
393
+
394
+ Returns:
395
+ Category of the material
396
+ """
397
+ material_name_lower = material_name.lower()
398
+
399
+ if any(term in material_name_lower for term in ["concrete", "steel", "timber", "wood", "brick", "stone", "structural"]):
400
+ return "Structure"
401
+
402
+ elif any(term in material_name_lower for term in ["insulation", "cladding", "siding", "roofing", "glass", "window", "door"]):
403
+ return "Envelope"
404
+
405
+ elif any(term in material_name_lower for term in ["paint", "carpet", "tile", "flooring", "ceiling", "gypsum", "drywall"]):
406
+ return "Finishes"
407
+
408
+ elif any(term in material_name_lower for term in ["hvac", "electrical", "plumbing", "mechanical", "pipe", "duct", "wire"]):
409
+ return "MEP Systems"
410
+
411
+ elif any(term in material_name_lower for term in ["furniture", "fixture", "equipment", "appliance"]):
412
+ return "Furniture"
413
+
414
+ else:
415
+ return "Other"
416
+
417
+ def display_material_inventory_tab():
418
+ """Display the material inventory tab."""
419
+ st.header("Material Inventory")
420
+
421
+ # Get material inventory
422
+ material_inventory = st.session_state.project_data["embodied_energy"]["material_inventory"]
423
+
424
+ # Allow user to edit material inventory
425
+ st.subheader("Edit Material Inventory")
426
+
427
+ # Create a DataFrame for display and editing
428
+ if material_inventory:
429
+ df = pd.DataFrame(material_inventory)
430
+
431
+ # Select columns for display
432
+ display_columns = ["component_type", "component_name", "material_name", "material_category", "quantity", "unit", "replacement_cycle"]
433
+ display_df = df[display_columns].copy()
434
+
435
+ # Format column names for display
436
+ display_df.columns = [col.replace("_", " ").title() for col in display_columns]
437
+
438
+ # Display the inventory table
439
+ st.dataframe(display_df)
440
+
441
+ # Allow adding new materials
442
+ st.subheader("Add New Material")
443
+
444
+ col1, col2 = st.columns(2)
445
+
446
+ with col1:
447
+ new_component_type = st.selectbox(
448
+ "Component Type",
449
+ ["walls", "roofs", "floors", "windows", "doors", "skylights", "systems", "other"],
450
+ key="new_material_component_type"
451
+ )
452
+
453
+ new_component_name = st.text_input(
454
+ "Component Name",
455
+ key="new_material_component_name"
456
+ )
457
+
458
+ new_material_name = st.selectbox(
459
+ "Material Name",
460
+ list(DEFAULT_EMBODIED_CARBON.keys()),
461
+ key="new_material_name"
462
+ )
463
+
464
+ with col2:
465
+ new_material_category = st.selectbox(
466
+ "Material Category",
467
+ list(REPLACEMENT_CYCLES.keys()),
468
+ key="new_material_category"
469
+ )
470
+
471
+ new_quantity = st.number_input(
472
+ "Quantity (kg)",
473
+ min_value=0.0,
474
+ value=100.0,
475
+ step=10.0,
476
+ key="new_material_quantity"
477
+ )
478
+
479
+ new_replacement_cycle = st.number_input(
480
+ "Replacement Cycle (years)",
481
+ min_value=1,
482
+ max_value=100,
483
+ value=REPLACEMENT_CYCLES.get(new_material_category, YEARS_FOR_ANALYSIS),
484
+ step=1,
485
+ key="new_material_replacement_cycle"
486
+ )
487
+
488
+ if st.button("Add Material", key="add_material_button"):
489
+ # Create new material inventory item
490
+ new_item = {
491
+ "component_type": new_component_type,
492
+ "component_name": new_component_name,
493
+ "material_name": new_material_name,
494
+ "material_category": new_material_category,
495
+ "quantity": new_quantity,
496
+ "unit": "kg",
497
+ "volume": 0,
498
+ "area": 0,
499
+ "thickness": 0,
500
+ "density": 0,
501
+ "replacement_cycle": new_replacement_cycle
502
+ }
503
+
504
+ # Add to inventory
505
+ material_inventory.append(new_item)
506
+ st.success(f"Added {new_material_name} to inventory.")
507
+ st.rerun()
508
+ else:
509
+ st.info("No materials in inventory. Generate inventory from building components.")
510
+
511
+ # Button to regenerate inventory
512
+ if st.button("Regenerate Inventory from Components", key="regenerate_inventory"):
513
+ material_inventory = generate_material_inventory_from_components()
514
+ st.session_state.project_data["embodied_energy"]["material_inventory"] = material_inventory
515
+ st.success("Material inventory regenerated from building components.")
516
+ st.rerun()
517
+
518
+ # Display material inventory summary
519
+ if material_inventory:
520
+ st.subheader("Material Inventory Summary")
521
+
522
+ # Create summary by material category
523
+ category_summary = {}
524
+ for item in material_inventory:
525
+ category = item["material_category"]
526
+ quantity = item["quantity"]
527
+
528
+ if category in category_summary:
529
+ category_summary[category] += quantity
530
+ else:
531
+ category_summary[category] = quantity
532
+
533
+ # Create summary chart
534
+ fig = px.pie(
535
+ values=list(category_summary.values()),
536
+ names=list(category_summary.keys()),
537
+ title="Material Quantity by Category (kg)"
538
+ )
539
+ st.plotly_chart(fig, use_container_width=True)
540
+
541
+ # Display total material quantity
542
+ total_quantity = sum(category_summary.values())
543
+ st.metric("Total Material Quantity", f"{total_quantity:.0f} kg")
544
+
545
+ def display_embodied_carbon_tab():
546
+ """Display the embodied carbon analysis tab."""
547
+ st.header("Embodied Carbon Analysis")
548
+
549
+ # Get material inventory and embodied carbon factors
550
+ material_inventory = st.session_state.project_data["embodied_energy"]["material_inventory"]
551
+ embodied_carbon_factors = st.session_state.project_data["embodied_energy"]["embodied_carbon_factors"]
552
+
553
+ if not material_inventory:
554
+ st.info("Please define material inventory in the Material Inventory tab.")
555
+ return
556
+
557
+ # Allow user to edit embodied carbon factors
558
+ st.subheader("Embodied Carbon Factors")
559
+
560
+ # Create a DataFrame for display and editing
561
+ factor_df = pd.DataFrame({
562
+ "Material": list(embodied_carbon_factors.keys()),
563
+ "Embodied Carbon (kg CO2e/kg)": list(embodied_carbon_factors.values())
564
+ })
565
+
566
+ # Display the factors table
567
+ edited_factor_df = st.data_editor(
568
+ factor_df,
569
+ column_config={
570
+ "Material": st.column_config.TextColumn("Material"),
571
+ "Embodied Carbon (kg CO2e/kg)": st.column_config.NumberColumn(
572
+ "Embodied Carbon (kg CO2e/kg)",
573
+ min_value=0.0,
574
+ max_value=20.0,
575
+ step=0.01,
576
+ format="%.2f"
577
+ )
578
+ },
579
+ use_container_width=True,
580
+ key="embodied_carbon_factors_editor"
581
+ )
582
+
583
+ # Update embodied carbon factors if edited
584
+ if not factor_df.equals(edited_factor_df):
585
+ updated_factors = dict(zip(edited_factor_df["Material"], edited_factor_df["Embodied Carbon (kg CO2e/kg)"]))
586
+ st.session_state.project_data["embodied_energy"]["embodied_carbon_factors"] = updated_factors
587
+ embodied_carbon_factors = updated_factors
588
+
589
+ # Calculate embodied carbon button
590
+ if st.button("Calculate Embodied Carbon", key="calculate_embodied_carbon"):
591
+ try:
592
+ results = calculate_embodied_carbon(material_inventory, embodied_carbon_factors)
593
+ st.session_state.project_data["embodied_energy"]["results"] = results
594
+ st.success("Embodied carbon calculated successfully.")
595
+ logger.info("Embodied carbon calculated.")
596
+ st.rerun() # Refresh to show results
597
+ except Exception as e:
598
+ st.error(f"Error calculating embodied carbon: {e}")
599
+ logger.error(f"Error calculating embodied carbon: {e}", exc_info=True)
600
+ st.session_state.project_data["embodied_energy"]["results"] = None
601
+
602
+ # Display embodied carbon results if available
603
+ results = st.session_state.project_data["embodied_energy"].get("results")
604
+ if results:
605
+ display_embodied_carbon_results(results)
606
+
607
+ def display_embodied_carbon_results(results: Dict[str, Any]):
608
+ """Display the embodied carbon calculation results."""
609
+ st.subheader("Embodied Carbon Summary")
610
+
611
+ # Get building info for normalization
612
+ building_info = st.session_state.project_data["building_info"]
613
+ floor_area = building_info.get("floor_area", 0)
614
+
615
+ col1, col2 = st.columns(2)
616
+
617
+ with col1:
618
+ st.metric(
619
+ "Total Initial Embodied Carbon",
620
+ f"{results['total_initial_embodied_carbon']:.1f} tonnes CO2e",
621
+ help="Total embodied carbon from initial construction."
622
+ )
623
+
624
+ with col2:
625
+ st.metric(
626
+ "Total Lifecycle Embodied Carbon",
627
+ f"{results['total_lifecycle_embodied_carbon']:.1f} tonnes CO2e",
628
+ help=f"Total embodied carbon over {YEARS_FOR_ANALYSIS} years including replacements."
629
+ )
630
+
631
+ # Display normalized metrics
632
+ col1, col2 = st.columns(2)
633
+
634
+ with col1:
635
+ st.metric(
636
+ "Initial Embodied Carbon Intensity",
637
+ f"{results['initial_embodied_carbon_intensity']:.1f} kg CO2e/m²",
638
+ help="Initial embodied carbon per square meter of floor area."
639
+ )
640
+
641
+ with col2:
642
+ st.metric(
643
+ "Lifecycle Embodied Carbon Intensity",
644
+ f"{results['lifecycle_embodied_carbon_intensity']:.1f} kg CO2e/m²",
645
+ help=f"Lifecycle embodied carbon per square meter of floor area over {YEARS_FOR_ANALYSIS} years."
646
+ )
647
+
648
+ # Display embodied carbon breakdown by category
649
+ st.subheader("Embodied Carbon by Category")
650
+
651
+ # Create pie chart of initial embodied carbon by category
652
+ fig_initial = px.pie(
653
+ values=list(results["initial_embodied_carbon_by_category"].values()),
654
+ names=list(results["initial_embodied_carbon_by_category"].keys()),
655
+ title="Initial Embodied Carbon by Category"
656
+ )
657
+ st.plotly_chart(fig_initial, use_container_width=True)
658
+
659
+ # Create pie chart of lifecycle embodied carbon by category
660
+ fig_lifecycle = px.pie(
661
+ values=list(results["lifecycle_embodied_carbon_by_category"].values()),
662
+ names=list(results["lifecycle_embodied_carbon_by_category"].keys()),
663
+ title=f"Lifecycle Embodied Carbon by Category ({YEARS_FOR_ANALYSIS} years)"
664
+ )
665
+ st.plotly_chart(fig_lifecycle, use_container_width=True)
666
+
667
+ # Display embodied carbon breakdown by material
668
+ st.subheader("Top 10 Materials by Embodied Carbon")
669
+
670
+ # Create bar chart of top 10 materials by initial embodied carbon
671
+ top_materials_df = pd.DataFrame({
672
+ "Material": list(results["initial_embodied_carbon_by_material"].keys()),
673
+ "Initial Embodied Carbon (tonnes CO2e)": list(results["initial_embodied_carbon_by_material"].values())
674
+ })
675
+
676
+ top_materials_df = top_materials_df.sort_values(
677
+ by="Initial Embodied Carbon (tonnes CO2e)",
678
+ ascending=False
679
+ ).head(10)
680
+
681
+ fig_top_materials = px.bar(
682
+ top_materials_df,
683
+ x="Material",
684
+ y="Initial Embodied Carbon (tonnes CO2e)",
685
+ title="Top 10 Materials by Initial Embodied Carbon"
686
+ )
687
+ st.plotly_chart(fig_top_materials, use_container_width=True)
688
+
689
+ # Display embodied carbon over time
690
+ st.subheader("Embodied Carbon Over Time")
691
+
692
+ # Create line chart of cumulative embodied carbon over time
693
+ years = list(range(0, YEARS_FOR_ANALYSIS + 1, 5))
694
+ cumulative_carbon = [results["cumulative_embodied_carbon"].get(str(year), 0) for year in years]
695
+
696
+ fig_over_time = px.line(
697
+ x=years,
698
+ y=cumulative_carbon,
699
+ title="Cumulative Embodied Carbon Over Time",
700
+ labels={"x": "Year", "y": "Cumulative Embodied Carbon (tonnes CO2e)"}
701
+ )
702
+ st.plotly_chart(fig_over_time, use_container_width=True)
703
+
704
+ def display_carbon_payback_tab():
705
+ """Display the carbon payback analysis tab."""
706
+ st.header("Carbon Payback Analysis")
707
+
708
+ # Check if embodied carbon results are available
709
+ embodied_results = st.session_state.project_data["embodied_energy"].get("results")
710
+ if not embodied_results:
711
+ st.info("Please calculate embodied carbon in the Embodied Carbon tab.")
712
+ return
713
+
714
+ # Check if operational carbon results are available
715
+ if "building_energy" not in st.session_state.project_data or not st.session_state.project_data["building_energy"].get("results"):
716
+ st.info("Please complete the Building Energy analysis to calculate operational carbon.")
717
+ return
718
+
719
+ # Check if renewable energy results are available
720
+ renewable_results = None
721
+ if "renewable_energy" in st.session_state.project_data and st.session_state.project_data["renewable_energy"].get("results"):
722
+ renewable_results = st.session_state.project_data["renewable_energy"]["results"]
723
+
724
+ # Get operational carbon from building energy results
725
+ building_energy_results = st.session_state.project_data["building_energy"]["results"]
726
+ annual_operational_carbon = building_energy_results["annual_carbon_emissions"] # tonnes CO2e/year
727
+
728
+ # Calculate carbon payback
729
+ lifecycle_embodied_carbon = embodied_results["total_lifecycle_embodied_carbon"] # tonnes CO2e
730
+
731
+ # Display carbon comparison
732
+ st.subheader("Embodied vs. Operational Carbon")
733
+
734
+ col1, col2 = st.columns(2)
735
+
736
+ with col1:
737
+ st.metric(
738
+ "Lifecycle Embodied Carbon",
739
+ f"{lifecycle_embodied_carbon:.1f} tonnes CO2e",
740
+ help=f"Total embodied carbon over {YEARS_FOR_ANALYSIS} years including replacements."
741
+ )
742
+
743
+ with col2:
744
+ st.metric(
745
+ "Annual Operational Carbon",
746
+ f"{annual_operational_carbon:.1f} tonnes CO2e/year",
747
+ help="Annual carbon emissions from building operation."
748
+ )
749
+
750
+ # Calculate total operational carbon over building lifecycle
751
+ total_operational_carbon = annual_operational_carbon * YEARS_FOR_ANALYSIS
752
+
753
+ # Calculate total lifecycle carbon
754
+ total_lifecycle_carbon = lifecycle_embodied_carbon + total_operational_carbon
755
+
756
+ # Display total lifecycle carbon
757
+ st.metric(
758
+ f"Total Lifecycle Carbon ({YEARS_FOR_ANALYSIS} years)",
759
+ f"{total_lifecycle_carbon:.1f} tonnes CO2e",
760
+ help=f"Total carbon emissions over {YEARS_FOR_ANALYSIS} years (embodied + operational)."
761
+ )
762
+
763
+ # Create pie chart of embodied vs. operational carbon
764
+ carbon_breakdown = {
765
+ "Embodied Carbon": lifecycle_embodied_carbon,
766
+ "Operational Carbon": total_operational_carbon
767
+ }
768
+
769
+ fig_breakdown = px.pie(
770
+ values=list(carbon_breakdown.values()),
771
+ names=list(carbon_breakdown.keys()),
772
+ title=f"Lifecycle Carbon Breakdown ({YEARS_FOR_ANALYSIS} years)"
773
+ )
774
+ st.plotly_chart(fig_breakdown, use_container_width=True)
775
+
776
+ # Calculate carbon payback period
777
+ st.subheader("Carbon Payback Analysis")
778
+
779
+ # If renewable energy results are available, calculate carbon savings
780
+ if renewable_results:
781
+ # Get annual PV generation
782
+ annual_pv_generation = renewable_results["annual_pv_generation"] # kWh
783
+
784
+ # Calculate carbon savings from PV
785
+ electricity_carbon_intensity = building_energy_results["energy_rates"]["electricity"]["carbon_intensity"] # kg CO2e/kWh
786
+ annual_carbon_savings = (annual_pv_generation * electricity_carbon_intensity) / 1000 # tonnes CO2e/year
787
+
788
+ # Calculate carbon payback period
789
+ initial_embodied_carbon = embodied_results["total_initial_embodied_carbon"] # tonnes CO2e
790
+
791
+ if annual_carbon_savings > 0:
792
+ carbon_payback_period = initial_embodied_carbon / annual_carbon_savings
793
+
794
+ st.metric(
795
+ "Carbon Payback Period",
796
+ f"{carbon_payback_period:.1f} years",
797
+ help="Years required for operational carbon savings to offset initial embodied carbon."
798
+ )
799
+
800
+ # Create carbon payback chart
801
+ years = list(range(0, min(int(carbon_payback_period * 2), YEARS_FOR_ANALYSIS) + 1))
802
+ embodied_carbon = [initial_embodied_carbon] * len(years)
803
+ carbon_savings = [year * annual_carbon_savings for year in years]
804
+
805
+ fig_payback = go.Figure()
806
+
807
+ fig_payback.add_trace(go.Scatter(
808
+ x=years,
809
+ y=embodied_carbon,
810
+ mode="lines",
811
+ name="Initial Embodied Carbon"
812
+ ))
813
+
814
+ fig_payback.add_trace(go.Scatter(
815
+ x=years,
816
+ y=carbon_savings,
817
+ mode="lines",
818
+ name="Cumulative Carbon Savings"
819
+ ))
820
+
821
+ fig_payback.update_layout(
822
+ title="Carbon Payback Analysis",
823
+ xaxis_title="Year",
824
+ yaxis_title="Carbon (tonnes CO2e)"
825
+ )
826
+
827
+ st.plotly_chart(fig_payback, use_container_width=True)
828
+ else:
829
+ st.warning("No carbon savings from renewable energy. Carbon payback period is infinite.")
830
+ else:
831
+ st.info("Please calculate PV generation in the Renewable Energy section for carbon payback analysis.")
832
+
833
+ # Display carbon reduction strategies
834
+ st.subheader("Carbon Reduction Strategies")
835
+
836
+ # Create a DataFrame of potential strategies
837
+ strategies_data = {
838
+ "Strategy": [
839
+ "Use low-carbon materials",
840
+ "Optimize structural design",
841
+ "Increase renewable energy capacity",
842
+ "Improve building envelope",
843
+ "Extend material lifespans"
844
+ ],
845
+ "Potential Reduction": [
846
+ "20-30% embodied carbon",
847
+ "15-25% embodied carbon",
848
+ "50-100% operational carbon",
849
+ "20-40% operational carbon",
850
+ "10-20% lifecycle embodied carbon"
851
+ ],
852
+ "Implementation Difficulty": [
853
+ "Medium",
854
+ "High",
855
+ "Medium",
856
+ "Medium",
857
+ "Low"
858
+ ]
859
+ }
860
+
861
+ strategies_df = pd.DataFrame(strategies_data)
862
+ st.table(strategies_df)
863
+
864
+ def calculate_embodied_carbon(material_inventory: List[Dict[str, Any]], embodied_carbon_factors: Dict[str, float]) -> Dict[str, Any]:
865
+ """
866
+ Calculate embodied carbon based on material inventory and carbon factors.
867
+
868
+ Args:
869
+ material_inventory: List of material items with quantities
870
+ embodied_carbon_factors: Dictionary of embodied carbon factors by material
871
+
872
+ Returns:
873
+ Dictionary containing embodied carbon results
874
+ """
875
+ logger.info("Starting embodied carbon calculations...")
876
+
877
+ # Initialize results
878
+ initial_embodied_carbon_by_material = {}
879
+ initial_embodied_carbon_by_category = {}
880
+ lifecycle_embodied_carbon_by_category = {}
881
+ cumulative_embodied_carbon = {"0": 0}
882
+
883
+ # Calculate embodied carbon for each material
884
+ for item in material_inventory:
885
+ material_name = item["material_name"]
886
+ category = item["material_category"]
887
+ quantity = item["quantity"] # kg
888
+ replacement_cycle = item["replacement_cycle"] # years
889
+
890
+ # Get embodied carbon factor
891
+ carbon_factor = embodied_carbon_factors.get(material_name, 0) # kg CO2e/kg
892
+
893
+ # Calculate initial embodied carbon (tonnes CO2e)
894
+ initial_carbon = (quantity * carbon_factor) / 1000
895
+
896
+ # Add to material totals
897
+ if material_name in initial_embodied_carbon_by_material:
898
+ initial_embodied_carbon_by_material[material_name] += initial_carbon
899
+ else:
900
+ initial_embodied_carbon_by_material[material_name] = initial_carbon
901
+
902
+ # Add to category totals
903
+ if category in initial_embodied_carbon_by_category:
904
+ initial_embodied_carbon_by_category[category] += initial_carbon
905
+ else:
906
+ initial_embodied_carbon_by_category[category] = initial_carbon
907
+
908
+ # Calculate lifecycle embodied carbon with replacements
909
+ num_replacements = YEARS_FOR_ANALYSIS // replacement_cycle
910
+ lifecycle_carbon = initial_carbon * (num_replacements + 1) # +1 for initial installation
911
+
912
+ # Add to lifecycle category totals
913
+ if category in lifecycle_embodied_carbon_by_category:
914
+ lifecycle_embodied_carbon_by_category[category] += lifecycle_carbon
915
+ else:
916
+ lifecycle_embodied_carbon_by_category[category] = lifecycle_carbon
917
+
918
+ # Add to cumulative carbon over time
919
+ cumulative_embodied_carbon["0"] += initial_carbon
920
+
921
+ for year in range(replacement_cycle, YEARS_FOR_ANALYSIS + 1, replacement_cycle):
922
+ year_str = str(year)
923
+ if year_str not in cumulative_embodied_carbon:
924
+ cumulative_embodied_carbon[year_str] = cumulative_embodied_carbon[str(year - replacement_cycle)]
925
+
926
+ cumulative_embodied_carbon[year_str] += initial_carbon
927
+
928
+ # Calculate totals
929
+ total_initial_embodied_carbon = sum(initial_embodied_carbon_by_material.values())
930
+ total_lifecycle_embodied_carbon = sum(lifecycle_embodied_carbon_by_category.values())
931
+
932
+ # Get building info for normalization
933
+ building_info = st.session_state.project_data["building_info"]
934
+ floor_area = building_info.get("floor_area", 0)
935
+
936
+ # Calculate normalized metrics
937
+ initial_embodied_carbon_intensity = (total_initial_embodied_carbon * 1000) / floor_area if floor_area > 0 else 0
938
+ lifecycle_embodied_carbon_intensity = (total_lifecycle_embodied_carbon * 1000) / floor_area if floor_area > 0 else 0
939
+
940
+ # Compile results
941
+ results = {
942
+ "initial_embodied_carbon_by_material": initial_embodied_carbon_by_material,
943
+ "initial_embodied_carbon_by_category": initial_embodied_carbon_by_category,
944
+ "lifecycle_embodied_carbon_by_category": lifecycle_embodied_carbon_by_category,
945
+ "cumulative_embodied_carbon": cumulative_embodied_carbon,
946
+ "total_initial_embodied_carbon": total_initial_embodied_carbon,
947
+ "total_lifecycle_embodied_carbon": total_lifecycle_embodied_carbon,
948
+ "initial_embodied_carbon_intensity": initial_embodied_carbon_intensity,
949
+ "lifecycle_embodied_carbon_intensity": lifecycle_embodied_carbon_intensity,
950
+ "calculation_timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
951
+ }
952
+
953
+ logger.info("Embodied carbon calculations completed.")
954
+ return results
955
+
956
+ def display_embodied_energy_help():
957
+ """
958
+ Display help information for the embodied energy page.
959
+ """
960
+ st.markdown("""
961
+ ### Embodied Energy Analysis Help
962
+
963
+ This section calculates the embodied carbon of building materials, analyzes lifecycle carbon emissions, and evaluates carbon payback periods.
964
+
965
+ **Key Concepts:**
966
+
967
+ * **Embodied Carbon**: Greenhouse gas emissions associated with materials throughout their lifecycle (extraction, manufacturing, transportation, installation, replacement, and disposal).
968
+ * **Initial Embodied Carbon**: Carbon emissions from the initial construction of the building.
969
+ * **Lifecycle Embodied Carbon**: Total carbon emissions over the building's lifespan, including initial construction and material replacements.
970
+ * **Carbon Intensity**: Embodied carbon per unit area (kg CO2e/m²).
971
+ * **Carbon Payback Period**: Time required for operational carbon savings (e.g., from renewable energy) to offset the embodied carbon.
972
+
973
+ **Workflow:**
974
+
975
+ 1. **Material Inventory Tab**:
976
+ * Review the automatically generated material inventory based on building components.
977
+ * Add or modify materials as needed.
978
+ * View material quantity summary by category.
979
+
980
+ 2. **Embodied Carbon Tab**:
981
+ * Adjust embodied carbon factors for different materials if needed.
982
+ * Calculate embodied carbon based on the material inventory.
983
+ * Analyze embodied carbon breakdown by category and material.
984
+ * View embodied carbon accumulation over the building's lifespan.
985
+
986
+ 3. **Carbon Payback Tab**:
987
+ * Compare embodied carbon with operational carbon.
988
+ * Analyze the carbon payback period if renewable energy is implemented.
989
+ * Explore carbon reduction strategies.
990
+
991
+ **Important:**
992
+
993
+ * Accurate material quantities are crucial for embodied carbon calculations.
994
+ * Embodied carbon factors vary by region and manufacturing process.
995
+ * The standard analysis period is 60 years, but different materials have different replacement cycles.
996
+ * Carbon payback analysis requires both embodied carbon and operational carbon data.
997
+ """)
app/hvac_loads.py ADDED
@@ -0,0 +1,683 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ HVAC Load Calculator - HVAC Loads Module
3
+
4
+ This module performs the core cooling and heating load calculations based on the
5
+ ASHRAE methodology. It integrates data from climate, components, and internal loads
6
+ modules to determine the building's thermal loads.
7
+
8
+ Developed by: Dr Majed Abuseif, Deakin University
9
+ © 2025
10
+ """
11
+
12
+ import streamlit as st
13
+ import pandas as pd
14
+ import numpy as np
15
+ import json
16
+ import logging
17
+ import uuid
18
+ import plotly.graph_objects as go
19
+ import plotly.express as px
20
+ from typing import Dict, List, Any, Optional, Tuple, Union
21
+
22
+ # Configure logging
23
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
24
+ logger = logging.getLogger(__name__)
25
+
26
+ # Constants
27
+ STEFAN_BOLTZMANN = 5.67e-8 # W/m²·K⁴
28
+ ABSORPTIVITY_DEFAULT = 0.7 # Default surface absorptivity
29
+ EMISSIVITY_DEFAULT = 0.9 # Default surface emissivity
30
+ SKY_TEMP_FACTOR = 0.0552 # Factor for sky temperature calculation
31
+ AIR_DENSITY = 1.2 # kg/m³
32
+ AIR_SPECIFIC_HEAT = 1005 # J/kg·K
33
+ LATENT_HEAT_VAPORIZATION = 2260000 # J/kg
34
+
35
+ def display_hvac_loads_page():
36
+ """
37
+ Display the HVAC loads calculation page.
38
+ This is the main function called by main.py when the HVAC Loads page is selected.
39
+ """
40
+ st.title("HVAC Load Calculations")
41
+
42
+ # Display help information in an expandable section
43
+ with st.expander("Help & Information"):
44
+ display_hvac_loads_help()
45
+
46
+ # Check if necessary data is available
47
+ if not check_prerequisites():
48
+ st.warning("Please complete all previous steps (Building Info, Climate, Materials, Construction, Components, Internal Loads) before calculating HVAC loads.")
49
+ return
50
+
51
+ # Calculation trigger
52
+ if st.button("Calculate HVAC Loads", key="calculate_hvac_loads"):
53
+ try:
54
+ results = calculate_loads()
55
+ st.session_state.project_data["hvac_loads"] = results
56
+ st.success("HVAC loads calculated successfully.")
57
+ logger.info("HVAC loads calculated.")
58
+ except Exception as e:
59
+ st.error(f"Error calculating HVAC loads: {e}")
60
+ logger.error(f"Error calculating HVAC loads: {e}", exc_info=True)
61
+ st.session_state.project_data["hvac_loads"] = None
62
+
63
+ # Display results if available
64
+ if "hvac_loads" in st.session_state.project_data and st.session_state.project_data["hvac_loads"]:
65
+ display_load_results(st.session_state.project_data["hvac_loads"])
66
+ else:
67
+ st.info("Click the button above to calculate HVAC loads.")
68
+
69
+ # Navigation buttons
70
+ col1, col2 = st.columns(2)
71
+
72
+ with col1:
73
+ if st.button("Back to Internal Loads", key="back_to_internal_loads"):
74
+ st.session_state.current_page = "Internal Loads"
75
+ st.rerun()
76
+
77
+ with col2:
78
+ if st.button("Continue to Building Energy", key="continue_to_building_energy"):
79
+ st.session_state.current_page = "Building Energy"
80
+ st.rerun()
81
+
82
+ def check_prerequisites() -> bool:
83
+ """Check if all prerequisite data is available in session state."""
84
+ required_keys = [
85
+ "building_info",
86
+ "climate_data",
87
+ "materials",
88
+ "constructions",
89
+ "components",
90
+ "internal_loads"
91
+ ]
92
+
93
+ for key in required_keys:
94
+ if key not in st.session_state.project_data or not st.session_state.project_data[key]:
95
+ logger.warning(f"Prerequisite check failed: Missing data for '{key}'.")
96
+ return False
97
+
98
+ # Specific checks within components
99
+ components = st.session_state.project_data["components"]
100
+ if not components.get("walls") and not components.get("roofs") and not components.get("floors"):
101
+ logger.warning("Prerequisite check failed: No opaque components defined.")
102
+ return False
103
+
104
+ # Specific checks within climate data
105
+ climate = st.session_state.project_data["climate_data"]
106
+ if not climate.get("hourly_data") or not climate.get("design_conditions"):
107
+ logger.warning("Prerequisite check failed: Climate data not processed.")
108
+ return False
109
+
110
+ return True
111
+
112
+ def calculate_loads() -> Dict[str, Any]:
113
+ """
114
+ Perform the main HVAC load calculations.
115
+
116
+ Returns:
117
+ Dictionary containing calculation results.
118
+ """
119
+ logger.info("Starting HVAC load calculations...")
120
+
121
+ # Get data from session state
122
+ building_info = st.session_state.project_data["building_info"]
123
+ climate_data = st.session_state.project_data["climate_data"]
124
+ materials = get_available_materials()
125
+ constructions = get_available_constructions()
126
+ fenestrations = get_available_fenestrations()
127
+ components = st.session_state.project_data["components"]
128
+ internal_loads = st.session_state.project_data["internal_loads"]
129
+
130
+ # Get design conditions
131
+ design_conditions = climate_data["design_conditions"]
132
+ hourly_data = pd.DataFrame(climate_data["hourly_data"])
133
+
134
+ # --- Solar Calculations --- #
135
+ logger.info("Calculating solar geometry...")
136
+ latitude = climate_data["location"]["latitude"]
137
+ longitude = climate_data["location"]["longitude"]
138
+ timezone = climate_data["location"]["timezone"]
139
+ building_orientation_angle = building_info["orientation_angle"]
140
+
141
+ # Calculate solar angles for every hour of the year
142
+ solar_angles = calculate_solar_angles(latitude, longitude, timezone, hourly_data.index)
143
+ hourly_data = pd.concat([hourly_data, solar_angles], axis=1)
144
+
145
+ # --- Heat Transfer Calculations --- #
146
+ logger.info("Calculating heat transfer through components...")
147
+ cooling_loads = {
148
+ "opaque_conduction": np.zeros(8760),
149
+ "fenestration_conduction": np.zeros(8760),
150
+ "fenestration_solar": np.zeros(8760),
151
+ "infiltration": np.zeros(8760),
152
+ "ventilation": np.zeros(8760),
153
+ "internal_sensible": np.zeros(8760),
154
+ "internal_latent": np.zeros(8760)
155
+ }
156
+ heating_loads = {
157
+ "opaque_conduction": np.zeros(8760),
158
+ "fenestration_conduction": np.zeros(8760),
159
+ "infiltration": np.zeros(8760),
160
+ "ventilation": np.zeros(8760)
161
+ }
162
+
163
+ # 1. Opaque Surfaces (Walls, Roofs, Floors)
164
+ for comp_type in ["walls", "roofs", "floors"]:
165
+ for comp in components.get(comp_type, []):
166
+ logger.debug(f"Calculating loads for {comp_type}: {comp['name']}")
167
+ construction = constructions[comp['construction']]
168
+ u_value = construction['u_value']
169
+ area = comp['area']
170
+ tilt = comp['tilt']
171
+ orientation = comp['orientation']
172
+
173
+ # Calculate surface azimuth
174
+ surface_azimuth = calculate_surface_azimuth(orientation, building_orientation_angle)
175
+
176
+ # Calculate sol-air temperature
177
+ sol_air_temp = calculate_sol_air_temperature(
178
+ hourly_data["Dry Bulb Temperature"],
179
+ hourly_data["Direct Normal Radiation"],
180
+ hourly_data["Diffuse Horizontal Radiation"],
181
+ hourly_data["solar_altitude"],
182
+ hourly_data["solar_azimuth"],
183
+ tilt,
184
+ surface_azimuth,
185
+ absorptivity=ABSORPTIVITY_DEFAULT, # TODO: Get from material/construction
186
+ emissivity=EMISSIVITY_DEFAULT, # TODO: Get from material/construction
187
+ h_o=20.0 # TODO: Calculate based on wind speed
188
+ )
189
+
190
+ # Calculate conduction heat gain/loss
191
+ delta_t_cooling = sol_air_temp - design_conditions["cooling_indoor_temp"]
192
+ delta_t_heating = design_conditions["heating_indoor_temp"] - hourly_data["Dry Bulb Temperature"] # Use outdoor air temp for heating loss
193
+
194
+ conduction_gain = u_value * area * delta_t_cooling
195
+ conduction_loss = u_value * area * delta_t_heating
196
+
197
+ cooling_loads["opaque_conduction"] += np.maximum(0, conduction_gain) # Only gain for cooling
198
+ heating_loads["opaque_conduction"] += np.maximum(0, conduction_loss) # Only loss for heating
199
+
200
+ # 2. Fenestration (Windows, Doors, Skylights)
201
+ for comp_type in ["windows", "doors", "skylights"]:
202
+ for comp in components.get(comp_type, []):
203
+ logger.debug(f"Calculating loads for {comp_type}: {comp['name']}")
204
+ fenestration = fenestrations[comp['fenestration']]
205
+ u_value = fenestration['u_value']
206
+ shgc = fenestration['shgc']
207
+ area = comp['area']
208
+ tilt = comp['tilt']
209
+ orientation = comp['orientation']
210
+
211
+ # Calculate surface azimuth
212
+ surface_azimuth = calculate_surface_azimuth(orientation, building_orientation_angle)
213
+
214
+ # Calculate incident solar radiation on the surface
215
+ incident_solar = calculate_incident_solar(
216
+ hourly_data["Direct Normal Radiation"],
217
+ hourly_data["Diffuse Horizontal Radiation"],
218
+ hourly_data["solar_altitude"],
219
+ hourly_data["solar_azimuth"],
220
+ tilt,
221
+ surface_azimuth
222
+ )
223
+
224
+ # Calculate conduction heat gain/loss
225
+ delta_t_cooling = hourly_data["Dry Bulb Temperature"] - design_conditions["cooling_indoor_temp"]
226
+ delta_t_heating = design_conditions["heating_indoor_temp"] - hourly_data["Dry Bulb Temperature"]
227
+
228
+ conduction_gain = u_value * area * delta_t_cooling
229
+ conduction_loss = u_value * area * delta_t_heating
230
+
231
+ cooling_loads["fenestration_conduction"] += np.maximum(0, conduction_gain)
232
+ heating_loads["fenestration_conduction"] += np.maximum(0, conduction_loss)
233
+
234
+ # Calculate solar heat gain
235
+ solar_gain = shgc * area * incident_solar
236
+ cooling_loads["fenestration_solar"] += solar_gain
237
+
238
+ # 3. Infiltration
239
+ logger.info("Calculating infiltration loads...")
240
+ # Simple ACH method for now - TODO: Implement more detailed method (e.g., LBL model)
241
+ volume = building_info["floor_area"] * building_info["building_height"]
242
+ ach = 0.5 # Air changes per hour (typical value, needs refinement)
243
+ infiltration_rate_m3s = volume * ach / 3600.0
244
+
245
+ delta_t_cooling_infil = hourly_data["Dry Bulb Temperature"] - design_conditions["cooling_indoor_temp"]
246
+ delta_t_heating_infil = design_conditions["heating_indoor_temp"] - hourly_data["Dry Bulb Temperature"]
247
+
248
+ infiltration_sensible_gain = AIR_DENSITY * AIR_SPECIFIC_HEAT * infiltration_rate_m3s * delta_t_cooling_infil
249
+ infiltration_sensible_loss = AIR_DENSITY * AIR_SPECIFIC_HEAT * infiltration_rate_m3s * delta_t_heating_infil
250
+
251
+ cooling_loads["infiltration"] += np.maximum(0, infiltration_sensible_gain)
252
+ heating_loads["infiltration"] += np.maximum(0, infiltration_sensible_loss)
253
+
254
+ # TODO: Add latent infiltration load calculation
255
+
256
+ # 4. Ventilation
257
+ logger.info("Calculating ventilation loads...")
258
+ ventilation_rate_lps = building_info["ventilation_rate"] # L/s per person or L/s per m² - needs clarification
259
+ # Assuming L/s per m² for now
260
+ ventilation_rate_m3s = ventilation_rate_lps * building_info["floor_area"] / 1000.0
261
+
262
+ delta_t_cooling_vent = hourly_data["Dry Bulb Temperature"] - design_conditions["cooling_indoor_temp"]
263
+ delta_t_heating_vent = design_conditions["heating_indoor_temp"] - hourly_data["Dry Bulb Temperature"]
264
+
265
+ ventilation_sensible_gain = AIR_DENSITY * AIR_SPECIFIC_HEAT * ventilation_rate_m3s * delta_t_cooling_vent
266
+ ventilation_sensible_loss = AIR_DENSITY * AIR_SPECIFIC_HEAT * ventilation_rate_m3s * delta_t_heating_vent
267
+
268
+ cooling_loads["ventilation"] += np.maximum(0, ventilation_sensible_gain)
269
+ heating_loads["ventilation"] += np.maximum(0, ventilation_sensible_loss)
270
+
271
+ # TODO: Add latent ventilation load calculation
272
+
273
+ # 5. Internal Loads
274
+ logger.info("Calculating internal loads...")
275
+ internal_sensible, internal_latent = calculate_hourly_internal_loads(internal_loads)
276
+ cooling_loads["internal_sensible"] = internal_sensible
277
+ cooling_loads["internal_latent"] = internal_latent
278
+
279
+ # --- Total Loads --- #
280
+ logger.info("Summing up loads...")
281
+ total_cooling_sensible = (
282
+ cooling_loads["opaque_conduction"] +
283
+ cooling_loads["fenestration_conduction"] +
284
+ cooling_loads["fenestration_solar"] +
285
+ cooling_loads["infiltration"] +
286
+ cooling_loads["ventilation"] +
287
+ cooling_loads["internal_sensible"]
288
+ )
289
+ total_cooling_latent = cooling_loads["internal_latent"] # Add infiltration/ventilation latent later
290
+ total_cooling = total_cooling_sensible + total_cooling_latent
291
+
292
+ # Heating loads are losses, internal gains reduce heating need
293
+ total_heating_loss = (
294
+ heating_loads["opaque_conduction"] +
295
+ heating_loads["fenestration_conduction"] +
296
+ heating_loads["infiltration"] +
297
+ heating_loads["ventilation"]
298
+ )
299
+ # Consider internal sensible gains as reducing heating load
300
+ total_heating_needed = np.maximum(0, total_heating_loss - cooling_loads["internal_sensible"])
301
+
302
+ # --- Peak Loads --- #
303
+ logger.info("Calculating peak loads...")
304
+ peak_cooling_sensible = np.max(total_cooling_sensible)
305
+ peak_cooling_latent = np.max(total_cooling_latent)
306
+ peak_cooling_total = np.max(total_cooling)
307
+ peak_heating = np.max(total_heating_needed)
308
+
309
+ peak_cooling_sensible_hour = np.argmax(total_cooling_sensible)
310
+ peak_cooling_latent_hour = np.argmax(total_cooling_latent)
311
+ peak_cooling_total_hour = np.argmax(total_cooling)
312
+ peak_heating_hour = np.argmax(total_heating_needed)
313
+
314
+ results = {
315
+ "hourly_cooling_sensible": total_cooling_sensible.tolist(),
316
+ "hourly_cooling_latent": total_cooling_latent.tolist(),
317
+ "hourly_cooling_total": total_cooling.tolist(),
318
+ "hourly_heating": total_heating_needed.tolist(),
319
+ "cooling_load_components": {k: v.tolist() for k, v in cooling_loads.items()},
320
+ "heating_load_components": {k: v.tolist() for k, v in heating_loads.items()},
321
+ "peak_cooling_sensible": peak_cooling_sensible,
322
+ "peak_cooling_latent": peak_cooling_latent,
323
+ "peak_cooling_total": peak_cooling_total,
324
+ "peak_heating": peak_heating,
325
+ "peak_cooling_sensible_hour": int(peak_cooling_sensible_hour),
326
+ "peak_cooling_latent_hour": int(peak_cooling_latent_hour),
327
+ "peak_cooling_total_hour": int(peak_cooling_total_hour),
328
+ "peak_heating_hour": int(peak_heating_hour)
329
+ }
330
+
331
+ logger.info("HVAC load calculations completed.")
332
+ return results
333
+
334
+ def calculate_solar_angles(latitude: float, longitude: float, timezone: float, index: pd.DatetimeIndex) -> pd.DataFrame:
335
+ """
336
+ Calculate solar altitude and azimuth angles for given location and time.
337
+ Uses formulas from Duffie & Beckman, Solar Engineering of Thermal Processes.
338
+
339
+ Args:
340
+ latitude: Latitude in degrees
341
+ longitude: Longitude in degrees (East positive)
342
+ timezone: Timezone offset from UTC in hours
343
+ index: Pandas DatetimeIndex for the hours of the year
344
+
345
+ Returns:
346
+ DataFrame with solar altitude and azimuth angles in degrees.
347
+ """
348
+ # Convert angles to radians
349
+ lat_rad = np.radians(latitude)
350
+
351
+ # Day of year
352
+ day_of_year = index.dayofyear
353
+
354
+ # Equation of time (simplified)
355
+ b = 2 * np.pi * (day_of_year - 81) / 364
356
+ eot = 9.87 * np.sin(2 * b) - 7.53 * np.cos(b) - 1.5 * np.sin(b) # minutes
357
+
358
+ # Local Standard Time Meridian (LSTM)
359
+ lstm = 15 * timezone
360
+
361
+ # Time Correction Factor (TC)
362
+ tc = 4 * (longitude - lstm) + eot # minutes
363
+
364
+ # Local Solar Time (LST)
365
+ local_time_hour = index.hour + index.minute / 60.0
366
+ lst = local_time_hour + tc / 60.0
367
+
368
+ # Hour Angle (HRA)
369
+ hra = 15 * (lst - 12) # degrees
370
+ hra_rad = np.radians(hra)
371
+
372
+ # Declination Angle
373
+ declination_rad = np.radians(23.45 * np.sin(2 * np.pi * (284 + day_of_year) / 365))
374
+
375
+ # Solar Altitude (alpha)
376
+ sin_alpha = np.sin(lat_rad) * np.sin(declination_rad) + \
377
+ np.cos(lat_rad) * np.cos(declination_rad) * np.cos(hra_rad)
378
+ solar_altitude_rad = np.arcsin(np.clip(sin_alpha, -1, 1))
379
+ solar_altitude_deg = np.degrees(solar_altitude_rad)
380
+
381
+ # Solar Azimuth (gamma_s) - measured from South, positive West
382
+ cos_gamma_s = (np.sin(solar_altitude_rad) * np.sin(lat_rad) - np.sin(declination_rad)) / \
383
+ (np.cos(solar_altitude_rad) * np.cos(lat_rad))
384
+ solar_azimuth_rad = np.arccos(np.clip(cos_gamma_s, -1, 1))
385
+ solar_azimuth_deg = np.degrees(solar_azimuth_rad)
386
+ # Adjust azimuth based on hour angle
387
+ solar_azimuth_deg = np.where(hra > 0, solar_azimuth_deg, 360 - solar_azimuth_deg)
388
+ # Convert to azimuth from North, positive East (common convention)
389
+ solar_azimuth_deg = (solar_azimuth_deg + 180) % 360
390
+
391
+ return pd.DataFrame({
392
+ "solar_altitude": solar_altitude_deg,
393
+ "solar_azimuth": solar_azimuth_deg
394
+ }, index=index)
395
+
396
+ def calculate_surface_azimuth(orientation: str, building_orientation_angle: float) -> float:
397
+ """
398
+ Calculate the actual surface azimuth based on orientation label and building rotation.
399
+ Azimuth: 0=N, 90=E, 180=S, 270=W
400
+
401
+ Args:
402
+ orientation: Orientation label (e.g., "A (North)", "B (South)")
403
+ building_orientation_angle: Building rotation angle from North (degrees, positive East)
404
+
405
+ Returns:
406
+ Surface azimuth in degrees.
407
+ """
408
+ base_azimuth = {
409
+ "A (North)": 0.0,
410
+ "B (South)": 180.0,
411
+ "C (East)": 90.0,
412
+ "D (West)": 270.0,
413
+ "Horizontal": 0.0 # Azimuth doesn't matter for horizontal
414
+ }.get(orientation, 0.0)
415
+
416
+ # Adjust for building rotation
417
+ surface_azimuth = (base_azimuth + building_orientation_angle) % 360
418
+ return surface_azimuth
419
+
420
+ def calculate_incident_solar(dnr: pd.Series, dhr: pd.Series, solar_altitude: pd.Series, solar_azimuth: pd.Series, tilt: float, surface_azimuth: float) -> pd.Series:
421
+ """
422
+ Calculate total incident solar radiation on a tilted surface.
423
+ Uses Perez model for diffuse radiation is simplified here.
424
+
425
+ Args:
426
+ dnr: Direct Normal Radiation (W/m²)
427
+ dhr: Diffuse Horizontal Radiation (W/m²)
428
+ solar_altitude: Solar altitude angle (degrees)
429
+ solar_azimuth: Solar azimuth angle (degrees, 0=N, positive E)
430
+ tilt: Surface tilt angle from horizontal (degrees)
431
+ surface_azimuth: Surface azimuth angle (degrees, 0=N, positive E)
432
+
433
+ Returns:
434
+ Total incident solar radiation on the surface (W/m²).
435
+ """
436
+ # Convert angles to radians
437
+ alt_rad = np.radians(solar_altitude)
438
+ az_rad = np.radians(solar_azimuth)
439
+ tilt_rad = np.radians(tilt)
440
+ surf_az_rad = np.radians(surface_azimuth)
441
+
442
+ # Angle of Incidence (theta)
443
+ cos_theta = np.cos(alt_rad) * np.sin(tilt_rad) * np.cos(az_rad - surf_az_rad) + \
444
+ np.sin(alt_rad) * np.cos(tilt_rad)
445
+ cos_theta = np.maximum(0, cos_theta) # Radiation only incident if cos_theta > 0
446
+
447
+ # Direct radiation component on tilted surface
448
+ direct_tilted = dnr * cos_theta
449
+
450
+ # Diffuse radiation component (simplified isotropic sky model)
451
+ # TODO: Implement Perez model or Hay-Davies model for better accuracy
452
+ sky_diffuse_tilted = dhr * (1 + np.cos(tilt_rad)) / 2
453
+
454
+ # Ground reflected component (simplified)
455
+ albedo = 0.2 # Typical ground reflectance
456
+ ground_reflected_tilted = (dnr * np.sin(alt_rad) + dhr) * albedo * (1 - np.cos(tilt_rad)) / 2
457
+
458
+ # Total incident solar radiation
459
+ total_incident = direct_tilted + sky_diffuse_tilted + ground_reflected_tilted
460
+ return total_incident
461
+
462
+ def calculate_sol_air_temperature(t_oa: pd.Series, dnr: pd.Series, dhr: pd.Series, solar_altitude: pd.Series, solar_azimuth: pd.Series, tilt: float, surface_azimuth: float, absorptivity: float, emissivity: float, h_o: float) -> pd.Series:
463
+ """
464
+ Calculate Sol-Air Temperature.
465
+
466
+ Args:
467
+ t_oa: Outdoor air temperature (°C)
468
+ dnr, dhr, solar_altitude, solar_azimuth: Solar data
469
+ tilt, surface_azimuth: Surface orientation
470
+ absorptivity: Surface solar absorptivity
471
+ emissivity: Surface thermal emissivity
472
+ h_o: Outside surface heat transfer coefficient (W/m²·K)
473
+
474
+ Returns:
475
+ Sol-air temperature (°C).
476
+ """
477
+ # Calculate incident solar radiation
478
+ i_total = calculate_incident_solar(dnr, dhr, solar_altitude, solar_azimuth, tilt, surface_azimuth)
479
+
480
+ # Calculate sky temperature (simplified from Berdahl & Martin)
481
+ t_sky = t_oa * (SKY_TEMP_FACTOR * hourly_data["Dew Point Temperature"]**0.25)**0.25 # Approximation
482
+
483
+ # Longwave radiation exchange term
484
+ delta_r = STEFAN_BOLTZMANN * emissivity * ((t_oa + 273.15)**4 - (t_sky + 273.15)**4) / h_o
485
+
486
+ # Sol-air temperature
487
+ t_sol_air = t_oa + (absorptivity * i_total / h_o) - delta_r
488
+ return t_sol_air
489
+
490
+ def calculate_hourly_internal_loads(internal_loads_data: Dict[str, List[Dict[str, Any]]]) -> Tuple[np.ndarray, np.ndarray]:
491
+ """
492
+ Calculate total hourly sensible and latent internal loads.
493
+
494
+ Args:
495
+ internal_loads_data: Dictionary containing lists of loads for each type.
496
+
497
+ Returns:
498
+ Tuple of (hourly_sensible_loads, hourly_latent_loads) as numpy arrays.
499
+ """
500
+ hourly_sensible = np.zeros(8760)
501
+ hourly_latent = np.zeros(8760)
502
+
503
+ # Process each load type
504
+ for load_type, loads in internal_loads_data.items():
505
+ for load in loads:
506
+ total_load = load["total"]
507
+ schedule_type = load["schedule_type"]
508
+
509
+ # Get schedule multipliers (assuming 365 days, repeating weekly pattern)
510
+ schedule_multipliers = np.zeros(8760)
511
+ if schedule_type == "Custom" and "custom_schedule" in load:
512
+ weekday_schedule = load["custom_schedule"]["Weekday"]
513
+ weekend_schedule = load["custom_schedule"]["Weekend"]
514
+ elif schedule_type in DEFAULT_SCHEDULES:
515
+ weekday_schedule = DEFAULT_SCHEDULES[schedule_type]["Weekday"]
516
+ weekend_schedule = DEFAULT_SCHEDULES[schedule_type]["Weekend"]
517
+ else: # Continuous
518
+ weekday_schedule = [1.0] * 24
519
+ weekend_schedule = [1.0] * 24
520
+
521
+ # Apply schedule to 8760 hours (assuming standard year)
522
+ # TODO: Use actual date index for proper weekday/weekend assignment
523
+ for hour in range(8760):
524
+ day_of_week = (hour // 24) % 7 # Simple approximation (0=Mon, 6=Sun)
525
+ hour_of_day = hour % 24
526
+ if day_of_week < 5: # Weekday
527
+ schedule_multipliers[hour] = weekday_schedule[hour_of_day]
528
+ else: # Weekend
529
+ schedule_multipliers[hour] = weekend_schedule[hour_of_day]
530
+
531
+ # Calculate hourly load components
532
+ if load_type == "occupancy":
533
+ sensible_fraction = load["sensible_fraction"]
534
+ latent_fraction = load["latent_fraction"]
535
+ hourly_sensible += total_load * sensible_fraction * schedule_multipliers
536
+ hourly_latent += total_load * latent_fraction * schedule_multipliers
537
+ else: # Lighting, Equipment, Other (assumed fully sensible)
538
+ hourly_sensible += total_load * schedule_multipliers
539
+
540
+ return hourly_sensible, hourly_latent
541
+
542
+ def display_load_results(results: Dict[str, Any]):
543
+ """
544
+ Display the calculated HVAC load results.
545
+
546
+ Args:
547
+ results: Dictionary containing calculation results.
548
+ """
549
+ st.subheader("Peak Load Summary")
550
+
551
+ col1, col2, col3 = st.columns(3)
552
+ with col1:
553
+ st.metric("Peak Cooling (Total)", f"{results['peak_cooling_total'] / 1000:.1f} kW", help=f"Occurred at hour {results['peak_cooling_total_hour']}")
554
+ st.metric("Peak Cooling (Sensible)", f"{results['peak_cooling_sensible'] / 1000:.1f} kW", help=f"Occurred at hour {results['peak_cooling_sensible_hour']}")
555
+ with col2:
556
+ st.metric("Peak Heating", f"{results['peak_heating'] / 1000:.1f} kW", help=f"Occurred at hour {results['peak_heating_hour']}")
557
+ st.metric("Peak Cooling (Latent)", f"{results['peak_cooling_latent'] / 1000:.1f} kW", help=f"Occurred at hour {results['peak_cooling_latent_hour']}")
558
+
559
+ st.subheader("Hourly Load Profiles")
560
+
561
+ # Create DataFrame for plotting
562
+ hourly_df = pd.DataFrame({
563
+ "Hour": range(8760),
564
+ "Cooling (Sensible)": results["hourly_cooling_sensible"],
565
+ "Cooling (Latent)": results["hourly_cooling_latent"],
566
+ "Cooling (Total)": results["hourly_cooling_total"],
567
+ "Heating": results["hourly_heating"]
568
+ })
569
+
570
+ # Plot cooling loads
571
+ fig_cooling = go.Figure()
572
+ fig_cooling.add_trace(go.Scatter(x=hourly_df["Hour"], y=hourly_df["Cooling (Sensible)"], name="Sensible Cooling", stackgroup='one'))
573
+ fig_cooling.add_trace(go.Scatter(x=hourly_df["Hour"], y=hourly_df["Cooling (Latent)"], name="Latent Cooling", stackgroup='one'))
574
+ fig_cooling.update_layout(title="Hourly Cooling Load Profile", xaxis_title="Hour of Year", yaxis_title="Load (W)", height=400)
575
+ st.plotly_chart(fig_cooling, use_container_width=True)
576
+
577
+ # Plot heating loads
578
+ fig_heating = go.Figure()
579
+ fig_heating.add_trace(go.Scatter(x=hourly_df["Hour"], y=hourly_df["Heating"], name="Heating Load", line=dict(color='red')))
580
+ fig_heating.update_layout(title="Hourly Heating Load Profile", xaxis_title="Hour of Year", yaxis_title="Load (W)", height=400)
581
+ st.plotly_chart(fig_heating, use_container_width=True)
582
+
583
+ st.subheader("Load Components at Peak Cooling Hour")
584
+ peak_hour = results["peak_cooling_total_hour"]
585
+
586
+ cooling_components = results["cooling_load_components"]
587
+ peak_cooling_data = {
588
+ "Component": list(cooling_components.keys()),
589
+ "Load (W)": [cooling_components[k][peak_hour] for k in cooling_components]
590
+ }
591
+ peak_cooling_df = pd.DataFrame(peak_cooling_data)
592
+ peak_cooling_df = peak_cooling_df[peak_cooling_df["Load (W)"] > 0] # Show only positive contributions
593
+
594
+ fig_peak_cooling = px.pie(
595
+ peak_cooling_df,
596
+ values="Load (W)",
597
+ names="Component",
598
+ title=f"Cooling Load Breakdown at Peak Hour ({peak_hour})"
599
+ )
600
+ st.plotly_chart(fig_peak_cooling, use_container_width=True)
601
+
602
+ st.subheader("Load Components at Peak Heating Hour")
603
+ peak_hour_heating = results["peak_heating_hour"]
604
+
605
+ heating_components = results["heating_load_components"]
606
+ internal_gains_at_peak = results["cooling_load_components"]["internal_sensible"][peak_hour_heating]
607
+
608
+ peak_heating_data = {
609
+ "Component": list(heating_components.keys()) + ["Internal Gains Offset"],
610
+ "Load (W)": [heating_components[k][peak_hour_heating] for k in heating_components] + [-internal_gains_at_peak] # Show gains as negative
611
+ }
612
+ peak_heating_df = pd.DataFrame(peak_heating_data)
613
+ peak_heating_df = peak_heating_df[peak_heating_df["Load (W)"] != 0] # Show only non-zero contributions
614
+
615
+ fig_peak_heating = px.pie(
616
+ peak_heating_df,
617
+ values="Load (W)",
618
+ names="Component",
619
+ title=f"Heating Load Breakdown at Peak Hour ({peak_hour_heating})"
620
+ )
621
+ st.plotly_chart(fig_peak_heating, use_container_width=True)
622
+
623
+ # Helper functions to get data (assuming they exist in other modules or session state)
624
+ def get_available_materials() -> Dict[str, Any]:
625
+ # Placeholder - should retrieve from materials_library state
626
+ mats = {}
627
+ if "materials" in st.session_state.project_data:
628
+ mats.update(st.session_state.project_data["materials"].get("library", {}))
629
+ mats.update(st.session_state.project_data["materials"].get("project", {}))
630
+ return mats
631
+
632
+ def get_available_constructions() -> Dict[str, Any]:
633
+ # Placeholder - should retrieve from construction state
634
+ consts = {}
635
+ if "constructions" in st.session_state.project_data:
636
+ consts.update(st.session_state.project_data["constructions"].get("library", {}))
637
+ consts.update(st.session_state.project_data["constructions"].get("project", {}))
638
+ return consts
639
+
640
+ def get_available_fenestrations() -> Dict[str, Any]:
641
+ # Placeholder - should retrieve from materials_library state
642
+ fens = {}
643
+ if "fenestrations" in st.session_state.project_data:
644
+ fens.update(st.session_state.project_data["fenestrations"].get("library", {}))
645
+ fens.update(st.session_state.project_data["fenestrations"].get("project", {}))
646
+ return fens
647
+
648
+ def display_hvac_loads_help():
649
+ """
650
+ Display help information for the HVAC loads page.
651
+ """
652
+ st.markdown("""
653
+ ### HVAC Loads Help
654
+
655
+ This section calculates the building's heating and cooling loads based on the information provided in the previous steps.
656
+
657
+ **Calculation Process:**
658
+
659
+ 1. **Heat Transfer**: Calculates heat gains and losses through the building envelope (walls, roofs, floors, windows, doors, skylights) considering conduction and solar radiation.
660
+ 2. **Infiltration & Ventilation**: Calculates loads due to air exchange with the outside.
661
+ 3. **Internal Loads**: Incorporates heat gains from occupants, lighting, and equipment.
662
+ 4. **Summation**: Combines all heat gains and losses to determine the net hourly cooling and heating loads.
663
+
664
+ **Results:**
665
+
666
+ * **Peak Loads**: Shows the maximum calculated cooling and heating loads required to size HVAC equipment.
667
+ * **Hourly Profiles**: Displays graphs of the calculated loads for every hour of the year.
668
+ * **Load Components**: Shows the breakdown of loads by source (e.g., conduction, solar, internal) at the peak hours.
669
+
670
+ **Workflow:**
671
+
672
+ 1. Ensure all previous sections (Building Info, Climate, Materials, Construction, Components, Internal Loads) are complete and accurate.
673
+ 2. Click the "Calculate HVAC Loads" button.
674
+ 3. Review the peak load summary and hourly profiles.
675
+ 4. Analyze the load component breakdowns to understand the main drivers of heating and cooling needs.
676
+ 5. Continue to the Building Energy section to simulate energy consumption based on these loads.
677
+
678
+ **Important:**
679
+
680
+ * The accuracy of these calculations depends heavily on the quality of the input data.
681
+ * Calculations are based on the ASHRAE methodology.
682
+ * Review the results carefully before proceeding.
683
+ """)
app/internal_loads.py ADDED
@@ -0,0 +1,883 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ HVAC Load Calculator - Internal Loads Module
3
+
4
+ This module handles the internal loads functionality of the HVAC Load Calculator application,
5
+ allowing users to define occupancy, lighting, equipment, and other internal heat gains.
6
+ It provides schedule-based load profiles and integrates with building information.
7
+
8
+ Developed by: Dr Majed Abuseif, Deakin University
9
+ © 2025
10
+ """
11
+
12
+ import streamlit as st
13
+ import pandas as pd
14
+ import numpy as np
15
+ import json
16
+ import logging
17
+ import uuid
18
+ import plotly.graph_objects as go
19
+ import plotly.express as px
20
+ from typing import Dict, List, Any, Optional, Tuple, Union
21
+
22
+ # Configure logging
23
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
24
+ logger = logging.getLogger(__name__)
25
+
26
+ # Define constants
27
+ LOAD_TYPES = ["Occupancy", "Lighting", "Equipment", "Other"]
28
+ SCHEDULE_TYPES = ["Continuous", "Day/Night", "Custom"]
29
+ DAYS_OF_WEEK = ["Weekday", "Weekend"]
30
+
31
+ # Default occupancy densities by building type (m² per person)
32
+ DEFAULT_OCCUPANCY_DENSITIES = {
33
+ "Office": 10.0,
34
+ "Retail": 5.0,
35
+ "Residential": 20.0,
36
+ "Educational": 4.0,
37
+ "Healthcare": 8.0,
38
+ "Industrial": 15.0,
39
+ "Hospitality": 3.0,
40
+ "Other": 10.0
41
+ }
42
+
43
+ # Default lighting power densities by building type (W/m²)
44
+ DEFAULT_LIGHTING_DENSITIES = {
45
+ "Office": 12.0,
46
+ "Retail": 18.0,
47
+ "Residential": 8.0,
48
+ "Educational": 15.0,
49
+ "Healthcare": 15.0,
50
+ "Industrial": 13.0,
51
+ "Hospitality": 14.0,
52
+ "Other": 12.0
53
+ }
54
+
55
+ # Default equipment power densities by building type (W/m²)
56
+ DEFAULT_EQUIPMENT_DENSITIES = {
57
+ "Office": 15.0,
58
+ "Retail": 10.0,
59
+ "Residential": 5.0,
60
+ "Educational": 8.0,
61
+ "Healthcare": 20.0,
62
+ "Industrial": 25.0,
63
+ "Hospitality": 10.0,
64
+ "Other": 12.0
65
+ }
66
+
67
+ # Default schedules
68
+ DEFAULT_SCHEDULES = {
69
+ "Continuous": {
70
+ "Weekday": [1.0] * 24,
71
+ "Weekend": [1.0] * 24
72
+ },
73
+ "Day/Night": {
74
+ "Weekday": [0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.5, 0.8, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.8, 0.6, 0.5, 0.5, 0.4, 0.4, 0.3, 0.3],
75
+ "Weekend": [0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.4, 0.5, 0.6, 0.7, 0.7, 0.7, 0.7, 0.6, 0.5, 0.5, 0.4, 0.4, 0.4, 0.3, 0.3, 0.3, 0.3]
76
+ }
77
+ }
78
+
79
+ def display_internal_loads_page():
80
+ """
81
+ Display the internal loads page.
82
+ This is the main function called by main.py when the Internal Loads page is selected.
83
+ """
84
+ st.title("Internal Loads")
85
+
86
+ # Display help information in an expandable section
87
+ with st.expander("Help & Information"):
88
+ display_internal_loads_help()
89
+
90
+ # Initialize internal loads in session state if not present
91
+ initialize_internal_loads()
92
+
93
+ # Create tabs for different load types
94
+ tabs = st.tabs(LOAD_TYPES)
95
+
96
+ for i, load_type in enumerate(LOAD_TYPES):
97
+ with tabs[i]:
98
+ display_load_tab(load_type)
99
+
100
+ # Summary tab
101
+ st.markdown("---")
102
+ st.subheader("Internal Loads Summary")
103
+ display_loads_summary()
104
+
105
+ # Navigation buttons
106
+ col1, col2 = st.columns(2)
107
+
108
+ with col1:
109
+ if st.button("Back to Building Components", key="back_to_components"):
110
+ st.session_state.current_page = "Building Components"
111
+ st.rerun()
112
+
113
+ with col2:
114
+ if st.button("Continue to HVAC Loads", key="continue_to_hvac_loads"):
115
+ st.session_state.current_page = "HVAC Loads"
116
+ st.rerun()
117
+
118
+ def initialize_internal_loads():
119
+ """Initialize internal loads in session state if not present."""
120
+ if "internal_loads" not in st.session_state.project_data:
121
+ st.session_state.project_data["internal_loads"] = {
122
+ "occupancy": [],
123
+ "lighting": [],
124
+ "equipment": [],
125
+ "other": []
126
+ }
127
+
128
+ # Initialize load editor state
129
+ if "load_editor" not in st.session_state:
130
+ st.session_state.load_editor = {
131
+ "type": LOAD_TYPES[0],
132
+ "name": "",
133
+ "zone": "Whole Building",
134
+ "area": get_building_area(),
135
+ "density": 0.0,
136
+ "total": 0.0,
137
+ "sensible_fraction": 0.7,
138
+ "latent_fraction": 0.3,
139
+ "radiative_fraction": 0.4,
140
+ "convective_fraction": 0.6,
141
+ "schedule_type": SCHEDULE_TYPES[0],
142
+ "custom_schedule": {
143
+ "Weekday": [0.0] * 24,
144
+ "Weekend": [0.0] * 24
145
+ },
146
+ "edit_mode": False,
147
+ "original_id": ""
148
+ }
149
+
150
+ # Initialize custom zones if not present
151
+ if "custom_zones" not in st.session_state.project_data:
152
+ st.session_state.project_data["custom_zones"] = ["Whole Building"]
153
+
154
+ def display_load_tab(load_type: str):
155
+ """
156
+ Display the content for a specific load type tab.
157
+
158
+ Args:
159
+ load_type: The type of load (e.g., "Occupancy", "Lighting")
160
+ """
161
+ st.subheader(f"{load_type} Loads")
162
+
163
+ # Get loads of this type
164
+ load_key = load_type.lower()
165
+ loads = st.session_state.project_data["internal_loads"].get(load_key, [])
166
+
167
+ # Display existing loads
168
+ if loads:
169
+ display_load_list(load_type, loads)
170
+ else:
171
+ st.info(f"No {load_type.lower()} loads added yet. Use the editor below to add loads.")
172
+
173
+ # Load Editor
174
+ st.markdown("---")
175
+ st.subheader(f"{load_type} Load Editor")
176
+ display_load_editor(load_type)
177
+
178
+ def display_load_list(load_type: str, loads: List[Dict[str, Any]]):
179
+ """
180
+ Display the list of existing loads for a given type.
181
+
182
+ Args:
183
+ load_type: The type of load
184
+ loads: List of load dictionaries
185
+ """
186
+ # Create a DataFrame for display
187
+ data = []
188
+ for i, load in enumerate(loads):
189
+ record = {
190
+ "#": i + 1,
191
+ "Name": load["name"],
192
+ "Zone": load["zone"],
193
+ "Area (m²)": load["area"],
194
+ "Total (W)": load["total"],
195
+ "Density (W/m²)": load["density"],
196
+ "Schedule": load["schedule_type"]
197
+ }
198
+
199
+ if load_type == "Occupancy":
200
+ record["Sensible (W)"] = load["total"] * load["sensible_fraction"]
201
+ record["Latent (W)"] = load["total"] * load["latent_fraction"]
202
+ else:
203
+ record["Radiative (W)"] = load["total"] * load["radiative_fraction"]
204
+ record["Convective (W)"] = load["total"] * load["convective_fraction"]
205
+
206
+ data.append(record)
207
+
208
+ df = pd.DataFrame(data)
209
+ st.dataframe(df, use_container_width=True, hide_index=True)
210
+
211
+ # Edit and delete options
212
+ col1, col2 = st.columns(2)
213
+
214
+ with col1:
215
+ selected_index = st.selectbox(
216
+ f"Select {load_type} Load # to Edit",
217
+ range(1, len(loads) + 1),
218
+ key=f"edit_{load_type}_selector"
219
+ )
220
+
221
+ if st.button(f"Edit {load_type} Load", key=f"edit_{load_type}_button"):
222
+ # Load data into editor
223
+ load_data = loads[selected_index - 1]
224
+ st.session_state.load_editor = {
225
+ "type": load_type,
226
+ "name": load_data["name"],
227
+ "zone": load_data["zone"],
228
+ "area": load_data["area"],
229
+ "density": load_data["density"],
230
+ "total": load_data["total"],
231
+ "sensible_fraction": load_data.get("sensible_fraction", 0.7),
232
+ "latent_fraction": load_data.get("latent_fraction", 0.3),
233
+ "radiative_fraction": load_data.get("radiative_fraction", 0.4),
234
+ "convective_fraction": load_data.get("convective_fraction", 0.6),
235
+ "schedule_type": load_data["schedule_type"],
236
+ "custom_schedule": load_data.get("custom_schedule", {
237
+ "Weekday": [0.0] * 24,
238
+ "Weekend": [0.0] * 24
239
+ }),
240
+ "edit_mode": True,
241
+ "original_id": load_data["id"]
242
+ }
243
+ st.success(f"{load_type} Load '{load_data['name']}' loaded for editing.")
244
+ st.rerun()
245
+
246
+ with col2:
247
+ selected_index_delete = st.selectbox(
248
+ f"Select {load_type} Load # to Delete",
249
+ range(1, len(loads) + 1),
250
+ key=f"delete_{load_type}_selector"
251
+ )
252
+
253
+ if st.button(f"Delete {load_type} Load", key=f"delete_{load_type}_button"):
254
+ # Delete load
255
+ load_key = load_type.lower()
256
+ deleted_load = st.session_state.project_data["internal_loads"][load_key].pop(selected_index_delete - 1)
257
+ st.success(f"{load_type} Load '{deleted_load['name']}' deleted.")
258
+ logger.info(f"Deleted {load_type} Load '{deleted_load['name']}'")
259
+ st.rerun()
260
+
261
+ def display_load_editor(load_type: str):
262
+ """
263
+ Display the editor form for a specific load type.
264
+
265
+ Args:
266
+ load_type: The type of load
267
+ """
268
+ # Check if the editor is currently set to this load type
269
+ if st.session_state.load_editor["type"] != load_type and not st.session_state.load_editor["edit_mode"]:
270
+ reset_load_editor(load_type)
271
+
272
+ # Get building information
273
+ building_area = get_building_area()
274
+ building_type = get_building_type()
275
+
276
+ with st.form(f"{load_type}_editor_form"):
277
+ # Load name
278
+ name = st.text_input(
279
+ "Load Name",
280
+ value=st.session_state.load_editor["name"],
281
+ help="Enter a unique name for this load."
282
+ )
283
+
284
+ # Create columns for layout
285
+ col1, col2 = st.columns(2)
286
+
287
+ with col1:
288
+ # Zone selection
289
+ zones = st.session_state.project_data["custom_zones"]
290
+ zone = st.selectbox(
291
+ "Zone",
292
+ zones,
293
+ index=zones.index(st.session_state.load_editor["zone"]) if st.session_state.load_editor["zone"] in zones else 0,
294
+ help="Select the zone for this load."
295
+ )
296
+
297
+ # Area
298
+ area = st.number_input(
299
+ "Area (m²)",
300
+ min_value=0.1,
301
+ max_value=float(building_area),
302
+ value=min(float(st.session_state.load_editor["area"]), float(building_area)),
303
+ format="%.2f",
304
+ help="Floor area affected by this load."
305
+ )
306
+
307
+ with col2:
308
+ # Density or total selection
309
+ input_mode = st.radio(
310
+ "Input Mode",
311
+ ["Density (W/m²)", "Total Load (W)"],
312
+ horizontal=True,
313
+ help="Choose whether to input load density or total load."
314
+ )
315
+
316
+ if input_mode == "Density (W/m²)":
317
+ # Set default density based on building type if not in edit mode
318
+ default_density = 0.0
319
+ if not st.session_state.load_editor["edit_mode"]:
320
+ if load_type == "Occupancy":
321
+ # For occupancy, we need to convert from m²/person to W/m²
322
+ people_density = 1.0 / DEFAULT_OCCUPANCY_DENSITIES.get(building_type, 10.0)
323
+ default_density = people_density * 115.0 # 115W per person (sensible + latent)
324
+ elif load_type == "Lighting":
325
+ default_density = DEFAULT_LIGHTING_DENSITIES.get(building_type, 12.0)
326
+ elif load_type == "Equipment":
327
+ default_density = DEFAULT_EQUIPMENT_DENSITIES.get(building_type, 15.0)
328
+ else: # Other
329
+ default_density = 5.0
330
+ else:
331
+ default_density = st.session_state.load_editor["density"]
332
+
333
+ density = st.number_input(
334
+ f"{load_type} Density (W/m²)",
335
+ min_value=0.0,
336
+ max_value=1000.0,
337
+ value=default_density,
338
+ format="%.2f",
339
+ help=f"Power density for {load_type.lower()}."
340
+ )
341
+ total = density * area
342
+ else:
343
+ # Total load input
344
+ default_total = st.session_state.load_editor["total"] if st.session_state.load_editor["edit_mode"] else 0.0
345
+ total = st.number_input(
346
+ f"Total {load_type} Load (W)",
347
+ min_value=0.0,
348
+ max_value=1000000.0,
349
+ value=default_total,
350
+ format="%.1f",
351
+ help=f"Total power for {load_type.lower()}."
352
+ )
353
+ density = total / area if area > 0 else 0.0
354
+
355
+ # Load-specific properties
356
+ st.subheader("Load Properties")
357
+
358
+ if load_type == "Occupancy":
359
+ col1, col2 = st.columns(2)
360
+
361
+ with col1:
362
+ sensible_fraction = st.slider(
363
+ "Sensible Heat Fraction",
364
+ min_value=0.0,
365
+ max_value=1.0,
366
+ value=float(st.session_state.load_editor["sensible_fraction"]),
367
+ format="%.2f",
368
+ help="Fraction of heat that is sensible (affects air temperature)."
369
+ )
370
+
371
+ with col2:
372
+ latent_fraction = st.slider(
373
+ "Latent Heat Fraction",
374
+ min_value=0.0,
375
+ max_value=1.0,
376
+ value=float(st.session_state.load_editor["latent_fraction"]),
377
+ format="%.2f",
378
+ help="Fraction of heat that is latent (affects humidity)."
379
+ )
380
+
381
+ # Ensure fractions sum to 1.0
382
+ if abs(sensible_fraction + latent_fraction - 1.0) > 0.01:
383
+ st.warning("Sensible and latent fractions should sum to 1.0. Values will be normalized.")
384
+ total_fraction = sensible_fraction + latent_fraction
385
+ if total_fraction > 0:
386
+ sensible_fraction = sensible_fraction / total_fraction
387
+ latent_fraction = latent_fraction / total_fraction
388
+ else:
389
+ sensible_fraction = 0.7
390
+ latent_fraction = 0.3
391
+
392
+ radiative_fraction = 0.0
393
+ convective_fraction = 0.0
394
+ else:
395
+ col1, col2 = st.columns(2)
396
+
397
+ with col1:
398
+ radiative_fraction = st.slider(
399
+ "Radiative Heat Fraction",
400
+ min_value=0.0,
401
+ max_value=1.0,
402
+ value=float(st.session_state.load_editor["radiative_fraction"]),
403
+ format="%.2f",
404
+ help="Fraction of heat that is radiative (affects surface temperatures)."
405
+ )
406
+
407
+ with col2:
408
+ convective_fraction = st.slider(
409
+ "Convective Heat Fraction",
410
+ min_value=0.0,
411
+ max_value=1.0,
412
+ value=float(st.session_state.load_editor["convective_fraction"]),
413
+ format="%.2f",
414
+ help="Fraction of heat that is convective (affects air temperature)."
415
+ )
416
+
417
+ # Ensure fractions sum to 1.0
418
+ if abs(radiative_fraction + convective_fraction - 1.0) > 0.01:
419
+ st.warning("Radiative and convective fractions should sum to 1.0. Values will be normalized.")
420
+ total_fraction = radiative_fraction + convective_fraction
421
+ if total_fraction > 0:
422
+ radiative_fraction = radiative_fraction / total_fraction
423
+ convective_fraction = convective_fraction / total_fraction
424
+ else:
425
+ radiative_fraction = 0.4
426
+ convective_fraction = 0.6
427
+
428
+ sensible_fraction = 1.0
429
+ latent_fraction = 0.0
430
+
431
+ # Schedule
432
+ st.subheader("Schedule")
433
+
434
+ schedule_type = st.selectbox(
435
+ "Schedule Type",
436
+ SCHEDULE_TYPES,
437
+ index=SCHEDULE_TYPES.index(st.session_state.load_editor["schedule_type"]) if st.session_state.load_editor["schedule_type"] in SCHEDULE_TYPES else 0,
438
+ help="Select the schedule type for this load."
439
+ )
440
+
441
+ if schedule_type == "Custom":
442
+ st.write("Define custom schedule for each day type:")
443
+
444
+ # Initialize custom schedule if not present
445
+ if "custom_schedule" not in st.session_state.load_editor or not st.session_state.load_editor["custom_schedule"]:
446
+ st.session_state.load_editor["custom_schedule"] = {
447
+ "Weekday": [0.0] * 24,
448
+ "Weekend": [0.0] * 24
449
+ }
450
+
451
+ # Create tabs for day types
452
+ day_tabs = st.tabs(DAYS_OF_WEEK)
453
+
454
+ for i, day_type in enumerate(DAYS_OF_WEEK):
455
+ with day_tabs[i]:
456
+ # Get current schedule values
457
+ current_values = st.session_state.load_editor["custom_schedule"].get(day_type, [0.0] * 24)
458
+
459
+ # Create sliders for each hour
460
+ for hour in range(0, 24, 3):
461
+ cols = st.columns(3)
462
+ for j in range(3):
463
+ if hour + j < 24:
464
+ with cols[j]:
465
+ hour_label = f"{hour + j:02d}:00 - {hour + j + 1:02d}:00"
466
+ current_values[hour + j] = st.slider(
467
+ hour_label,
468
+ min_value=0.0,
469
+ max_value=1.0,
470
+ value=float(current_values[hour + j]),
471
+ format="%.2f",
472
+ key=f"schedule_{day_type}_{hour + j}"
473
+ )
474
+
475
+ # Update custom schedule
476
+ st.session_state.load_editor["custom_schedule"][day_type] = current_values
477
+
478
+ # Display schedule as a chart
479
+ fig = px.bar(
480
+ x=list(range(24)),
481
+ y=current_values,
482
+ labels={"x": "Hour", "y": "Load Factor"},
483
+ title=f"{day_type} Schedule"
484
+ )
485
+ fig.update_layout(height=300)
486
+ st.plotly_chart(fig, use_container_width=True)
487
+ else:
488
+ # Display predefined schedule
489
+ if schedule_type in DEFAULT_SCHEDULES:
490
+ schedule_data = DEFAULT_SCHEDULES[schedule_type]
491
+
492
+ # Create tabs for day types
493
+ day_tabs = st.tabs(DAYS_OF_WEEK)
494
+
495
+ for i, day_type in enumerate(DAYS_OF_WEEK):
496
+ with day_tabs[i]:
497
+ # Display schedule as a chart
498
+ fig = px.bar(
499
+ x=list(range(24)),
500
+ y=schedule_data[day_type],
501
+ labels={"x": "Hour", "y": "Load Factor"},
502
+ title=f"{day_type} Schedule"
503
+ )
504
+ fig.update_layout(height=300)
505
+ st.plotly_chart(fig, use_container_width=True)
506
+
507
+ # Form submission buttons
508
+ col1, col2 = st.columns(2)
509
+
510
+ with col1:
511
+ submit_button = st.form_submit_button("Save Load")
512
+
513
+ with col2:
514
+ clear_button = st.form_submit_button("Clear Form")
515
+
516
+ # Handle form submission
517
+ if submit_button:
518
+ # Validate inputs
519
+ validation_errors = validate_load(
520
+ load_type, name, zone, area, density, total,
521
+ sensible_fraction, latent_fraction, radiative_fraction, convective_fraction,
522
+ schedule_type, st.session_state.load_editor["custom_schedule"] if schedule_type == "Custom" else None,
523
+ st.session_state.load_editor["edit_mode"], st.session_state.load_editor["original_id"]
524
+ )
525
+
526
+ if validation_errors:
527
+ # Display validation errors
528
+ for error in validation_errors:
529
+ st.error(error)
530
+ else:
531
+ # Create load data
532
+ load_data = {
533
+ "id": st.session_state.load_editor["original_id"] if st.session_state.load_editor["edit_mode"] else str(uuid.uuid4()),
534
+ "name": name,
535
+ "type": load_type,
536
+ "zone": zone,
537
+ "area": area,
538
+ "density": density,
539
+ "total": total,
540
+ "schedule_type": schedule_type
541
+ }
542
+
543
+ # Add load-specific properties
544
+ if load_type == "Occupancy":
545
+ load_data["sensible_fraction"] = sensible_fraction
546
+ load_data["latent_fraction"] = latent_fraction
547
+ else:
548
+ load_data["radiative_fraction"] = radiative_fraction
549
+ load_data["convective_fraction"] = convective_fraction
550
+
551
+ # Add custom schedule if applicable
552
+ if schedule_type == "Custom":
553
+ load_data["custom_schedule"] = st.session_state.load_editor["custom_schedule"]
554
+
555
+ # Handle edit mode
556
+ load_key = load_type.lower()
557
+ if st.session_state.load_editor["edit_mode"]:
558
+ # Find and update the load
559
+ loads = st.session_state.project_data["internal_loads"][load_key]
560
+ for i, load in enumerate(loads):
561
+ if load["id"] == st.session_state.load_editor["original_id"]:
562
+ loads[i] = load_data
563
+ break
564
+ st.success(f"{load_type} Load '{name}' updated successfully.")
565
+ logger.info(f"Updated {load_type} Load '{name}'")
566
+ else:
567
+ # Add new load
568
+ st.session_state.project_data["internal_loads"][load_key].append(load_data)
569
+ st.success(f"{load_type} Load '{name}' added successfully.")
570
+ logger.info(f"Added new {load_type} Load '{name}'")
571
+
572
+ # Reset editor
573
+ reset_load_editor(load_type)
574
+ st.rerun()
575
+
576
+ # Handle clear button
577
+ if clear_button:
578
+ reset_load_editor(load_type)
579
+ st.rerun()
580
+
581
+ def display_loads_summary():
582
+ """Display a summary of all internal loads."""
583
+ # Get all loads
584
+ all_loads = []
585
+ for load_type in LOAD_TYPES:
586
+ load_key = load_type.lower()
587
+ loads = st.session_state.project_data["internal_loads"].get(load_key, [])
588
+ for load in loads:
589
+ all_loads.append({
590
+ "Type": load_type,
591
+ "Name": load["name"],
592
+ "Zone": load["zone"],
593
+ "Total (W)": load["total"]
594
+ })
595
+
596
+ if all_loads:
597
+ # Create a DataFrame for display
598
+ df = pd.DataFrame(all_loads)
599
+
600
+ # Calculate totals by type
601
+ totals_by_type = df.groupby("Type")["Total (W)"].sum().reset_index()
602
+
603
+ # Display summary table
604
+ st.subheader("Total Internal Loads by Type")
605
+ st.dataframe(totals_by_type, use_container_width=True, hide_index=True)
606
+
607
+ # Display pie chart
608
+ fig = px.pie(
609
+ totals_by_type,
610
+ values="Total (W)",
611
+ names="Type",
612
+ title="Internal Loads Distribution"
613
+ )
614
+ st.plotly_chart(fig, use_container_width=True)
615
+
616
+ # Calculate peak load profiles
617
+ st.subheader("Peak Load Profiles")
618
+
619
+ # Create tabs for day types
620
+ day_tabs = st.tabs(DAYS_OF_WEEK)
621
+
622
+ for i, day_type in enumerate(DAYS_OF_WEEK):
623
+ with day_tabs[i]:
624
+ # Calculate hourly profiles for each load type
625
+ hourly_data = calculate_hourly_profiles(day_type)
626
+
627
+ if hourly_data:
628
+ # Create a stacked area chart
629
+ fig = go.Figure()
630
+
631
+ for load_type in LOAD_TYPES:
632
+ if load_type in hourly_data:
633
+ fig.add_trace(go.Scatter(
634
+ x=list(range(24)),
635
+ y=hourly_data[load_type],
636
+ mode='lines',
637
+ name=load_type,
638
+ stackgroup='one',
639
+ line=dict(width=0.5)
640
+ ))
641
+
642
+ fig.update_layout(
643
+ title=f"{day_type} Hourly Load Profile",
644
+ xaxis_title="Hour",
645
+ yaxis_title="Load (W)",
646
+ legend_title="Load Type",
647
+ height=400
648
+ )
649
+
650
+ st.plotly_chart(fig, use_container_width=True)
651
+ else:
652
+ st.info("No load profiles available. Add loads to see hourly profiles.")
653
+ else:
654
+ st.info("No internal loads defined yet. Add loads in the tabs above to see a summary.")
655
+
656
+ def calculate_hourly_profiles(day_type: str) -> Dict[str, List[float]]:
657
+ """
658
+ Calculate hourly load profiles for each load type.
659
+
660
+ Args:
661
+ day_type: Day type ("Weekday" or "Weekend")
662
+
663
+ Returns:
664
+ Dictionary of load type to hourly values
665
+ """
666
+ hourly_data = {}
667
+
668
+ for load_type in LOAD_TYPES:
669
+ load_key = load_type.lower()
670
+ loads = st.session_state.project_data["internal_loads"].get(load_key, [])
671
+
672
+ if loads:
673
+ hourly_values = [0.0] * 24
674
+
675
+ for load in loads:
676
+ # Get schedule values
677
+ schedule_values = []
678
+
679
+ if load["schedule_type"] == "Custom" and "custom_schedule" in load:
680
+ schedule_values = load["custom_schedule"].get(day_type, [0.0] * 24)
681
+ elif load["schedule_type"] in DEFAULT_SCHEDULES:
682
+ schedule_values = DEFAULT_SCHEDULES[load["schedule_type"]].get(day_type, [0.0] * 24)
683
+ else:
684
+ schedule_values = [1.0] * 24
685
+
686
+ # Apply schedule to load
687
+ for hour in range(24):
688
+ hourly_values[hour] += load["total"] * schedule_values[hour]
689
+
690
+ hourly_data[load_type] = hourly_values
691
+
692
+ return hourly_data
693
+
694
+ def get_building_area() -> float:
695
+ """
696
+ Get the total floor area from building information.
697
+
698
+ Returns:
699
+ Total floor area in m²
700
+ """
701
+ if "building_info" in st.session_state.project_data and "floor_area" in st.session_state.project_data["building_info"]:
702
+ return st.session_state.project_data["building_info"]["floor_area"]
703
+ return 100.0 # Default value
704
+
705
+ def get_building_type() -> str:
706
+ """
707
+ Get the building type from building information.
708
+
709
+ Returns:
710
+ Building type
711
+ """
712
+ if "building_info" in st.session_state.project_data and "building_type" in st.session_state.project_data["building_info"]:
713
+ return st.session_state.project_data["building_info"]["building_type"]
714
+ return "Other" # Default value
715
+
716
+ def validate_load(
717
+ load_type: str, name: str, zone: str, area: float, density: float, total: float,
718
+ sensible_fraction: float, latent_fraction: float, radiative_fraction: float, convective_fraction: float,
719
+ schedule_type: str, custom_schedule: Optional[Dict[str, List[float]]], edit_mode: bool, original_id: str
720
+ ) -> List[str]:
721
+ """
722
+ Validate load inputs.
723
+
724
+ Args:
725
+ load_type: Type of load
726
+ name: Load name
727
+ zone: Zone name
728
+ area: Floor area
729
+ density: Power density
730
+ total: Total power
731
+ sensible_fraction: Sensible heat fraction
732
+ latent_fraction: Latent heat fraction
733
+ radiative_fraction: Radiative heat fraction
734
+ convective_fraction: Convective heat fraction
735
+ schedule_type: Schedule type
736
+ custom_schedule: Custom schedule data
737
+ edit_mode: Whether in edit mode
738
+ original_id: Original ID if in edit mode
739
+
740
+ Returns:
741
+ List of validation error messages, empty if all inputs are valid
742
+ """
743
+ errors = []
744
+
745
+ # Validate name
746
+ if not name or name.strip() == "":
747
+ errors.append("Load name is required.")
748
+
749
+ # Check for name uniqueness within the same load type
750
+ load_key = load_type.lower()
751
+ loads = st.session_state.project_data["internal_loads"].get(load_key, [])
752
+
753
+ for load in loads:
754
+ if load["name"] == name and (not edit_mode or load["id"] != original_id):
755
+ errors.append(f"{load_type} Load name '{name}' already exists.")
756
+ break
757
+
758
+ # Validate zone
759
+ if not zone:
760
+ errors.append("Zone selection is required.")
761
+
762
+ # Validate area
763
+ if area <= 0:
764
+ errors.append("Area must be greater than zero.")
765
+
766
+ # Validate density and total
767
+ if density <= 0 and total <= 0:
768
+ errors.append("Either density or total load must be greater than zero.")
769
+
770
+ # Validate fractions
771
+ if load_type == "Occupancy":
772
+ if abs(sensible_fraction + latent_fraction - 1.0) > 0.01:
773
+ errors.append("Sensible and latent fractions should sum to 1.0.")
774
+ else:
775
+ if abs(radiative_fraction + convective_fraction - 1.0) > 0.01:
776
+ errors.append("Radiative and convective fractions should sum to 1.0.")
777
+
778
+ # Validate schedule
779
+ if schedule_type == "Custom":
780
+ if not custom_schedule:
781
+ errors.append("Custom schedule data is missing.")
782
+ else:
783
+ for day_type in DAYS_OF_WEEK:
784
+ if day_type not in custom_schedule or len(custom_schedule[day_type]) != 24:
785
+ errors.append(f"Custom schedule for {day_type} must have 24 hourly values.")
786
+
787
+ return errors
788
+
789
+ def reset_load_editor(load_type: str):
790
+ """
791
+ Reset the load editor to default values for the given type.
792
+
793
+ Args:
794
+ load_type: The type of load
795
+ """
796
+ # Get building information
797
+ building_area = get_building_area()
798
+ building_type = get_building_type()
799
+
800
+ # Set default density based on building type
801
+ default_density = 0.0
802
+ if load_type == "Occupancy":
803
+ # For occupancy, we need to convert from m²/person to W/m²
804
+ people_density = 1.0 / DEFAULT_OCCUPANCY_DENSITIES.get(building_type, 10.0)
805
+ default_density = people_density * 115.0 # 115W per person (sensible + latent)
806
+ elif load_type == "Lighting":
807
+ default_density = DEFAULT_LIGHTING_DENSITIES.get(building_type, 12.0)
808
+ elif load_type == "Equipment":
809
+ default_density = DEFAULT_EQUIPMENT_DENSITIES.get(building_type, 15.0)
810
+ else: # Other
811
+ default_density = 5.0
812
+
813
+ st.session_state.load_editor = {
814
+ "type": load_type,
815
+ "name": "",
816
+ "zone": "Whole Building",
817
+ "area": building_area,
818
+ "density": default_density,
819
+ "total": default_density * building_area,
820
+ "sensible_fraction": 0.7,
821
+ "latent_fraction": 0.3,
822
+ "radiative_fraction": 0.4,
823
+ "convective_fraction": 0.6,
824
+ "schedule_type": SCHEDULE_TYPES[0],
825
+ "custom_schedule": {
826
+ "Weekday": [0.0] * 24,
827
+ "Weekend": [0.0] * 24
828
+ },
829
+ "edit_mode": False,
830
+ "original_id": ""
831
+ }
832
+
833
+ def display_internal_loads_help():
834
+ """
835
+ Display help information for the internal loads page.
836
+ """
837
+ st.markdown("""
838
+ ### Internal Loads Help
839
+
840
+ This section allows you to define the internal heat gains in your building from occupants, lighting, equipment, and other sources.
841
+
842
+ **Key Concepts:**
843
+
844
+ * **Internal Loads**: Heat gains inside the building that contribute to cooling loads and reduce heating loads.
845
+ * **Occupancy Loads**: Heat generated by people, including both sensible heat (affects air temperature) and latent heat (affects humidity).
846
+ * **Lighting Loads**: Heat generated by lighting fixtures, primarily as a combination of radiative and convective heat.
847
+ * **Equipment Loads**: Heat generated by appliances, computers, and other electrical equipment.
848
+ * **Schedules**: Time-based patterns that define when loads are active throughout the day.
849
+
850
+ **Load Properties:**
851
+
852
+ * **Density**: Power per unit area (W/m²).
853
+ * **Total**: Total power for the load (W).
854
+ * **Sensible Heat**: Heat that directly affects air temperature.
855
+ * **Latent Heat**: Heat that affects humidity (moisture in the air).
856
+ * **Radiative Heat**: Heat transferred by radiation to surfaces.
857
+ * **Convective Heat**: Heat transferred directly to the air.
858
+
859
+ **Schedules:**
860
+
861
+ * **Continuous**: Load is constant throughout the day.
862
+ * **Day/Night**: Load varies between day and night hours.
863
+ * **Custom**: Define your own hourly schedule for weekdays and weekends.
864
+
865
+ **Workflow:**
866
+
867
+ 1. Select the tab for the load type you want to define (e.g., "Occupancy").
868
+ 2. Use the editor to add new loads:
869
+ * Give the load a unique name.
870
+ * Select the zone it applies to.
871
+ * Enter the area and either the density or total load.
872
+ * Set the appropriate heat fractions.
873
+ * Choose or define a schedule.
874
+ 3. Save the load.
875
+ 4. Repeat for all internal load sources.
876
+ 5. Review the summary to see the total internal loads and hourly profiles.
877
+
878
+ **Important:**
879
+
880
+ * Internal loads are a significant factor in cooling load calculations.
881
+ * Accurate schedules are essential for proper load calculations.
882
+ * The summary section shows the combined effect of all internal loads.
883
+ """)
app/materials_cost.py ADDED
@@ -0,0 +1,889 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ HVAC Load Calculator - Materials Cost Module
3
+
4
+ This module handles the cost calculations for building materials,
5
+ lifecycle cost analysis including initial, replacement, and maintenance costs,
6
+ cost optimization recommendations, and economic payback period analysis.
7
+
8
+ Developed by: Dr Majed Abuseif, Deakin University
9
+ © 2025
10
+ """
11
+
12
+ import streamlit as st
13
+ import pandas as pd
14
+ import numpy as np
15
+ import json
16
+ import logging
17
+ import plotly.graph_objects as go
18
+ import plotly.express as px
19
+ from typing import Dict, List, Any, Optional, Tuple, Union
20
+ from datetime import datetime
21
+
22
+ # Configure logging
23
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
24
+ logger = logging.getLogger(__name__)
25
+
26
+ # Constants
27
+ YEARS_FOR_ANALYSIS = 60 # Standard building lifecycle for cost analysis
28
+ DISCOUNT_RATE = 0.03 # Default discount rate for NPV calculations (3%)
29
+ INFLATION_RATE = 0.025 # Default inflation rate (2.5%)
30
+
31
+ # Default material costs ($/kg)
32
+ DEFAULT_MATERIAL_COSTS = {
33
+ "Concrete": 0.12,
34
+ "Steel": 2.50,
35
+ "Timber": 1.80,
36
+ "Brick": 0.45,
37
+ "Glass": 3.20,
38
+ "Aluminum": 4.50,
39
+ "Insulation (mineral wool)": 2.80,
40
+ "Insulation (EPS)": 3.50,
41
+ "Gypsum board": 1.20,
42
+ "Carpet": 15.00,
43
+ "Ceramic tile": 8.50,
44
+ "PVC": 3.80,
45
+ "Paint": 12.00,
46
+ "HVAC equipment": 25.00,
47
+ "Electrical equipment": 35.00,
48
+ "Plumbing fixtures": 18.00
49
+ }
50
+
51
+ # Default labor costs ($/kg for installation)
52
+ DEFAULT_LABOR_COSTS = {
53
+ "Concrete": 0.08,
54
+ "Steel": 1.20,
55
+ "Timber": 0.90,
56
+ "Brick": 0.35,
57
+ "Glass": 2.50,
58
+ "Aluminum": 1.80,
59
+ "Insulation (mineral wool)": 1.50,
60
+ "Insulation (EPS)": 1.20,
61
+ "Gypsum board": 2.00,
62
+ "Carpet": 8.00,
63
+ "Ceramic tile": 12.00,
64
+ "PVC": 2.50,
65
+ "Paint": 8.00,
66
+ "HVAC equipment": 15.00,
67
+ "Electrical equipment": 20.00,
68
+ "Plumbing fixtures": 12.00
69
+ }
70
+
71
+ def display_materials_cost_page():
72
+ """
73
+ Display the materials cost page.
74
+ This is the main function called by main.py when the Materials Cost page is selected.
75
+ """
76
+ st.title("Materials Cost Analysis")
77
+
78
+ # Display help information in an expandable section
79
+ with st.expander("Help & Information"):
80
+ display_materials_cost_help()
81
+
82
+ # Check if embodied energy has been calculated (for material inventory)
83
+ if "embodied_energy" not in st.session_state.project_data or not st.session_state.project_data["embodied_energy"].get("material_inventory"):
84
+ st.warning("Please complete the Embodied Energy analysis to generate material inventory before proceeding to Materials Cost analysis.")
85
+
86
+ # Navigation buttons
87
+ col1, col2 = st.columns(2)
88
+ with col1:
89
+ if st.button("Back to Embodied Energy", key="back_to_embodied_energy_mc"):
90
+ st.session_state.current_page = "Embodied Energy"
91
+ st.rerun()
92
+ return
93
+
94
+ # Initialize materials cost data if not present
95
+ initialize_materials_cost_data()
96
+
97
+ # Create tabs for different aspects of materials cost analysis
98
+ tabs = st.tabs(["Cost Parameters", "Cost Analysis", "Lifecycle Costs", "Cost Optimization"])
99
+
100
+ with tabs[0]:
101
+ display_cost_parameters_tab()
102
+
103
+ with tabs[1]:
104
+ display_cost_analysis_tab()
105
+
106
+ with tabs[2]:
107
+ display_lifecycle_costs_tab()
108
+
109
+ with tabs[3]:
110
+ display_cost_optimization_tab()
111
+
112
+ # Navigation buttons
113
+ col1, col2 = st.columns(2)
114
+
115
+ with col1:
116
+ if st.button("Back to Embodied Energy", key="back_to_embodied_energy"):
117
+ st.session_state.current_page = "Embodied Energy"
118
+ st.rerun()
119
+
120
+ with col2:
121
+ st.info("This is the final module in the HVAC Load Calculator.")
122
+
123
+ def initialize_materials_cost_data():
124
+ """Initialize materials cost data in session state if not present."""
125
+ if "materials_cost" not in st.session_state.project_data:
126
+ st.session_state.project_data["materials_cost"] = {
127
+ "material_costs": DEFAULT_MATERIAL_COSTS.copy(),
128
+ "labor_costs": DEFAULT_LABOR_COSTS.copy(),
129
+ "economic_parameters": {
130
+ "discount_rate": DISCOUNT_RATE,
131
+ "inflation_rate": INFLATION_RATE,
132
+ "maintenance_rate": 0.02, # 2% of initial cost annually
133
+ "energy_cost_escalation": 0.03 # 3% annual energy cost increase
134
+ },
135
+ "results": None
136
+ }
137
+
138
+ def display_cost_parameters_tab():
139
+ """Display the cost parameters configuration tab."""
140
+ st.header("Cost Parameters Configuration")
141
+
142
+ # Get current cost data
143
+ material_costs = st.session_state.project_data["materials_cost"]["material_costs"]
144
+ labor_costs = st.session_state.project_data["materials_cost"]["labor_costs"]
145
+ economic_params = st.session_state.project_data["materials_cost"]["economic_parameters"]
146
+
147
+ # Economic parameters
148
+ st.subheader("Economic Parameters")
149
+
150
+ col1, col2 = st.columns(2)
151
+
152
+ with col1:
153
+ discount_rate = st.number_input(
154
+ "Discount Rate",
155
+ min_value=0.01,
156
+ max_value=0.15,
157
+ value=float(economic_params["discount_rate"]),
158
+ step=0.005,
159
+ format="%.3f",
160
+ help="Annual discount rate for net present value calculations."
161
+ )
162
+
163
+ maintenance_rate = st.number_input(
164
+ "Annual Maintenance Rate",
165
+ min_value=0.005,
166
+ max_value=0.1,
167
+ value=float(economic_params["maintenance_rate"]),
168
+ step=0.005,
169
+ format="%.3f",
170
+ help="Annual maintenance cost as a fraction of initial cost."
171
+ )
172
+
173
+ with col2:
174
+ inflation_rate = st.number_input(
175
+ "Inflation Rate",
176
+ min_value=0.01,
177
+ max_value=0.1,
178
+ value=float(economic_params["inflation_rate"]),
179
+ step=0.005,
180
+ format="%.3f",
181
+ help="Annual inflation rate for cost escalation."
182
+ )
183
+
184
+ energy_cost_escalation = st.number_input(
185
+ "Energy Cost Escalation Rate",
186
+ min_value=0.01,
187
+ max_value=0.1,
188
+ value=float(economic_params["energy_cost_escalation"]),
189
+ step=0.005,
190
+ format="%.3f",
191
+ help="Annual energy cost escalation rate."
192
+ )
193
+
194
+ # Update economic parameters
195
+ economic_params.update({
196
+ "discount_rate": discount_rate,
197
+ "inflation_rate": inflation_rate,
198
+ "maintenance_rate": maintenance_rate,
199
+ "energy_cost_escalation": energy_cost_escalation
200
+ })
201
+
202
+ # Material costs
203
+ st.subheader("Material Costs")
204
+
205
+ # Create a DataFrame for display and editing
206
+ cost_df = pd.DataFrame({
207
+ "Material": list(material_costs.keys()),
208
+ "Material Cost ($/kg)": list(material_costs.values()),
209
+ "Labor Cost ($/kg)": [labor_costs.get(material, 0) for material in material_costs.keys()]
210
+ })
211
+
212
+ # Display the costs table
213
+ edited_cost_df = st.data_editor(
214
+ cost_df,
215
+ column_config={
216
+ "Material": st.column_config.TextColumn("Material"),
217
+ "Material Cost ($/kg)": st.column_config.NumberColumn(
218
+ "Material Cost ($/kg)",
219
+ min_value=0.0,
220
+ max_value=100.0,
221
+ step=0.01,
222
+ format="%.2f"
223
+ ),
224
+ "Labor Cost ($/kg)": st.column_config.NumberColumn(
225
+ "Labor Cost ($/kg)",
226
+ min_value=0.0,
227
+ max_value=50.0,
228
+ step=0.01,
229
+ format="%.2f"
230
+ )
231
+ },
232
+ use_container_width=True,
233
+ key="material_costs_editor"
234
+ )
235
+
236
+ # Update material and labor costs if edited
237
+ if not cost_df.equals(edited_cost_df):
238
+ updated_material_costs = dict(zip(edited_cost_df["Material"], edited_cost_df["Material Cost ($/kg)"]))
239
+ updated_labor_costs = dict(zip(edited_cost_df["Material"], edited_cost_df["Labor Cost ($/kg)"]))
240
+
241
+ st.session_state.project_data["materials_cost"]["material_costs"] = updated_material_costs
242
+ st.session_state.project_data["materials_cost"]["labor_costs"] = updated_labor_costs
243
+
244
+ material_costs = updated_material_costs
245
+ labor_costs = updated_labor_costs
246
+
247
+ # Calculate costs button
248
+ if st.button("Calculate Material Costs", key="calculate_material_costs"):
249
+ try:
250
+ results = calculate_material_costs()
251
+ st.session_state.project_data["materials_cost"]["results"] = results
252
+ st.success("Material costs calculated successfully.")
253
+ logger.info("Material costs calculated.")
254
+ st.rerun() # Refresh to show results
255
+ except Exception as e:
256
+ st.error(f"Error calculating material costs: {e}")
257
+ logger.error(f"Error calculating material costs: {e}", exc_info=True)
258
+ st.session_state.project_data["materials_cost"]["results"] = None
259
+
260
+ def display_cost_analysis_tab():
261
+ """Display the cost analysis tab."""
262
+ st.header("Cost Analysis")
263
+
264
+ # Check if results are available
265
+ results = st.session_state.project_data["materials_cost"].get("results")
266
+ if not results:
267
+ st.info("Please calculate material costs using the Cost Parameters tab.")
268
+ return
269
+
270
+ # Display cost summary
271
+ st.subheader("Cost Summary")
272
+
273
+ # Get building info for normalization
274
+ building_info = st.session_state.project_data["building_info"]
275
+ floor_area = building_info.get("floor_area", 0)
276
+
277
+ col1, col2 = st.columns(2)
278
+
279
+ with col1:
280
+ st.metric(
281
+ "Total Initial Material Cost",
282
+ f"${results['total_initial_material_cost']:,.0f}",
283
+ help="Total cost of materials for initial construction."
284
+ )
285
+
286
+ with col2:
287
+ st.metric(
288
+ "Total Initial Labor Cost",
289
+ f"${results['total_initial_labor_cost']:,.0f}",
290
+ help="Total labor cost for initial construction."
291
+ )
292
+
293
+ col1, col2 = st.columns(2)
294
+
295
+ with col1:
296
+ st.metric(
297
+ "Total Initial Construction Cost",
298
+ f"${results['total_initial_construction_cost']:,.0f}",
299
+ help="Total initial construction cost (materials + labor)."
300
+ )
301
+
302
+ with col2:
303
+ st.metric(
304
+ "Construction Cost per Area",
305
+ f"${results['construction_cost_per_area']:,.0f}/m²",
306
+ help="Initial construction cost per square meter of floor area."
307
+ )
308
+
309
+ # Display cost breakdown by category
310
+ st.subheader("Cost Breakdown by Category")
311
+
312
+ # Create pie chart of initial costs by category
313
+ fig_material_category = px.pie(
314
+ values=list(results["initial_material_cost_by_category"].values()),
315
+ names=list(results["initial_material_cost_by_category"].keys()),
316
+ title="Initial Material Cost by Category"
317
+ )
318
+ st.plotly_chart(fig_material_category, use_container_width=True)
319
+
320
+ # Create pie chart of initial labor costs by category
321
+ fig_labor_category = px.pie(
322
+ values=list(results["initial_labor_cost_by_category"].values()),
323
+ names=list(results["initial_labor_cost_by_category"].keys()),
324
+ title="Initial Labor Cost by Category"
325
+ )
326
+ st.plotly_chart(fig_labor_category, use_container_width=True)
327
+
328
+ # Display cost breakdown by material
329
+ st.subheader("Top 10 Materials by Cost")
330
+
331
+ # Create bar chart of top 10 materials by total initial cost
332
+ material_total_costs = {}
333
+ for material, material_cost in results["initial_material_cost_by_material"].items():
334
+ labor_cost = results["initial_labor_cost_by_material"].get(material, 0)
335
+ material_total_costs[material] = material_cost + labor_cost
336
+
337
+ top_materials_df = pd.DataFrame({
338
+ "Material": list(material_total_costs.keys()),
339
+ "Total Initial Cost ($)": list(material_total_costs.values())
340
+ })
341
+
342
+ top_materials_df = top_materials_df.sort_values(
343
+ by="Total Initial Cost ($)",
344
+ ascending=False
345
+ ).head(10)
346
+
347
+ fig_top_materials = px.bar(
348
+ top_materials_df,
349
+ x="Material",
350
+ y="Total Initial Cost ($)",
351
+ title="Top 10 Materials by Total Initial Cost"
352
+ )
353
+ st.plotly_chart(fig_top_materials, use_container_width=True)
354
+
355
+ def display_lifecycle_costs_tab():
356
+ """Display the lifecycle costs analysis tab."""
357
+ st.header("Lifecycle Cost Analysis")
358
+
359
+ # Check if results are available
360
+ results = st.session_state.project_data["materials_cost"].get("results")
361
+ if not results:
362
+ st.info("Please calculate material costs using the Cost Parameters tab.")
363
+ return
364
+
365
+ # Display lifecycle cost summary
366
+ st.subheader("Lifecycle Cost Summary")
367
+
368
+ col1, col2 = st.columns(2)
369
+
370
+ with col1:
371
+ st.metric(
372
+ f"Total Lifecycle Material Cost ({YEARS_FOR_ANALYSIS} years)",
373
+ f"${results['total_lifecycle_material_cost']:,.0f}",
374
+ help=f"Total material cost over {YEARS_FOR_ANALYSIS} years including replacements."
375
+ )
376
+
377
+ with col2:
378
+ st.metric(
379
+ f"Total Lifecycle Construction Cost ({YEARS_FOR_ANALYSIS} years)",
380
+ f"${results['total_lifecycle_construction_cost']:,.0f}",
381
+ help=f"Total construction cost over {YEARS_FOR_ANALYSIS} years including materials, labor, and maintenance."
382
+ )
383
+
384
+ # Display net present value
385
+ col1, col2 = st.columns(2)
386
+
387
+ with col1:
388
+ st.metric(
389
+ "Net Present Value (NPV)",
390
+ f"${results['net_present_value']:,.0f}",
391
+ help="Net present value of all lifecycle costs."
392
+ )
393
+
394
+ with col2:
395
+ st.metric(
396
+ "Annualized Cost",
397
+ f"${results['annualized_cost']:,.0f}/year",
398
+ help=f"Average annual cost over {YEARS_FOR_ANALYSIS} years."
399
+ )
400
+
401
+ # Display lifecycle cost breakdown
402
+ st.subheader("Lifecycle Cost Breakdown")
403
+
404
+ # Create pie chart of lifecycle costs
405
+ lifecycle_components = {
406
+ "Initial Materials": results["total_initial_material_cost"],
407
+ "Initial Labor": results["total_initial_labor_cost"],
408
+ "Replacement Materials": results["total_lifecycle_material_cost"] - results["total_initial_material_cost"],
409
+ "Replacement Labor": results["total_lifecycle_labor_cost"] - results["total_initial_labor_cost"],
410
+ "Maintenance": results["total_maintenance_cost"]
411
+ }
412
+
413
+ fig_lifecycle = px.pie(
414
+ values=list(lifecycle_components.values()),
415
+ names=list(lifecycle_components.keys()),
416
+ title=f"Lifecycle Cost Breakdown ({YEARS_FOR_ANALYSIS} years)"
417
+ )
418
+ st.plotly_chart(fig_lifecycle, use_container_width=True)
419
+
420
+ # Display cost over time
421
+ st.subheader("Cost Accumulation Over Time")
422
+
423
+ # Create line chart of cumulative costs over time
424
+ years = list(range(0, YEARS_FOR_ANALYSIS + 1, 5))
425
+ cumulative_costs = [results["cumulative_costs"].get(str(year), 0) for year in years]
426
+
427
+ fig_over_time = px.line(
428
+ x=years,
429
+ y=cumulative_costs,
430
+ title="Cumulative Costs Over Time",
431
+ labels={"x": "Year", "y": "Cumulative Cost ($)"}
432
+ )
433
+ st.plotly_chart(fig_over_time, use_container_width=True)
434
+
435
+ # Compare with energy costs if available
436
+ if "building_energy" in st.session_state.project_data and st.session_state.project_data["building_energy"].get("results"):
437
+ st.subheader("Construction vs. Energy Costs")
438
+
439
+ building_energy_results = st.session_state.project_data["building_energy"]["results"]
440
+ annual_energy_cost = building_energy_results["annual_energy_cost"]
441
+
442
+ # Calculate total energy cost over lifecycle
443
+ economic_params = st.session_state.project_data["materials_cost"]["economic_parameters"]
444
+ energy_escalation = economic_params["energy_cost_escalation"]
445
+ discount_rate = economic_params["discount_rate"]
446
+
447
+ total_energy_cost = 0
448
+ for year in range(1, YEARS_FOR_ANALYSIS + 1):
449
+ escalated_cost = annual_energy_cost * ((1 + energy_escalation) ** year)
450
+ discounted_cost = escalated_cost / ((1 + discount_rate) ** year)
451
+ total_energy_cost += discounted_cost
452
+
453
+ # Create comparison chart
454
+ cost_comparison = {
455
+ "Construction (Lifecycle)": results["net_present_value"],
456
+ "Energy (Lifecycle)": total_energy_cost
457
+ }
458
+
459
+ fig_comparison = px.bar(
460
+ x=list(cost_comparison.keys()),
461
+ y=list(cost_comparison.values()),
462
+ title=f"Lifecycle Cost Comparison ({YEARS_FOR_ANALYSIS} years, NPV)",
463
+ labels={"x": "Cost Category", "y": "Net Present Value ($)"}
464
+ )
465
+ st.plotly_chart(fig_comparison, use_container_width=True)
466
+
467
+ # Display comparison metrics
468
+ col1, col2 = st.columns(2)
469
+
470
+ with col1:
471
+ st.metric(
472
+ "Construction NPV",
473
+ f"${results['net_present_value']:,.0f}",
474
+ help="Net present value of construction costs."
475
+ )
476
+
477
+ with col2:
478
+ st.metric(
479
+ "Energy NPV",
480
+ f"${total_energy_cost:,.0f}",
481
+ help="Net present value of energy costs."
482
+ )
483
+
484
+ def display_cost_optimization_tab():
485
+ """Display the cost optimization recommendations tab."""
486
+ st.header("Cost Optimization")
487
+
488
+ # Check if results are available
489
+ results = st.session_state.project_data["materials_cost"].get("results")
490
+ if not results:
491
+ st.info("Please calculate material costs using the Cost Parameters tab.")
492
+ return
493
+
494
+ # Display cost optimization opportunities
495
+ st.subheader("Cost Optimization Opportunities")
496
+
497
+ # Identify high-cost materials
498
+ material_total_costs = {}
499
+ for material, material_cost in results["initial_material_cost_by_material"].items():
500
+ labor_cost = results["initial_labor_cost_by_material"].get(material, 0)
501
+ material_total_costs[material] = material_cost + labor_cost
502
+
503
+ # Sort materials by cost
504
+ sorted_materials = sorted(material_total_costs.items(), key=lambda x: x[1], reverse=True)
505
+
506
+ # Create optimization recommendations
507
+ optimization_data = []
508
+
509
+ for i, (material, cost) in enumerate(sorted_materials[:10]): # Top 10 materials
510
+ # Calculate potential savings
511
+ potential_savings_low = cost * 0.1 # 10% savings
512
+ potential_savings_high = cost * 0.3 # 30% savings
513
+
514
+ # Determine optimization strategy
515
+ if "Concrete" in material:
516
+ strategy = "Use high-performance concrete, optimize mix design"
517
+ difficulty = "Medium"
518
+ elif "Steel" in material:
519
+ strategy = "Optimize structural design, use high-strength steel"
520
+ difficulty = "High"
521
+ elif "Timber" in material:
522
+ strategy = "Use engineered wood products, optimize sizing"
523
+ difficulty = "Medium"
524
+ elif "Glass" in material:
525
+ strategy = "Optimize window sizing, use high-performance glazing"
526
+ difficulty = "Medium"
527
+ elif "Aluminum" in material:
528
+ strategy = "Optimize frame design, consider alternative materials"
529
+ difficulty = "Medium"
530
+ elif "Insulation" in material:
531
+ strategy = "Optimize insulation thickness, use cost-effective materials"
532
+ difficulty = "Low"
533
+ elif "equipment" in material:
534
+ strategy = "Right-size equipment, consider high-efficiency options"
535
+ difficulty = "Medium"
536
+ else:
537
+ strategy = "Value engineering, alternative materials"
538
+ difficulty = "Medium"
539
+
540
+ optimization_data.append({
541
+ "Rank": i + 1,
542
+ "Material": material,
543
+ "Current Cost": f"${cost:,.0f}",
544
+ "Potential Savings (10-30%)": f"${potential_savings_low:,.0f} - ${potential_savings_high:,.0f}",
545
+ "Optimization Strategy": strategy,
546
+ "Implementation Difficulty": difficulty
547
+ })
548
+
549
+ # Display optimization table
550
+ optimization_df = pd.DataFrame(optimization_data)
551
+ st.table(optimization_df)
552
+
553
+ # Display cost reduction scenarios
554
+ st.subheader("Cost Reduction Scenarios")
555
+
556
+ # Calculate different scenarios
557
+ current_cost = results["total_initial_construction_cost"]
558
+
559
+ scenarios = {
560
+ "Current Design": current_cost,
561
+ "5% Cost Reduction": current_cost * 0.95,
562
+ "10% Cost Reduction": current_cost * 0.90,
563
+ "15% Cost Reduction": current_cost * 0.85,
564
+ "20% Cost Reduction": current_cost * 0.80
565
+ }
566
+
567
+ # Create scenario comparison chart
568
+ fig_scenarios = px.bar(
569
+ x=list(scenarios.keys()),
570
+ y=list(scenarios.values()),
571
+ title="Cost Reduction Scenarios",
572
+ labels={"x": "Scenario", "y": "Initial Construction Cost ($)"}
573
+ )
574
+ st.plotly_chart(fig_scenarios, use_container_width=True)
575
+
576
+ # Display payback analysis if renewable energy is available
577
+ if "renewable_energy" in st.session_state.project_data and st.session_state.project_data["renewable_energy"].get("results"):
578
+ st.subheader("Economic Payback Analysis")
579
+
580
+ # Get renewable energy results
581
+ renewable_results = st.session_state.project_data["renewable_energy"]["results"]
582
+ building_energy_results = st.session_state.project_data["building_energy"]["results"]
583
+
584
+ # Calculate PV system cost
585
+ pv_system = st.session_state.project_data["renewable_energy"]["pv_system"]
586
+ pv_capacity = pv_system["system_capacity_kw"]
587
+ pv_cost_per_kw = pv_system["cost_per_kw"]
588
+ pv_system_cost = pv_capacity * pv_cost_per_kw
589
+
590
+ # Calculate annual energy savings
591
+ annual_pv_generation = renewable_results["annual_pv_generation"] # kWh
592
+ electricity_rate = building_energy_results["energy_rates"]["electricity"]["rate"] # $/kWh
593
+ annual_energy_savings = annual_pv_generation * electricity_rate
594
+
595
+ # Calculate simple payback period
596
+ if annual_energy_savings > 0:
597
+ simple_payback = pv_system_cost / annual_energy_savings
598
+
599
+ st.metric(
600
+ "PV System Simple Payback Period",
601
+ f"{simple_payback:.1f} years",
602
+ help="Years required for energy savings to offset PV system cost."
603
+ )
604
+
605
+ # Create payback chart
606
+ years = list(range(0, min(int(simple_payback * 2), 30) + 1))
607
+ pv_cost = [pv_system_cost] * len(years)
608
+ energy_savings = [year * annual_energy_savings for year in years]
609
+
610
+ fig_payback = go.Figure()
611
+
612
+ fig_payback.add_trace(go.Scatter(
613
+ x=years,
614
+ y=pv_cost,
615
+ mode="lines",
616
+ name="PV System Cost"
617
+ ))
618
+
619
+ fig_payback.add_trace(go.Scatter(
620
+ x=years,
621
+ y=energy_savings,
622
+ mode="lines",
623
+ name="Cumulative Energy Savings"
624
+ ))
625
+
626
+ fig_payback.update_layout(
627
+ title="Economic Payback Analysis",
628
+ xaxis_title="Year",
629
+ yaxis_title="Cost/Savings ($)"
630
+ )
631
+
632
+ st.plotly_chart(fig_payback, use_container_width=True)
633
+ else:
634
+ st.warning("No energy savings calculated. Cannot determine payback period.")
635
+
636
+ # Display general cost optimization strategies
637
+ st.subheader("General Cost Optimization Strategies")
638
+
639
+ strategies_data = {
640
+ "Strategy": [
641
+ "Value Engineering",
642
+ "Design Optimization",
643
+ "Material Substitution",
644
+ "Bulk Purchasing",
645
+ "Construction Sequencing",
646
+ "Energy Efficiency Measures",
647
+ "Lifecycle Cost Analysis",
648
+ "Standardization"
649
+ ],
650
+ "Potential Savings": [
651
+ "10-20%",
652
+ "5-15%",
653
+ "5-25%",
654
+ "2-8%",
655
+ "3-10%",
656
+ "Long-term savings",
657
+ "Optimize total cost",
658
+ "5-15%"
659
+ ],
660
+ "Implementation Phase": [
661
+ "Design",
662
+ "Design",
663
+ "Design/Procurement",
664
+ "Procurement",
665
+ "Construction",
666
+ "Design",
667
+ "Design",
668
+ "Design"
669
+ ]
670
+ }
671
+
672
+ strategies_df = pd.DataFrame(strategies_data)
673
+ st.table(strategies_df)
674
+
675
+ def calculate_material_costs() -> Dict[str, Any]:
676
+ """
677
+ Calculate material costs based on material inventory and cost parameters.
678
+
679
+ Returns:
680
+ Dictionary containing material cost results.
681
+ """
682
+ logger.info("Starting material cost calculations...")
683
+
684
+ # Get required data
685
+ material_inventory = st.session_state.project_data["embodied_energy"]["material_inventory"]
686
+ material_costs = st.session_state.project_data["materials_cost"]["material_costs"]
687
+ labor_costs = st.session_state.project_data["materials_cost"]["labor_costs"]
688
+ economic_params = st.session_state.project_data["materials_cost"]["economic_parameters"]
689
+
690
+ # Get economic parameters
691
+ discount_rate = economic_params["discount_rate"]
692
+ inflation_rate = economic_params["inflation_rate"]
693
+ maintenance_rate = economic_params["maintenance_rate"]
694
+
695
+ # Initialize results
696
+ initial_material_cost_by_material = {}
697
+ initial_labor_cost_by_material = {}
698
+ initial_material_cost_by_category = {}
699
+ initial_labor_cost_by_category = {}
700
+ lifecycle_material_cost_by_category = {}
701
+ lifecycle_labor_cost_by_category = {}
702
+ cumulative_costs = {"0": 0}
703
+
704
+ # Calculate costs for each material
705
+ for item in material_inventory:
706
+ material_name = item["material_name"]
707
+ category = item["material_category"]
708
+ quantity = item["quantity"] # kg
709
+ replacement_cycle = item["replacement_cycle"] # years
710
+
711
+ # Get cost factors
712
+ material_cost_per_kg = material_costs.get(material_name, 0) # $/kg
713
+ labor_cost_per_kg = labor_costs.get(material_name, 0) # $/kg
714
+
715
+ # Calculate initial costs
716
+ initial_material_cost = quantity * material_cost_per_kg
717
+ initial_labor_cost = quantity * labor_cost_per_kg
718
+
719
+ # Add to material totals
720
+ if material_name in initial_material_cost_by_material:
721
+ initial_material_cost_by_material[material_name] += initial_material_cost
722
+ initial_labor_cost_by_material[material_name] += initial_labor_cost
723
+ else:
724
+ initial_material_cost_by_material[material_name] = initial_material_cost
725
+ initial_labor_cost_by_material[material_name] = initial_labor_cost
726
+
727
+ # Add to category totals
728
+ if category in initial_material_cost_by_category:
729
+ initial_material_cost_by_category[category] += initial_material_cost
730
+ initial_labor_cost_by_category[category] += initial_labor_cost
731
+ else:
732
+ initial_material_cost_by_category[category] = initial_material_cost
733
+ initial_labor_cost_by_category[category] = initial_labor_cost
734
+
735
+ # Calculate lifecycle costs with replacements and inflation
736
+ num_replacements = YEARS_FOR_ANALYSIS // replacement_cycle
737
+
738
+ # Initial cost
739
+ lifecycle_material_cost = initial_material_cost
740
+ lifecycle_labor_cost = initial_labor_cost
741
+
742
+ # Replacement costs
743
+ for replacement in range(1, num_replacements + 1):
744
+ replacement_year = replacement * replacement_cycle
745
+
746
+ # Apply inflation to replacement costs
747
+ inflated_material_cost = initial_material_cost * ((1 + inflation_rate) ** replacement_year)
748
+ inflated_labor_cost = initial_labor_cost * ((1 + inflation_rate) ** replacement_year)
749
+
750
+ # Discount to present value
751
+ discounted_material_cost = inflated_material_cost / ((1 + discount_rate) ** replacement_year)
752
+ discounted_labor_cost = inflated_labor_cost / ((1 + discount_rate) ** replacement_year)
753
+
754
+ lifecycle_material_cost += discounted_material_cost
755
+ lifecycle_labor_cost += discounted_labor_cost
756
+
757
+ # Add to lifecycle category totals
758
+ if category in lifecycle_material_cost_by_category:
759
+ lifecycle_material_cost_by_category[category] += lifecycle_material_cost
760
+ lifecycle_labor_cost_by_category[category] += lifecycle_labor_cost
761
+ else:
762
+ lifecycle_material_cost_by_category[category] = lifecycle_material_cost
763
+ lifecycle_labor_cost_by_category[category] = lifecycle_labor_cost
764
+
765
+ # Add to cumulative costs over time
766
+ cumulative_costs["0"] += initial_material_cost + initial_labor_cost
767
+
768
+ for replacement in range(1, num_replacements + 1):
769
+ replacement_year = replacement * replacement_cycle
770
+ year_str = str(replacement_year)
771
+
772
+ if year_str not in cumulative_costs:
773
+ # Find the previous year's cumulative cost
774
+ prev_year = replacement_year - replacement_cycle
775
+ cumulative_costs[year_str] = cumulative_costs[str(prev_year)]
776
+
777
+ # Add replacement cost (not discounted for cumulative display)
778
+ inflated_material_cost = initial_material_cost * ((1 + inflation_rate) ** replacement_year)
779
+ inflated_labor_cost = initial_labor_cost * ((1 + inflation_rate) ** replacement_year)
780
+
781
+ cumulative_costs[year_str] += inflated_material_cost + inflated_labor_cost
782
+
783
+ # Calculate totals
784
+ total_initial_material_cost = sum(initial_material_cost_by_material.values())
785
+ total_initial_labor_cost = sum(initial_labor_cost_by_material.values())
786
+ total_initial_construction_cost = total_initial_material_cost + total_initial_labor_cost
787
+
788
+ total_lifecycle_material_cost = sum(lifecycle_material_cost_by_category.values())
789
+ total_lifecycle_labor_cost = sum(lifecycle_labor_cost_by_category.values())
790
+
791
+ # Calculate maintenance costs
792
+ total_maintenance_cost = 0
793
+ for year in range(1, YEARS_FOR_ANALYSIS + 1):
794
+ annual_maintenance = total_initial_construction_cost * maintenance_rate
795
+ inflated_maintenance = annual_maintenance * ((1 + inflation_rate) ** year)
796
+ discounted_maintenance = inflated_maintenance / ((1 + discount_rate) ** year)
797
+ total_maintenance_cost += discounted_maintenance
798
+
799
+ total_lifecycle_construction_cost = total_lifecycle_material_cost + total_lifecycle_labor_cost + total_maintenance_cost
800
+
801
+ # Calculate net present value
802
+ net_present_value = total_lifecycle_construction_cost
803
+
804
+ # Calculate annualized cost
805
+ # Using capital recovery factor
806
+ capital_recovery_factor = (discount_rate * ((1 + discount_rate) ** YEARS_FOR_ANALYSIS)) / (((1 + discount_rate) ** YEARS_FOR_ANALYSIS) - 1)
807
+ annualized_cost = net_present_value * capital_recovery_factor
808
+
809
+ # Get building info for normalization
810
+ building_info = st.session_state.project_data["building_info"]
811
+ floor_area = building_info.get("floor_area", 0)
812
+
813
+ # Calculate normalized metrics
814
+ construction_cost_per_area = total_initial_construction_cost / floor_area if floor_area > 0 else 0
815
+
816
+ # Compile results
817
+ results = {
818
+ "initial_material_cost_by_material": initial_material_cost_by_material,
819
+ "initial_labor_cost_by_material": initial_labor_cost_by_material,
820
+ "initial_material_cost_by_category": initial_material_cost_by_category,
821
+ "initial_labor_cost_by_category": initial_labor_cost_by_category,
822
+ "lifecycle_material_cost_by_category": lifecycle_material_cost_by_category,
823
+ "lifecycle_labor_cost_by_category": lifecycle_labor_cost_by_category,
824
+ "cumulative_costs": cumulative_costs,
825
+ "total_initial_material_cost": total_initial_material_cost,
826
+ "total_initial_labor_cost": total_initial_labor_cost,
827
+ "total_initial_construction_cost": total_initial_construction_cost,
828
+ "total_lifecycle_material_cost": total_lifecycle_material_cost,
829
+ "total_lifecycle_labor_cost": total_lifecycle_labor_cost,
830
+ "total_maintenance_cost": total_maintenance_cost,
831
+ "total_lifecycle_construction_cost": total_lifecycle_construction_cost,
832
+ "net_present_value": net_present_value,
833
+ "annualized_cost": annualized_cost,
834
+ "construction_cost_per_area": construction_cost_per_area,
835
+ "calculation_timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
836
+ }
837
+
838
+ logger.info("Material cost calculations completed.")
839
+ return results
840
+
841
+ def display_materials_cost_help():
842
+ """
843
+ Display help information for the materials cost page.
844
+ """
845
+ st.markdown("""
846
+ ### Materials Cost Analysis Help
847
+
848
+ This section calculates the costs associated with building materials, including initial construction costs, lifecycle costs, and economic optimization opportunities.
849
+
850
+ **Key Concepts:**
851
+
852
+ * **Material Costs**: Direct costs of materials ($/kg).
853
+ * **Labor Costs**: Installation labor costs ($/kg).
854
+ * **Lifecycle Costs**: Total costs over the building's lifespan including initial construction, replacements, and maintenance.
855
+ * **Net Present Value (NPV)**: Present value of all future costs, accounting for discount rate.
856
+ * **Discount Rate**: Interest rate used to calculate present value of future costs.
857
+ * **Inflation Rate**: Annual rate of cost increase.
858
+ * **Payback Period**: Time required for savings to offset initial investment.
859
+
860
+ **Workflow:**
861
+
862
+ 1. **Cost Parameters Tab**:
863
+ * Configure economic parameters (discount rate, inflation rate, maintenance rate).
864
+ * Adjust material and labor costs for different materials.
865
+ * Click "Calculate Material Costs" to perform the analysis.
866
+
867
+ 2. **Cost Analysis Tab**:
868
+ * Review initial construction costs and cost breakdown.
869
+ * Analyze costs by material category and individual materials.
870
+ * Identify the most expensive materials.
871
+
872
+ 3. **Lifecycle Costs Tab**:
873
+ * Review total lifecycle costs including replacements and maintenance.
874
+ * Analyze cost accumulation over time.
875
+ * Compare construction costs with energy costs.
876
+
877
+ 4. **Cost Optimization Tab**:
878
+ * Identify cost optimization opportunities.
879
+ * Review cost reduction scenarios.
880
+ * Analyze economic payback of renewable energy systems.
881
+ * Explore general cost optimization strategies.
882
+
883
+ **Important:**
884
+
885
+ * Accurate material quantities from the Embodied Energy module are essential.
886
+ * Cost data should reflect local market conditions.
887
+ * Lifecycle cost analysis helps identify the most cost-effective solutions.
888
+ * Consider both initial costs and long-term operational costs in decision-making.
889
+ """)
app/materials_library.py ADDED
@@ -0,0 +1,1215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ HVAC Load Calculator - Material Library Module
3
+
4
+ This module handles the material library functionality of the HVAC Load Calculator application,
5
+ allowing users to manage building materials and fenestrations (windows, doors, skylights).
6
+ It provides both predefined library materials and the ability to create custom materials.
7
+
8
+ Developed by: Dr Majed Abuseif, Deakin University
9
+ © 2025
10
+ """
11
+
12
+ import streamlit as st
13
+ import pandas as pd
14
+ import numpy as np
15
+ import json
16
+ import logging
17
+ import uuid
18
+ from typing import Dict, List, Any, Optional, Tuple, Union
19
+
20
+ # Configure logging
21
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
22
+ logger = logging.getLogger(__name__)
23
+
24
+ # Define constants
25
+ MATERIAL_CATEGORIES = [
26
+ "Finishing Materials",
27
+ "Structural Materials",
28
+ "Sub-Structural Materials",
29
+ "Insulation",
30
+ "Custom"
31
+ ]
32
+
33
+ FENESTRATION_TYPES = [
34
+ "Window",
35
+ "Door",
36
+ "Skylight",
37
+ "Custom"
38
+ ]
39
+
40
+ # Default library materials
41
+ DEFAULT_MATERIALS = {
42
+ "Brick": {
43
+ "category": "Structural Materials",
44
+ "thermal_conductivity": 0.72,
45
+ "density": 1920.0,
46
+ "specific_heat": 840.0,
47
+ "thickness_range": [0.05, 0.3],
48
+ "default_thickness": 0.1,
49
+ "embodied_carbon": 240.0, # kg CO2e/m³
50
+ "cost": 180.0 # USD/m³
51
+ },
52
+ "Concrete": {
53
+ "category": "Structural Materials",
54
+ "thermal_conductivity": 1.4,
55
+ "density": 2300.0,
56
+ "specific_heat": 880.0,
57
+ "thickness_range": [0.05, 0.5],
58
+ "default_thickness": 0.15,
59
+ "embodied_carbon": 320.0,
60
+ "cost": 120.0
61
+ },
62
+ "Gypsum Board": {
63
+ "category": "Finishing Materials",
64
+ "thermal_conductivity": 0.25,
65
+ "density": 900.0,
66
+ "specific_heat": 1000.0,
67
+ "thickness_range": [0.01, 0.05],
68
+ "default_thickness": 0.0125,
69
+ "embodied_carbon": 120.0,
70
+ "cost": 90.0
71
+ },
72
+ "Mineral Wool": {
73
+ "category": "Insulation",
74
+ "thermal_conductivity": 0.04,
75
+ "density": 30.0,
76
+ "specific_heat": 840.0,
77
+ "thickness_range": [0.025, 0.3],
78
+ "default_thickness": 0.1,
79
+ "embodied_carbon": 45.0,
80
+ "cost": 70.0
81
+ },
82
+ "EPS Insulation": {
83
+ "category": "Insulation",
84
+ "thermal_conductivity": 0.035,
85
+ "density": 25.0,
86
+ "specific_heat": 1400.0,
87
+ "thickness_range": [0.025, 0.3],
88
+ "default_thickness": 0.1,
89
+ "embodied_carbon": 88.0,
90
+ "cost": 65.0
91
+ },
92
+ "Wood (Pine)": {
93
+ "category": "Structural Materials",
94
+ "thermal_conductivity": 0.14,
95
+ "density": 550.0,
96
+ "specific_heat": 1600.0,
97
+ "thickness_range": [0.01, 0.3],
98
+ "default_thickness": 0.05,
99
+ "embodied_carbon": 110.0,
100
+ "cost": 250.0
101
+ },
102
+ "Steel": {
103
+ "category": "Structural Materials",
104
+ "thermal_conductivity": 50.0,
105
+ "density": 7800.0,
106
+ "specific_heat": 450.0,
107
+ "thickness_range": [0.001, 0.05],
108
+ "default_thickness": 0.005,
109
+ "embodied_carbon": 1750.0,
110
+ "cost": 800.0
111
+ },
112
+ "Aluminum": {
113
+ "category": "Structural Materials",
114
+ "thermal_conductivity": 230.0,
115
+ "density": 2700.0,
116
+ "specific_heat": 880.0,
117
+ "thickness_range": [0.001, 0.05],
118
+ "default_thickness": 0.003,
119
+ "embodied_carbon": 8500.0,
120
+ "cost": 1200.0
121
+ },
122
+ "Glass Fiber Insulation": {
123
+ "category": "Insulation",
124
+ "thermal_conductivity": 0.035,
125
+ "density": 12.0,
126
+ "specific_heat": 840.0,
127
+ "thickness_range": [0.025, 0.3],
128
+ "default_thickness": 0.1,
129
+ "embodied_carbon": 28.0,
130
+ "cost": 45.0
131
+ },
132
+ "Ceramic Tile": {
133
+ "category": "Finishing Materials",
134
+ "thermal_conductivity": 1.3,
135
+ "density": 2300.0,
136
+ "specific_heat": 840.0,
137
+ "thickness_range": [0.005, 0.025],
138
+ "default_thickness": 0.01,
139
+ "embodied_carbon": 650.0,
140
+ "cost": 280.0
141
+ },
142
+ "Carpet": {
143
+ "category": "Finishing Materials",
144
+ "thermal_conductivity": 0.06,
145
+ "density": 200.0,
146
+ "specific_heat": 1300.0,
147
+ "thickness_range": [0.005, 0.02],
148
+ "default_thickness": 0.01,
149
+ "embodied_carbon": 40.0,
150
+ "cost": 150.0
151
+ },
152
+ "Plywood": {
153
+ "category": "Sub-Structural Materials",
154
+ "thermal_conductivity": 0.13,
155
+ "density": 560.0,
156
+ "specific_heat": 1400.0,
157
+ "thickness_range": [0.006, 0.025],
158
+ "default_thickness": 0.012,
159
+ "embodied_carbon": 350.0,
160
+ "cost": 180.0
161
+ },
162
+ "Concrete Block": {
163
+ "category": "Structural Materials",
164
+ "thermal_conductivity": 0.51,
165
+ "density": 1400.0,
166
+ "specific_heat": 1000.0,
167
+ "thickness_range": [0.1, 0.3],
168
+ "default_thickness": 0.2,
169
+ "embodied_carbon": 260.0,
170
+ "cost": 140.0
171
+ },
172
+ "Stone (Granite)": {
173
+ "category": "Finishing Materials",
174
+ "thermal_conductivity": 2.8,
175
+ "density": 2600.0,
176
+ "specific_heat": 790.0,
177
+ "thickness_range": [0.01, 0.1],
178
+ "default_thickness": 0.03,
179
+ "embodied_carbon": 170.0,
180
+ "cost": 450.0
181
+ },
182
+ "Polyurethane Insulation": {
183
+ "category": "Insulation",
184
+ "thermal_conductivity": 0.025,
185
+ "density": 30.0,
186
+ "specific_heat": 1400.0,
187
+ "thickness_range": [0.025, 0.2],
188
+ "default_thickness": 0.075,
189
+ "embodied_carbon": 102.0,
190
+ "cost": 95.0
191
+ }
192
+ }
193
+
194
+ # Default library fenestrations
195
+ DEFAULT_FENESTRATIONS = {
196
+ "Single Glazing": {
197
+ "type": "Window",
198
+ "u_value": 5.8,
199
+ "shgc": 0.86,
200
+ "visible_transmittance": 0.9,
201
+ "thickness": 0.006,
202
+ "embodied_carbon": 800.0, # kg CO2e/m²
203
+ "cost": 120.0 # USD/m²
204
+ },
205
+ "Double Glazing (Air)": {
206
+ "type": "Window",
207
+ "u_value": 2.8,
208
+ "shgc": 0.72,
209
+ "visible_transmittance": 0.78,
210
+ "thickness": 0.024,
211
+ "embodied_carbon": 950.0,
212
+ "cost": 180.0
213
+ },
214
+ "Double Glazing (Argon)": {
215
+ "type": "Window",
216
+ "u_value": 1.4,
217
+ "shgc": 0.7,
218
+ "visible_transmittance": 0.76,
219
+ "thickness": 0.024,
220
+ "embodied_carbon": 980.0,
221
+ "cost": 220.0
222
+ },
223
+ "Triple Glazing": {
224
+ "type": "Window",
225
+ "u_value": 0.8,
226
+ "shgc": 0.5,
227
+ "visible_transmittance": 0.68,
228
+ "thickness": 0.036,
229
+ "embodied_carbon": 1100.0,
230
+ "cost": 320.0
231
+ },
232
+ "Low-E Double Glazing": {
233
+ "type": "Window",
234
+ "u_value": 1.8,
235
+ "shgc": 0.4,
236
+ "visible_transmittance": 0.74,
237
+ "thickness": 0.024,
238
+ "embodied_carbon": 1050.0,
239
+ "cost": 250.0
240
+ },
241
+ "Wooden Door": {
242
+ "type": "Door",
243
+ "u_value": 2.2,
244
+ "shgc": 0.0,
245
+ "visible_transmittance": 0.0,
246
+ "thickness": 0.04,
247
+ "embodied_carbon": 400.0,
248
+ "cost": 280.0
249
+ },
250
+ "Steel Door": {
251
+ "type": "Door",
252
+ "u_value": 3.1,
253
+ "shgc": 0.0,
254
+ "visible_transmittance": 0.0,
255
+ "thickness": 0.035,
256
+ "embodied_carbon": 1200.0,
257
+ "cost": 350.0
258
+ },
259
+ "Glass Door": {
260
+ "type": "Door",
261
+ "u_value": 3.8,
262
+ "shgc": 0.6,
263
+ "visible_transmittance": 0.7,
264
+ "thickness": 0.01,
265
+ "embodied_carbon": 900.0,
266
+ "cost": 420.0
267
+ },
268
+ "Skylight (Double Glazed)": {
269
+ "type": "Skylight",
270
+ "u_value": 3.0,
271
+ "shgc": 0.65,
272
+ "visible_transmittance": 0.75,
273
+ "thickness": 0.024,
274
+ "embodied_carbon": 1050.0,
275
+ "cost": 380.0
276
+ },
277
+ "Skylight (Low-E)": {
278
+ "type": "Skylight",
279
+ "u_value": 2.0,
280
+ "shgc": 0.35,
281
+ "visible_transmittance": 0.7,
282
+ "thickness": 0.024,
283
+ "embodied_carbon": 1150.0,
284
+ "cost": 450.0
285
+ }
286
+ }
287
+
288
+ def display_materials_page():
289
+ """
290
+ Display the material library page.
291
+ This is the main function called by main.py when the Material Library page is selected.
292
+ """
293
+ st.title("Material Library")
294
+
295
+ # Display help information in an expandable section
296
+ with st.expander("Help & Information"):
297
+ display_materials_help()
298
+
299
+ # Create tabs for materials and fenestrations
300
+ tab1, tab2 = st.tabs(["Materials", "Fenestrations"])
301
+
302
+ # Materials tab
303
+ with tab1:
304
+ display_materials_tab()
305
+
306
+ # Fenestrations tab
307
+ with tab2:
308
+ display_fenestrations_tab()
309
+
310
+ # Navigation buttons
311
+ col1, col2 = st.columns(2)
312
+
313
+ with col1:
314
+ if st.button("Back to Climate Data", key="back_to_climate"):
315
+ st.session_state.current_page = "Climate Data"
316
+ st.rerun()
317
+
318
+ with col2:
319
+ if st.button("Continue to Construction", key="continue_to_construction"):
320
+ st.session_state.current_page = "Construction"
321
+ st.rerun()
322
+
323
+ def display_materials_tab():
324
+ """Display the materials tab content."""
325
+ # Initialize materials in session state if not present
326
+ initialize_materials()
327
+
328
+ # Create columns for library and project materials
329
+ col1, col2 = st.columns(2)
330
+
331
+ # Library Materials
332
+ with col1:
333
+ st.subheader("Library Materials")
334
+ display_library_materials()
335
+
336
+ # Project Materials
337
+ with col2:
338
+ st.subheader("Project Materials")
339
+ display_project_materials()
340
+
341
+ # Material Editor
342
+ st.markdown("---")
343
+ st.subheader("Material Editor")
344
+ display_material_editor()
345
+
346
+ def display_fenestrations_tab():
347
+ """Display the fenestrations tab content."""
348
+ # Initialize fenestrations in session state if not present
349
+ initialize_fenestrations()
350
+
351
+ # Create columns for library and project fenestrations
352
+ col1, col2 = st.columns(2)
353
+
354
+ # Library Fenestrations
355
+ with col1:
356
+ st.subheader("Library Fenestrations")
357
+ display_library_fenestrations()
358
+
359
+ # Project Fenestrations
360
+ with col2:
361
+ st.subheader("Project Fenestrations")
362
+ display_project_fenestrations()
363
+
364
+ # Fenestration Editor
365
+ st.markdown("---")
366
+ st.subheader("Fenestration Editor")
367
+ display_fenestration_editor()
368
+
369
+ def initialize_materials():
370
+ """Initialize materials in session state if not present."""
371
+ if "materials" not in st.session_state.project_data:
372
+ st.session_state.project_data["materials"] = {
373
+ "library": {},
374
+ "project": {}
375
+ }
376
+
377
+ # Initialize library materials if empty
378
+ if not st.session_state.project_data["materials"]["library"]:
379
+ st.session_state.project_data["materials"]["library"] = DEFAULT_MATERIALS.copy()
380
+
381
+ # Initialize material editor state
382
+ if "material_editor" not in st.session_state:
383
+ st.session_state.material_editor = {
384
+ "name": "",
385
+ "category": MATERIAL_CATEGORIES[0],
386
+ "thermal_conductivity": 0.5,
387
+ "density": 1000.0,
388
+ "specific_heat": 1000.0,
389
+ "thickness_range": [0.01, 0.2],
390
+ "default_thickness": 0.05,
391
+ "embodied_carbon": 100.0,
392
+ "cost": 100.0,
393
+ "edit_mode": False,
394
+ "original_name": ""
395
+ }
396
+
397
+ def initialize_fenestrations():
398
+ """Initialize fenestrations in session state if not present."""
399
+ if "fenestrations" not in st.session_state.project_data:
400
+ st.session_state.project_data["fenestrations"] = {
401
+ "library": {},
402
+ "project": {}
403
+ }
404
+
405
+ # Initialize library fenestrations if empty
406
+ if not st.session_state.project_data["fenestrations"]["library"]:
407
+ st.session_state.project_data["fenestrations"]["library"] = DEFAULT_FENESTRATIONS.copy()
408
+
409
+ # Initialize fenestration editor state
410
+ if "fenestration_editor" not in st.session_state:
411
+ st.session_state.fenestration_editor = {
412
+ "name": "",
413
+ "type": FENESTRATION_TYPES[0],
414
+ "u_value": 2.8,
415
+ "shgc": 0.7,
416
+ "visible_transmittance": 0.8,
417
+ "thickness": 0.024,
418
+ "embodied_carbon": 900.0,
419
+ "cost": 200.0,
420
+ "edit_mode": False,
421
+ "original_name": ""
422
+ }
423
+
424
+ def display_library_materials():
425
+ """Display the library materials section."""
426
+ # Filter options
427
+ category_filter = st.selectbox(
428
+ "Filter by Category",
429
+ ["All"] + MATERIAL_CATEGORIES,
430
+ key="library_material_category_filter"
431
+ )
432
+
433
+ # Get library materials
434
+ library_materials = st.session_state.project_data["materials"]["library"]
435
+
436
+ # Apply filter
437
+ if category_filter != "All":
438
+ filtered_materials = {
439
+ name: props for name, props in library_materials.items()
440
+ if props["category"] == category_filter
441
+ }
442
+ else:
443
+ filtered_materials = library_materials
444
+
445
+ # Display materials in a table
446
+ if filtered_materials:
447
+ # Create a DataFrame for display
448
+ data = []
449
+ for name, props in filtered_materials.items():
450
+ data.append({
451
+ "Name": name,
452
+ "Category": props["category"],
453
+ "Thermal Conductivity (W/m·K)": props["thermal_conductivity"],
454
+ "Density (kg/m³)": props["density"],
455
+ "Specific Heat (J/kg·K)": props["specific_heat"]
456
+ })
457
+
458
+ df = pd.DataFrame(data)
459
+ st.dataframe(df, use_container_width=True, hide_index=True)
460
+
461
+ # Add to project button
462
+ selected_material = st.selectbox(
463
+ "Select Material to Add to Project",
464
+ list(filtered_materials.keys()),
465
+ key="library_material_selector"
466
+ )
467
+
468
+ if st.button("Add to Project", key="add_library_material_to_project"):
469
+ # Check if material already exists in project
470
+ if selected_material in st.session_state.project_data["materials"]["project"]:
471
+ st.warning(f"Material '{selected_material}' already exists in your project.")
472
+ else:
473
+ # Add to project materials
474
+ st.session_state.project_data["materials"]["project"][selected_material] = \
475
+ st.session_state.project_data["materials"]["library"][selected_material].copy()
476
+ st.success(f"Material '{selected_material}' added to your project.")
477
+ logger.info(f"Added library material '{selected_material}' to project")
478
+ else:
479
+ st.info("No materials found in the selected category.")
480
+
481
+ def display_project_materials():
482
+ """Display the project materials section."""
483
+ # Get project materials
484
+ project_materials = st.session_state.project_data["materials"]["project"]
485
+
486
+ if project_materials:
487
+ # Create a DataFrame for display
488
+ data = []
489
+ for name, props in project_materials.items():
490
+ data.append({
491
+ "Name": name,
492
+ "Category": props["category"],
493
+ "Thermal Conductivity (W/m·K)": props["thermal_conductivity"],
494
+ "Density (kg/m³)": props["density"],
495
+ "Specific Heat (J/kg·K)": props["specific_heat"]
496
+ })
497
+
498
+ df = pd.DataFrame(data)
499
+ st.dataframe(df, use_container_width=True, hide_index=True)
500
+
501
+ # Edit and delete options
502
+ col1, col2 = st.columns(2)
503
+
504
+ with col1:
505
+ selected_material = st.selectbox(
506
+ "Select Material to Edit",
507
+ list(project_materials.keys()),
508
+ key="project_material_edit_selector"
509
+ )
510
+
511
+ if st.button("Edit Material", key="edit_project_material"):
512
+ # Load material data into editor
513
+ material_data = project_materials[selected_material]
514
+ st.session_state.material_editor = {
515
+ "name": selected_material,
516
+ "category": material_data["category"],
517
+ "thermal_conductivity": material_data["thermal_conductivity"],
518
+ "density": material_data["density"],
519
+ "specific_heat": material_data["specific_heat"],
520
+ "thickness_range": material_data["thickness_range"],
521
+ "default_thickness": material_data["default_thickness"],
522
+ "embodied_carbon": material_data["embodied_carbon"],
523
+ "cost": material_data["cost"],
524
+ "edit_mode": True,
525
+ "original_name": selected_material
526
+ }
527
+ st.success(f"Material '{selected_material}' loaded for editing.")
528
+
529
+ with col2:
530
+ selected_material_delete = st.selectbox(
531
+ "Select Material to Delete",
532
+ list(project_materials.keys()),
533
+ key="project_material_delete_selector"
534
+ )
535
+
536
+ if st.button("Delete Material", key="delete_project_material"):
537
+ # Check if material is in use
538
+ is_in_use = check_material_in_use(selected_material_delete)
539
+
540
+ if is_in_use:
541
+ st.error(f"Cannot delete material '{selected_material_delete}' because it is in use in constructions.")
542
+ else:
543
+ # Delete material
544
+ del st.session_state.project_data["materials"]["project"][selected_material_delete]
545
+ st.success(f"Material '{selected_material_delete}' deleted from your project.")
546
+ logger.info(f"Deleted material '{selected_material_delete}' from project")
547
+ else:
548
+ st.info("No materials in your project. Add materials from the library or create custom materials.")
549
+
550
+ def display_material_editor():
551
+ """Display the material editor form."""
552
+ with st.form("material_editor_form"):
553
+ # Material name
554
+ name = st.text_input(
555
+ "Material Name",
556
+ value=st.session_state.material_editor["name"],
557
+ help="Enter a unique name for the material."
558
+ )
559
+
560
+ # Create two columns for layout
561
+ col1, col2 = st.columns(2)
562
+
563
+ with col1:
564
+ # Category
565
+ category = st.selectbox(
566
+ "Category",
567
+ MATERIAL_CATEGORIES,
568
+ index=MATERIAL_CATEGORIES.index(st.session_state.material_editor["category"]) if st.session_state.material_editor["category"] in MATERIAL_CATEGORIES else 0,
569
+ help="Select the material category."
570
+ )
571
+
572
+ # Thermal conductivity
573
+ thermal_conductivity = st.number_input(
574
+ "Thermal Conductivity (W/m·K)",
575
+ min_value=0.001,
576
+ max_value=1000.0,
577
+ value=float(st.session_state.material_editor["thermal_conductivity"]),
578
+ format="%.3f",
579
+ help="Thermal conductivity in W/m·K. Lower values indicate better insulation."
580
+ )
581
+
582
+ # Density
583
+ density = st.number_input(
584
+ "Density (kg/m³)",
585
+ min_value=1.0,
586
+ max_value=20000.0,
587
+ value=float(st.session_state.material_editor["density"]),
588
+ format="%.1f",
589
+ help="Material density in kg/m³."
590
+ )
591
+
592
+ with col2:
593
+ # Specific heat
594
+ specific_heat = st.number_input(
595
+ "Specific Heat (J/kg·K)",
596
+ min_value=100.0,
597
+ max_value=10000.0,
598
+ value=float(st.session_state.material_editor["specific_heat"]),
599
+ format="%.1f",
600
+ help="Specific heat capacity in J/kg·K. Higher values indicate better thermal mass."
601
+ )
602
+
603
+ # Thickness range
604
+ min_thickness, max_thickness = st.session_state.material_editor["thickness_range"]
605
+ thickness_range = st.slider(
606
+ "Thickness Range (m)",
607
+ min_value=0.001,
608
+ max_value=0.5,
609
+ value=(float(min_thickness), float(max_thickness)),
610
+ format="%.3f",
611
+ help="Minimum and maximum thickness range for this material in meters."
612
+ )
613
+
614
+ # Default thickness
615
+ default_thickness = st.number_input(
616
+ "Default Thickness (m)",
617
+ min_value=float(thickness_range[0]),
618
+ max_value=float(thickness_range[1]),
619
+ value=min(max(float(st.session_state.material_editor["default_thickness"]), float(thickness_range[0])), float(thickness_range[1])),
620
+ format="%.3f",
621
+ help="Default thickness for this material in meters."
622
+ )
623
+
624
+ # Additional properties for embodied carbon and cost
625
+ st.subheader("Additional Properties")
626
+ col1, col2 = st.columns(2)
627
+
628
+ with col1:
629
+ # Embodied carbon
630
+ embodied_carbon = st.number_input(
631
+ "Embodied Carbon (kg CO₂e/m³)",
632
+ min_value=0.0,
633
+ max_value=10000.0,
634
+ value=float(st.session_state.material_editor["embodied_carbon"]),
635
+ format="%.1f",
636
+ help="Embodied carbon in kg CO₂e per cubic meter."
637
+ )
638
+
639
+ with col2:
640
+ # Cost
641
+ cost = st.number_input(
642
+ "Cost (USD/m³)",
643
+ min_value=0.0,
644
+ max_value=10000.0,
645
+ value=float(st.session_state.material_editor["cost"]),
646
+ format="%.1f",
647
+ help="Material cost in USD per cubic meter."
648
+ )
649
+
650
+ # Form submission buttons
651
+ col1, col2 = st.columns(2)
652
+
653
+ with col1:
654
+ submit_button = st.form_submit_button("Save Material")
655
+
656
+ with col2:
657
+ clear_button = st.form_submit_button("Clear Form")
658
+
659
+ # Handle form submission
660
+ if submit_button:
661
+ # Validate inputs
662
+ validation_errors = validate_material(
663
+ name, category, thermal_conductivity, density, specific_heat,
664
+ thickness_range, default_thickness, embodied_carbon, cost,
665
+ st.session_state.material_editor["edit_mode"], st.session_state.material_editor["original_name"]
666
+ )
667
+
668
+ if validation_errors:
669
+ # Display validation errors
670
+ for error in validation_errors:
671
+ st.error(error)
672
+ else:
673
+ # Create material data
674
+ material_data = {
675
+ "category": category,
676
+ "thermal_conductivity": thermal_conductivity,
677
+ "density": density,
678
+ "specific_heat": specific_heat,
679
+ "thickness_range": list(thickness_range),
680
+ "default_thickness": default_thickness,
681
+ "embodied_carbon": embodied_carbon,
682
+ "cost": cost
683
+ }
684
+
685
+ # Handle edit mode
686
+ if st.session_state.material_editor["edit_mode"]:
687
+ original_name = st.session_state.material_editor["original_name"]
688
+
689
+ # If name changed, delete old entry and create new one
690
+ if original_name != name:
691
+ del st.session_state.project_data["materials"]["project"][original_name]
692
+
693
+ # Update material
694
+ st.session_state.project_data["materials"]["project"][name] = material_data
695
+ st.success(f"Material '{name}' updated successfully.")
696
+ logger.info(f"Updated material '{name}' in project")
697
+ else:
698
+ # Add new material
699
+ st.session_state.project_data["materials"]["project"][name] = material_data
700
+ st.success(f"Material '{name}' added to your project.")
701
+ logger.info(f"Added new material '{name}' to project")
702
+
703
+ # Reset editor
704
+ reset_material_editor()
705
+
706
+ # Handle clear button
707
+ if clear_button:
708
+ reset_material_editor()
709
+ st.rerun()
710
+
711
+ def display_library_fenestrations():
712
+ """Display the library fenestrations section."""
713
+ # Filter options
714
+ type_filter = st.selectbox(
715
+ "Filter by Type",
716
+ ["All"] + FENESTRATION_TYPES,
717
+ key="library_fenestration_type_filter"
718
+ )
719
+
720
+ # Get library fenestrations
721
+ library_fenestrations = st.session_state.project_data["fenestrations"]["library"]
722
+
723
+ # Apply filter
724
+ if type_filter != "All":
725
+ filtered_fenestrations = {
726
+ name: props for name, props in library_fenestrations.items()
727
+ if props["type"] == type_filter
728
+ }
729
+ else:
730
+ filtered_fenestrations = library_fenestrations
731
+
732
+ # Display fenestrations in a table
733
+ if filtered_fenestrations:
734
+ # Create a DataFrame for display
735
+ data = []
736
+ for name, props in filtered_fenestrations.items():
737
+ data.append({
738
+ "Name": name,
739
+ "Type": props["type"],
740
+ "U-Value (W/m²·K)": props["u_value"],
741
+ "SHGC": props["shgc"],
742
+ "Visible Transmittance": props["visible_transmittance"]
743
+ })
744
+
745
+ df = pd.DataFrame(data)
746
+ st.dataframe(df, use_container_width=True, hide_index=True)
747
+
748
+ # Add to project button
749
+ selected_fenestration = st.selectbox(
750
+ "Select Fenestration to Add to Project",
751
+ list(filtered_fenestrations.keys()),
752
+ key="library_fenestration_selector"
753
+ )
754
+
755
+ if st.button("Add to Project", key="add_library_fenestration_to_project"):
756
+ # Check if fenestration already exists in project
757
+ if selected_fenestration in st.session_state.project_data["fenestrations"]["project"]:
758
+ st.warning(f"Fenestration '{selected_fenestration}' already exists in your project.")
759
+ else:
760
+ # Add to project fenestrations
761
+ st.session_state.project_data["fenestrations"]["project"][selected_fenestration] = \
762
+ st.session_state.project_data["fenestrations"]["library"][selected_fenestration].copy()
763
+ st.success(f"Fenestration '{selected_fenestration}' added to your project.")
764
+ logger.info(f"Added library fenestration '{selected_fenestration}' to project")
765
+ else:
766
+ st.info("No fenestrations found in the selected type.")
767
+
768
+ def display_project_fenestrations():
769
+ """Display the project fenestrations section."""
770
+ # Get project fenestrations
771
+ project_fenestrations = st.session_state.project_data["fenestrations"]["project"]
772
+
773
+ if project_fenestrations:
774
+ # Create a DataFrame for display
775
+ data = []
776
+ for name, props in project_fenestrations.items():
777
+ data.append({
778
+ "Name": name,
779
+ "Type": props["type"],
780
+ "U-Value (W/m²·K)": props["u_value"],
781
+ "SHGC": props["shgc"],
782
+ "Visible Transmittance": props["visible_transmittance"]
783
+ })
784
+
785
+ df = pd.DataFrame(data)
786
+ st.dataframe(df, use_container_width=True, hide_index=True)
787
+
788
+ # Edit and delete options
789
+ col1, col2 = st.columns(2)
790
+
791
+ with col1:
792
+ selected_fenestration = st.selectbox(
793
+ "Select Fenestration to Edit",
794
+ list(project_fenestrations.keys()),
795
+ key="project_fenestration_edit_selector"
796
+ )
797
+
798
+ if st.button("Edit Fenestration", key="edit_project_fenestration"):
799
+ # Load fenestration data into editor
800
+ fenestration_data = project_fenestrations[selected_fenestration]
801
+ st.session_state.fenestration_editor = {
802
+ "name": selected_fenestration,
803
+ "type": fenestration_data["type"],
804
+ "u_value": fenestration_data["u_value"],
805
+ "shgc": fenestration_data["shgc"],
806
+ "visible_transmittance": fenestration_data["visible_transmittance"],
807
+ "thickness": fenestration_data["thickness"],
808
+ "embodied_carbon": fenestration_data["embodied_carbon"],
809
+ "cost": fenestration_data["cost"],
810
+ "edit_mode": True,
811
+ "original_name": selected_fenestration
812
+ }
813
+ st.success(f"Fenestration '{selected_fenestration}' loaded for editing.")
814
+
815
+ with col2:
816
+ selected_fenestration_delete = st.selectbox(
817
+ "Select Fenestration to Delete",
818
+ list(project_fenestrations.keys()),
819
+ key="project_fenestration_delete_selector"
820
+ )
821
+
822
+ if st.button("Delete Fenestration", key="delete_project_fenestration"):
823
+ # Check if fenestration is in use
824
+ is_in_use = check_fenestration_in_use(selected_fenestration_delete)
825
+
826
+ if is_in_use:
827
+ st.error(f"Cannot delete fenestration '{selected_fenestration_delete}' because it is in use in components.")
828
+ else:
829
+ # Delete fenestration
830
+ del st.session_state.project_data["fenestrations"]["project"][selected_fenestration_delete]
831
+ st.success(f"Fenestration '{selected_fenestration_delete}' deleted from your project.")
832
+ logger.info(f"Deleted fenestration '{selected_fenestration_delete}' from project")
833
+ else:
834
+ st.info("No fenestrations in your project. Add fenestrations from the library or create custom fenestrations.")
835
+
836
+ def display_fenestration_editor():
837
+ """Display the fenestration editor form."""
838
+ with st.form("fenestration_editor_form"):
839
+ # Fenestration name
840
+ name = st.text_input(
841
+ "Fenestration Name",
842
+ value=st.session_state.fenestration_editor["name"],
843
+ help="Enter a unique name for the fenestration."
844
+ )
845
+
846
+ # Create two columns for layout
847
+ col1, col2 = st.columns(2)
848
+
849
+ with col1:
850
+ # Type
851
+ fenestration_type = st.selectbox(
852
+ "Type",
853
+ FENESTRATION_TYPES,
854
+ index=FENESTRATION_TYPES.index(st.session_state.fenestration_editor["type"]) if st.session_state.fenestration_editor["type"] in FENESTRATION_TYPES else 0,
855
+ help="Select the fenestration type."
856
+ )
857
+
858
+ # U-value
859
+ u_value = st.number_input(
860
+ "U-Value (W/m²·K)",
861
+ min_value=0.1,
862
+ max_value=10.0,
863
+ value=float(st.session_state.fenestration_editor["u_value"]),
864
+ format="%.2f",
865
+ help="U-value in W/m²·K. Lower values indicate better insulation."
866
+ )
867
+
868
+ # SHGC
869
+ shgc = st.number_input(
870
+ "Solar Heat Gain Coefficient (SHGC)",
871
+ min_value=0.0,
872
+ max_value=1.0,
873
+ value=float(st.session_state.fenestration_editor["shgc"]),
874
+ format="%.2f",
875
+ help="Solar Heat Gain Coefficient (0-1). Lower values indicate less solar heat transmission."
876
+ )
877
+
878
+ with col2:
879
+ # Visible transmittance
880
+ visible_transmittance = st.number_input(
881
+ "Visible Transmittance",
882
+ min_value=0.0,
883
+ max_value=1.0,
884
+ value=float(st.session_state.fenestration_editor["visible_transmittance"]),
885
+ format="%.2f",
886
+ help="Visible Transmittance (0-1). Higher values indicate more visible light transmission."
887
+ )
888
+
889
+ # Thickness
890
+ thickness = st.number_input(
891
+ "Thickness (m)",
892
+ min_value=0.001,
893
+ max_value=0.1,
894
+ value=float(st.session_state.fenestration_editor["thickness"]),
895
+ format="%.3f",
896
+ help="Thickness in meters."
897
+ )
898
+
899
+ # Additional properties for embodied carbon and cost
900
+ st.subheader("Additional Properties")
901
+ col1, col2 = st.columns(2)
902
+
903
+ with col1:
904
+ # Embodied carbon
905
+ embodied_carbon = st.number_input(
906
+ "Embodied Carbon (kg CO₂e/m²)",
907
+ min_value=0.0,
908
+ max_value=5000.0,
909
+ value=float(st.session_state.fenestration_editor["embodied_carbon"]),
910
+ format="%.1f",
911
+ help="Embodied carbon in kg CO₂e per square meter."
912
+ )
913
+
914
+ with col2:
915
+ # Cost
916
+ cost = st.number_input(
917
+ "Cost (USD/m²)",
918
+ min_value=0.0,
919
+ max_value=5000.0,
920
+ value=float(st.session_state.fenestration_editor["cost"]),
921
+ format="%.1f",
922
+ help="Fenestration cost in USD per square meter."
923
+ )
924
+
925
+ # Form submission buttons
926
+ col1, col2 = st.columns(2)
927
+
928
+ with col1:
929
+ submit_button = st.form_submit_button("Save Fenestration")
930
+
931
+ with col2:
932
+ clear_button = st.form_submit_button("Clear Form")
933
+
934
+ # Handle form submission
935
+ if submit_button:
936
+ # Validate inputs
937
+ validation_errors = validate_fenestration(
938
+ name, fenestration_type, u_value, shgc, visible_transmittance, thickness,
939
+ embodied_carbon, cost, st.session_state.fenestration_editor["edit_mode"],
940
+ st.session_state.fenestration_editor["original_name"]
941
+ )
942
+
943
+ if validation_errors:
944
+ # Display validation errors
945
+ for error in validation_errors:
946
+ st.error(error)
947
+ else:
948
+ # Create fenestration data
949
+ fenestration_data = {
950
+ "type": fenestration_type,
951
+ "u_value": u_value,
952
+ "shgc": shgc,
953
+ "visible_transmittance": visible_transmittance,
954
+ "thickness": thickness,
955
+ "embodied_carbon": embodied_carbon,
956
+ "cost": cost
957
+ }
958
+
959
+ # Handle edit mode
960
+ if st.session_state.fenestration_editor["edit_mode"]:
961
+ original_name = st.session_state.fenestration_editor["original_name"]
962
+
963
+ # If name changed, delete old entry and create new one
964
+ if original_name != name:
965
+ del st.session_state.project_data["fenestrations"]["project"][original_name]
966
+
967
+ # Update fenestration
968
+ st.session_state.project_data["fenestrations"]["project"][name] = fenestration_data
969
+ st.success(f"Fenestration '{name}' updated successfully.")
970
+ logger.info(f"Updated fenestration '{name}' in project")
971
+ else:
972
+ # Add new fenestration
973
+ st.session_state.project_data["fenestrations"]["project"][name] = fenestration_data
974
+ st.success(f"Fenestration '{name}' added to your project.")
975
+ logger.info(f"Added new fenestration '{name}' to project")
976
+
977
+ # Reset editor
978
+ reset_fenestration_editor()
979
+
980
+ # Handle clear button
981
+ if clear_button:
982
+ reset_fenestration_editor()
983
+ st.rerun()
984
+
985
+ def validate_material(
986
+ name: str, category: str, thermal_conductivity: float, density: float,
987
+ specific_heat: float, thickness_range: Tuple[float, float], default_thickness: float,
988
+ embodied_carbon: float, cost: float, edit_mode: bool, original_name: str
989
+ ) -> List[str]:
990
+ """
991
+ Validate material inputs.
992
+
993
+ Args:
994
+ name: Material name
995
+ category: Material category
996
+ thermal_conductivity: Thermal conductivity in W/m·K
997
+ density: Density in kg/m³
998
+ specific_heat: Specific heat in J/kg·K
999
+ thickness_range: Tuple of (min_thickness, max_thickness) in meters
1000
+ default_thickness: Default thickness in meters
1001
+ embodied_carbon: Embodied carbon in kg CO₂e/m³
1002
+ cost: Cost in USD/m³
1003
+ edit_mode: Whether in edit mode
1004
+ original_name: Original name if in edit mode
1005
+
1006
+ Returns:
1007
+ List of validation error messages, empty if all inputs are valid
1008
+ """
1009
+ errors = []
1010
+
1011
+ # Validate name
1012
+ if not name or name.strip() == "":
1013
+ errors.append("Material name is required.")
1014
+
1015
+ # Check for name uniqueness if not in edit mode or if name changed
1016
+ if not edit_mode or (edit_mode and name != original_name):
1017
+ if name in st.session_state.project_data["materials"]["project"]:
1018
+ errors.append(f"Material name '{name}' already exists in your project.")
1019
+
1020
+ # Validate category
1021
+ if category not in MATERIAL_CATEGORIES:
1022
+ errors.append("Please select a valid material category.")
1023
+
1024
+ # Validate thermal conductivity
1025
+ if thermal_conductivity <= 0:
1026
+ errors.append("Thermal conductivity must be greater than zero.")
1027
+
1028
+ # Validate density
1029
+ if density <= 0:
1030
+ errors.append("Density must be greater than zero.")
1031
+
1032
+ # Validate specific heat
1033
+ if specific_heat <= 0:
1034
+ errors.append("Specific heat must be greater than zero.")
1035
+
1036
+ # Validate thickness range
1037
+ if thickness_range[0] <= 0:
1038
+ errors.append("Minimum thickness must be greater than zero.")
1039
+ if thickness_range[0] >= thickness_range[1]:
1040
+ errors.append("Maximum thickness must be greater than minimum thickness.")
1041
+
1042
+ # Validate default thickness
1043
+ if default_thickness < thickness_range[0] or default_thickness > thickness_range[1]:
1044
+ errors.append("Default thickness must be within the thickness range.")
1045
+
1046
+ # Validate embodied carbon
1047
+ if embodied_carbon < 0:
1048
+ errors.append("Embodied carbon cannot be negative.")
1049
+
1050
+ # Validate cost
1051
+ if cost < 0:
1052
+ errors.append("Cost cannot be negative.")
1053
+
1054
+ return errors
1055
+
1056
+ def validate_fenestration(
1057
+ name: str, fenestration_type: str, u_value: float, shgc: float,
1058
+ visible_transmittance: float, thickness: float, embodied_carbon: float,
1059
+ cost: float, edit_mode: bool, original_name: str
1060
+ ) -> List[str]:
1061
+ """
1062
+ Validate fenestration inputs.
1063
+
1064
+ Args:
1065
+ name: Fenestration name
1066
+ fenestration_type: Fenestration type
1067
+ u_value: U-value in W/m²·K
1068
+ shgc: Solar Heat Gain Coefficient (0-1)
1069
+ visible_transmittance: Visible Transmittance (0-1)
1070
+ thickness: Thickness in meters
1071
+ embodied_carbon: Embodied carbon in kg CO₂e/m²
1072
+ cost: Cost in USD/m²
1073
+ edit_mode: Whether in edit mode
1074
+ original_name: Original name if in edit mode
1075
+
1076
+ Returns:
1077
+ List of validation error messages, empty if all inputs are valid
1078
+ """
1079
+ errors = []
1080
+
1081
+ # Validate name
1082
+ if not name or name.strip() == "":
1083
+ errors.append("Fenestration name is required.")
1084
+
1085
+ # Check for name uniqueness if not in edit mode or if name changed
1086
+ if not edit_mode or (edit_mode and name != original_name):
1087
+ if name in st.session_state.project_data["fenestrations"]["project"]:
1088
+ errors.append(f"Fenestration name '{name}' already exists in your project.")
1089
+
1090
+ # Validate type
1091
+ if fenestration_type not in FENESTRATION_TYPES:
1092
+ errors.append("Please select a valid fenestration type.")
1093
+
1094
+ # Validate u_value
1095
+ if u_value <= 0:
1096
+ errors.append("U-value must be greater than zero.")
1097
+
1098
+ # Validate shgc
1099
+ if shgc < 0 or shgc > 1:
1100
+ errors.append("SHGC must be between 0 and 1.")
1101
+
1102
+ # Validate visible transmittance
1103
+ if visible_transmittance < 0 or visible_transmittance > 1:
1104
+ errors.append("Visible transmittance must be between 0 and 1.")
1105
+
1106
+ # Validate thickness
1107
+ if thickness <= 0:
1108
+ errors.append("Thickness must be greater than zero.")
1109
+
1110
+ # Validate embodied carbon
1111
+ if embodied_carbon < 0:
1112
+ errors.append("Embodied carbon cannot be negative.")
1113
+
1114
+ # Validate cost
1115
+ if cost < 0:
1116
+ errors.append("Cost cannot be negative.")
1117
+
1118
+ return errors
1119
+
1120
+ def reset_material_editor():
1121
+ """Reset the material editor to default values."""
1122
+ st.session_state.material_editor = {
1123
+ "name": "",
1124
+ "category": MATERIAL_CATEGORIES[0],
1125
+ "thermal_conductivity": 0.5,
1126
+ "density": 1000.0,
1127
+ "specific_heat": 1000.0,
1128
+ "thickness_range": [0.01, 0.2],
1129
+ "default_thickness": 0.05,
1130
+ "embodied_carbon": 100.0,
1131
+ "cost": 100.0,
1132
+ "edit_mode": False,
1133
+ "original_name": ""
1134
+ }
1135
+
1136
+ def reset_fenestration_editor():
1137
+ """Reset the fenestration editor to default values."""
1138
+ st.session_state.fenestration_editor = {
1139
+ "name": "",
1140
+ "type": FENESTRATION_TYPES[0],
1141
+ "u_value": 2.8,
1142
+ "shgc": 0.7,
1143
+ "visible_transmittance": 0.8,
1144
+ "thickness": 0.024,
1145
+ "embodied_carbon": 900.0,
1146
+ "cost": 200.0,
1147
+ "edit_mode": False,
1148
+ "original_name": ""
1149
+ }
1150
+
1151
+ def check_material_in_use(material_name: str) -> bool:
1152
+ """
1153
+ Check if a material is in use in any constructions.
1154
+
1155
+ Args:
1156
+ material_name: Name of the material to check
1157
+
1158
+ Returns:
1159
+ True if the material is in use, False otherwise
1160
+ """
1161
+ # This is a placeholder function that will be implemented when constructions are added
1162
+ # For now, we'll assume materials are not in use
1163
+ return False
1164
+
1165
+ def check_fenestration_in_use(fenestration_name: str) -> bool:
1166
+ """
1167
+ Check if a fenestration is in use in any components.
1168
+
1169
+ Args:
1170
+ fenestration_name: Name of the fenestration to check
1171
+
1172
+ Returns:
1173
+ True if the fenestration is in use, False otherwise
1174
+ """
1175
+ # This is a placeholder function that will be implemented when components are added
1176
+ # For now, we'll assume fenestrations are not in use
1177
+ return False
1178
+
1179
+ def display_materials_help():
1180
+ """Display help information for the material library page."""
1181
+ st.markdown("""
1182
+ ### Material Library Help
1183
+
1184
+ This section allows you to manage building materials and fenestrations for your project.
1185
+
1186
+ **Materials Tab:**
1187
+
1188
+ * **Library Materials**: Pre-defined materials with standard thermal properties.
1189
+ * **Project Materials**: Materials you've added to your project from the library or created custom.
1190
+ * **Material Editor**: Create new materials or edit existing ones in your project.
1191
+
1192
+ **Fenestrations Tab:**
1193
+
1194
+ * **Library Fenestrations**: Pre-defined windows, doors, and skylights with standard thermal properties.
1195
+ * **Project Fenestrations**: Fenestrations you've added to your project from the library or created custom.
1196
+ * **Fenestration Editor**: Create new fenestrations or edit existing ones in your project.
1197
+
1198
+ **Key Properties:**
1199
+
1200
+ * **Thermal Conductivity (W/m·K)**: Rate of heat transfer through a material. Lower values indicate better insulation.
1201
+ * **Density (kg/m³)**: Mass per unit volume.
1202
+ * **Specific Heat (J/kg·K)**: Energy required to raise the temperature of 1 kg by 1 K. Higher values indicate better thermal mass.
1203
+ * **U-Value (W/m²·K)**: Overall heat transfer coefficient for fenestrations. Lower values indicate better insulation.
1204
+ * **SHGC**: Solar Heat Gain Coefficient (0-1). Fraction of incident solar radiation that enters through a fenestration.
1205
+ * **Visible Transmittance**: Fraction of visible light that passes through a fenestration.
1206
+ * **Embodied Carbon**: Carbon emissions associated with material production, measured in kg CO₂e per unit volume or area.
1207
+ * **Cost**: Material cost in USD per unit volume or area.
1208
+
1209
+ **Workflow:**
1210
+
1211
+ 1. Browse the library materials and fenestrations
1212
+ 2. Add items to your project or create custom ones
1213
+ 3. Edit properties as needed for your specific project
1214
+ 4. Continue to the Construction page to create assemblies using these materials
1215
+ """)
app/renewable_energy.py ADDED
@@ -0,0 +1,626 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ HVAC Load Calculator - Renewable Energy Module
3
+
4
+ This module handles the renewable energy system sizing and simulation, focusing on
5
+ solar PV performance modeling, energy offset calculations, and zero energy building analysis.
6
+
7
+ Developed by: Dr Majed Abuseif, Deakin University
8
+ © 2025
9
+ """
10
+
11
+ import streamlit as st
12
+ import pandas as pd
13
+ import numpy as np
14
+ import json
15
+ import logging
16
+ import plotly.graph_objects as go
17
+ import plotly.express as px
18
+ from typing import Dict, List, Any, Optional, Tuple, Union
19
+ from datetime import datetime
20
+
21
+ # Configure logging
22
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
23
+ logger = logging.getLogger(__name__)
24
+
25
+ # Constants
26
+ MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
27
+ DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] # Non-leap year
28
+ HOURS_IN_YEAR = 8760
29
+
30
+ # Default PV system parameters
31
+ DEFAULT_PV_SYSTEM = {
32
+ "system_capacity_kw": 5.0, # kWp
33
+ "module_efficiency": 0.20, # 20%
34
+ "inverter_efficiency": 0.96, # 96%
35
+ "system_losses": 0.14, # 14% (wiring, soiling, temperature, etc.)
36
+ "tilt_angle": 20.0, # degrees
37
+ "azimuth_angle": 180.0, # degrees (0=N, 180=S)
38
+ "cost_per_kw": 1500, # $/kWp
39
+ "lifespan_years": 25,
40
+ "maintenance_cost_ratio": 0.01 # Annual maintenance as fraction of installation cost
41
+ }
42
+
43
+ def display_renewable_energy_page():
44
+ """
45
+ Display the renewable energy page.
46
+ This is the main function called by main.py when the Renewable Energy page is selected.
47
+ """
48
+ st.title("Renewable Energy Systems")
49
+
50
+ # Display help information in an expandable section
51
+ with st.expander("Help & Information"):
52
+ display_renewable_energy_help()
53
+
54
+ # Check if building energy has been calculated
55
+ if "building_energy" not in st.session_state.project_data or not st.session_state.project_data["building_energy"].get("results"):
56
+ st.warning("Please complete the Building Energy analysis before proceeding to Renewable Energy systems.")
57
+
58
+ # Navigation buttons
59
+ col1, col2 = st.columns(2)
60
+ with col1:
61
+ if st.button("Back to Building Energy", key="back_to_building_energy_re"):
62
+ st.session_state.current_page = "Building Energy"
63
+ st.rerun()
64
+ return
65
+
66
+ # Initialize renewable energy data if not present
67
+ initialize_renewable_energy_data()
68
+
69
+ # Create tabs for different aspects of renewable energy analysis
70
+ tabs = st.tabs(["Solar PV System", "Energy Offset & Net Zero"])
71
+
72
+ with tabs[0]:
73
+ display_solar_pv_system_tab()
74
+
75
+ with tabs[1]:
76
+ display_energy_offset_tab()
77
+
78
+ # Navigation buttons
79
+ col1, col2 = st.columns(2)
80
+
81
+ with col1:
82
+ if st.button("Back to Building Energy", key="back_to_building_energy_re"):
83
+ st.session_state.current_page = "Building Energy"
84
+ st.rerun()
85
+
86
+ with col2:
87
+ if st.button("Continue to Embodied Energy", key="continue_to_embodied_energy"):
88
+ st.session_state.current_page = "Embodied Energy"
89
+ st.rerun()
90
+
91
+ def initialize_renewable_energy_data():
92
+ """Initialize renewable energy data in session state if not present."""
93
+ if "renewable_energy" not in st.session_state.project_data:
94
+ st.session_state.project_data["renewable_energy"] = {
95
+ "pv_system": DEFAULT_PV_SYSTEM.copy(),
96
+ "results": None
97
+ }
98
+
99
+ def display_solar_pv_system_tab():
100
+ """
101
+ Display the Solar PV system configuration and analysis tab.
102
+ """
103
+ st.header("Solar Photovoltaic (PV) System")
104
+
105
+ # Get current PV system data
106
+ pv_system = st.session_state.project_data["renewable_energy"]["pv_system"]
107
+
108
+ # System parameters
109
+ st.subheader("PV System Parameters")
110
+
111
+ col1, col2 = st.columns(2)
112
+
113
+ with col1:
114
+ system_capacity_kw = st.number_input(
115
+ "System Capacity (kWp)",
116
+ min_value=0.1,
117
+ max_value=1000.0,
118
+ value=float(pv_system["system_capacity_kw"]),
119
+ step=0.1,
120
+ format="%.1f",
121
+ help="Peak DC power output of the PV array."
122
+ )
123
+
124
+ module_efficiency = st.number_input(
125
+ "Module Efficiency",
126
+ min_value=0.10,
127
+ max_value=0.30,
128
+ value=float(pv_system["module_efficiency"]),
129
+ step=0.01,
130
+ format="%.2f",
131
+ help="Efficiency of the PV modules (e.g., 0.20 for 20%)."
132
+ )
133
+
134
+ tilt_angle = st.number_input(
135
+ "Tilt Angle (degrees)",
136
+ min_value=0.0,
137
+ max_value=90.0,
138
+ value=float(pv_system["tilt_angle"]),
139
+ step=1.0,
140
+ format="%.1f",
141
+ help="Angle of the PV panels relative to horizontal."
142
+ )
143
+
144
+ with col2:
145
+ inverter_efficiency = st.number_input(
146
+ "Inverter Efficiency",
147
+ min_value=0.80,
148
+ max_value=0.99,
149
+ value=float(pv_system["inverter_efficiency"]),
150
+ step=0.01,
151
+ format="%.2f",
152
+ help="Efficiency of the PV inverter."
153
+ )
154
+
155
+ system_losses = st.number_input(
156
+ "System Losses",
157
+ min_value=0.05,
158
+ max_value=0.30,
159
+ value=float(pv_system["system_losses"]),
160
+ step=0.01,
161
+ format="%.2f",
162
+ help="Overall system losses (wiring, soiling, temperature, etc.)."
163
+ )
164
+
165
+ azimuth_angle = st.number_input(
166
+ "Azimuth Angle (degrees)",
167
+ min_value=0.0,
168
+ max_value=359.9,
169
+ value=float(pv_system["azimuth_angle"]),
170
+ step=1.0,
171
+ format="%.1f",
172
+ help="Orientation of the PV panels (0=North, 90=East, 180=South, 270=West)."
173
+ )
174
+
175
+ # System cost parameters
176
+ st.subheader("PV System Cost Parameters")
177
+
178
+ col1, col2, col3 = st.columns(3)
179
+
180
+ with col1:
181
+ cost_per_kw = st.number_input(
182
+ "Installation Cost ($/kWp)",
183
+ min_value=500.0,
184
+ max_value=5000.0,
185
+ value=float(pv_system["cost_per_kw"]),
186
+ step=50.0,
187
+ format="%.0f",
188
+ help="Installation cost per kWp of PV system capacity."
189
+ )
190
+
191
+ with col2:
192
+ lifespan_years = st.number_input(
193
+ "System Lifespan (years)",
194
+ min_value=10,
195
+ max_value=40,
196
+ value=int(pv_system["lifespan_years"]),
197
+ step=1,
198
+ help="Expected lifespan of the PV system in years."
199
+ )
200
+
201
+ with col3:
202
+ maintenance_cost_ratio = st.number_input(
203
+ "Annual Maintenance Cost Ratio",
204
+ min_value=0.001,
205
+ max_value=0.05,
206
+ value=float(pv_system["maintenance_cost_ratio"]),
207
+ step=0.001,
208
+ format="%.3f",
209
+ help="Annual maintenance cost as a fraction of installation cost."
210
+ )
211
+
212
+ # Update PV system data
213
+ pv_system.update({
214
+ "system_capacity_kw": system_capacity_kw,
215
+ "module_efficiency": module_efficiency,
216
+ "inverter_efficiency": inverter_efficiency,
217
+ "system_losses": system_losses,
218
+ "tilt_angle": tilt_angle,
219
+ "azimuth_angle": azimuth_angle,
220
+ "cost_per_kw": cost_per_kw,
221
+ "lifespan_years": lifespan_years,
222
+ "maintenance_cost_ratio": maintenance_cost_ratio
223
+ })
224
+
225
+ # Calculate PV generation button
226
+ if st.button("Calculate PV Generation", key="calculate_pv_generation"):
227
+ try:
228
+ results = calculate_pv_generation()
229
+ st.session_state.project_data["renewable_energy"]["results"] = results
230
+ st.success("PV generation calculated successfully.")
231
+ logger.info("PV generation calculated.")
232
+ st.rerun() # Refresh to show results
233
+ except Exception as e:
234
+ st.error(f"Error calculating PV generation: {e}")
235
+ logger.error(f"Error calculating PV generation: {e}", exc_info=True)
236
+ st.session_state.project_data["renewable_energy"]["results"] = None
237
+
238
+ # Display PV generation results if available
239
+ results = st.session_state.project_data["renewable_energy"].get("results")
240
+ if results:
241
+ display_pv_generation_results(results)
242
+
243
+ def display_pv_generation_results(results: Dict[str, Any]):
244
+ """Display the PV generation calculation results."""
245
+ st.subheader("PV Generation Summary")
246
+
247
+ col1, col2 = st.columns(2)
248
+
249
+ with col1:
250
+ st.metric(
251
+ "Annual PV Generation",
252
+ f"{results['annual_pv_generation'] / 1000:.1f} MWh",
253
+ help="Total annual electricity generated by the PV system."
254
+ )
255
+
256
+ with col2:
257
+ st.metric(
258
+ "Capacity Factor",
259
+ f"{results['capacity_factor'] * 100:.1f}%",
260
+ help="Ratio of actual energy generated to maximum possible generation."
261
+ )
262
+
263
+ # Display monthly PV generation
264
+ st.subheader("Monthly PV Generation")
265
+
266
+ # Create bar chart of monthly generation
267
+ monthly_data = {
268
+ "Month": MONTHS,
269
+ "PV Generation (kWh)": results["monthly_pv_generation"]
270
+ }
271
+
272
+ monthly_df = pd.DataFrame(monthly_data)
273
+
274
+ fig_monthly = px.bar(
275
+ monthly_df,
276
+ x="Month",
277
+ y="PV Generation (kWh)",
278
+ title="Monthly PV Generation"
279
+ )
280
+ st.plotly_chart(fig_monthly, use_container_width=True)
281
+
282
+ # Display hourly PV generation profile for a typical day
283
+ st.subheader("Hourly PV Generation Profile")
284
+
285
+ # Allow user to select month for hourly profile
286
+ selected_month = st.selectbox(
287
+ "Select Month for Hourly Profile",
288
+ MONTHS,
289
+ index=6, # Default to July
290
+ key="pv_hourly_month_selector",
291
+ help="Select a month to view the typical daily PV generation profile."
292
+ )
293
+
294
+ month_index = MONTHS.index(selected_month)
295
+
296
+ # Get hourly data for the selected month
297
+ month_start_hour = sum(DAYS_IN_MONTH[:month_index]) * 24
298
+ month_hours = DAYS_IN_MONTH[month_index] * 24
299
+
300
+ # Calculate average hourly profile for the month
301
+ hourly_profile = calculate_average_daily_profile(
302
+ results["hourly_pv_generation"][month_start_hour:month_start_hour + month_hours],
303
+ DAYS_IN_MONTH[month_index]
304
+ )
305
+
306
+ # Create hourly profile chart
307
+ fig_hourly = px.line(
308
+ x=list(range(24)),
309
+ y=hourly_profile,
310
+ title=f"Average Daily PV Generation Profile for {selected_month}",
311
+ labels={"x": "Hour of Day", "y": "PV Generation (kWh)"}
312
+ )
313
+ st.plotly_chart(fig_hourly, use_container_width=True)
314
+
315
+ def display_energy_offset_tab():
316
+ """Display the energy offset and net-zero analysis tab."""
317
+ st.header("Energy Offset & Net-Zero Analysis")
318
+
319
+ # Check if PV results are available
320
+ pv_results = st.session_state.project_data["renewable_energy"].get("results")
321
+ if not pv_results:
322
+ st.info("Please calculate PV generation using the Solar PV System tab.")
323
+ return
324
+
325
+ # Get building energy consumption data
326
+ building_energy_results = st.session_state.project_data["building_energy"]["results"]
327
+
328
+ # Calculate energy offset
329
+ annual_consumption = building_energy_results["annual_total_energy"]
330
+ annual_generation = pv_results["annual_pv_generation"]
331
+ energy_offset_percentage = (annual_generation / annual_consumption) * 100 if annual_consumption > 0 else 0
332
+
333
+ # Display offset summary
334
+ st.subheader("Energy Offset Summary")
335
+
336
+ col1, col2 = st.columns(2)
337
+
338
+ with col1:
339
+ st.metric(
340
+ "Annual Energy Consumption",
341
+ f"{annual_consumption / 1000:.1f} MWh",
342
+ help="Total annual energy consumed by the building."
343
+ )
344
+
345
+ with col2:
346
+ st.metric(
347
+ "Annual PV Generation",
348
+ f"{annual_generation / 1000:.1f} MWh",
349
+ help="Total annual electricity generated by the PV system."
350
+ )
351
+
352
+ st.metric(
353
+ "Energy Offset Percentage",
354
+ f"{energy_offset_percentage:.1f}%",
355
+ help="Percentage of building energy consumption offset by PV generation."
356
+ )
357
+
358
+ # Display net energy balance
359
+ st.subheader("Net Energy Balance")
360
+
361
+ # Calculate hourly net energy
362
+ hourly_consumption = np.array(building_energy_results["hourly_total_energy"])
363
+ hourly_generation = np.array(pv_results["hourly_pv_generation"])
364
+ hourly_net_energy = hourly_generation - hourly_consumption # Positive = export, Negative = import
365
+
366
+ # Calculate monthly net energy
367
+ monthly_net_energy = calculate_monthly_totals(hourly_net_energy)
368
+
369
+ # Create bar chart of monthly net energy
370
+ monthly_net_df = pd.DataFrame({
371
+ "Month": MONTHS,
372
+ "Net Energy (kWh)": monthly_net_energy
373
+ })
374
+
375
+ fig_monthly_net = px.bar(
376
+ monthly_net_df,
377
+ x="Month",
378
+ y="Net Energy (kWh)",
379
+ title="Monthly Net Energy Balance (PV Generation - Consumption)",
380
+ color="Net Energy (kWh)",
381
+ color_continuous_scale=px.colors.diverging.RdBu
382
+ )
383
+ st.plotly_chart(fig_monthly_net, use_container_width=True)
384
+
385
+ # Display net-zero analysis
386
+ st.subheader("Net-Zero Energy Analysis")
387
+
388
+ if energy_offset_percentage >= 100:
389
+ st.success("Congratulations! This building achieves Net-Zero Energy based on annual generation vs. consumption.")
390
+ else:
391
+ st.warning("This building does not achieve Net-Zero Energy based on annual generation vs. consumption.")
392
+
393
+ # Calculate additional PV capacity needed for net-zero
394
+ shortfall_kwh = annual_consumption - annual_generation
395
+ if shortfall_kwh > 0:
396
+ # Estimate additional capacity based on current system performance
397
+ current_capacity_kw = st.session_state.project_data["renewable_energy"]["pv_system"]["system_capacity_kw"]
398
+ kwh_per_kw = annual_generation / current_capacity_kw if current_capacity_kw > 0 else 0
399
+ additional_capacity_kw = shortfall_kwh / kwh_per_kw if kwh_per_kw > 0 else float("inf")
400
+
401
+ st.info(f"Estimated additional PV capacity needed for Net-Zero: {additional_capacity_kw:.1f} kWp")
402
+
403
+ # Display self-consumption and grid interaction
404
+ st.subheader("Self-Consumption & Grid Interaction")
405
+
406
+ # Calculate self-consumption and grid export/import
407
+ hourly_self_consumption = np.minimum(hourly_consumption, hourly_generation)
408
+ hourly_grid_export = np.maximum(0, hourly_generation - hourly_consumption)
409
+ hourly_grid_import = np.maximum(0, hourly_consumption - hourly_generation)
410
+
411
+ # Calculate annual totals
412
+ annual_self_consumption = sum(hourly_self_consumption)
413
+ annual_grid_export = sum(hourly_grid_export)
414
+ annual_grid_import = sum(hourly_grid_import)
415
+
416
+ # Calculate self-consumption rate and self-sufficiency rate
417
+ self_consumption_rate = (annual_self_consumption / annual_generation) * 100 if annual_generation > 0 else 0
418
+ self_sufficiency_rate = (annual_self_consumption / annual_consumption) * 100 if annual_consumption > 0 else 0
419
+
420
+ col1, col2, col3 = st.columns(3)
421
+
422
+ with col1:
423
+ st.metric("Self-Consumption Rate", f"{self_consumption_rate:.1f}%", help="Percentage of PV generation consumed on-site.")
424
+ with col2:
425
+ st.metric("Self-Sufficiency Rate", f"{self_sufficiency_rate:.1f}%", help="Percentage of building energy demand met by on-site PV.")
426
+
427
+ # Create pie chart of energy flows
428
+ energy_flows = {
429
+ "Self-Consumed PV": annual_self_consumption,
430
+ "Exported to Grid": annual_grid_export,
431
+ "Imported from Grid": annual_grid_import
432
+ }
433
+
434
+ fig_flows = px.pie(
435
+ values=list(energy_flows.values()),
436
+ names=list(energy_flows.keys()),
437
+ title="Annual Energy Flows"
438
+ )
439
+ st.plotly_chart(fig_flows, use_container_width=True)
440
+
441
+ def calculate_pv_generation() -> Dict[str, Any]:
442
+ """
443
+ Calculate PV system energy generation.
444
+
445
+ Returns:
446
+ Dictionary containing PV generation results.
447
+ """
448
+ logger.info("Starting PV generation calculations...")
449
+
450
+ # Get required data
451
+ climate_data = st.session_state.project_data["climate_data"]
452
+ pv_system = st.session_state.project_data["renewable_energy"]["pv_system"]
453
+
454
+ # Get hourly solar radiation data
455
+ hourly_weather = pd.DataFrame(climate_data["hourly_data"])
456
+ hourly_solar_angles = pd.DataFrame(climate_data["solar_angles"]) # Assuming this is stored from HVAC loads
457
+
458
+ # Combine weather and solar angles
459
+ hourly_data = pd.concat([hourly_weather, hourly_solar_angles], axis=1)
460
+
461
+ # Get PV system parameters
462
+ capacity_kw = pv_system["system_capacity_kw"]
463
+ module_efficiency = pv_system["module_efficiency"]
464
+ inverter_efficiency = pv_system["inverter_efficiency"]
465
+ system_losses = pv_system["system_losses"]
466
+ tilt_angle = pv_system["tilt_angle"]
467
+ azimuth_angle = pv_system["azimuth_angle"]
468
+
469
+ # Calculate incident solar radiation on PV panels (W/m²)
470
+ # Using the same function from hvac_loads.py (ensure it is accessible or duplicated)
471
+ incident_solar_pv = calculate_incident_solar_on_surface(
472
+ hourly_data["Direct Normal Radiation"],
473
+ hourly_data["Diffuse Horizontal Radiation"],
474
+ hourly_data["solar_altitude"],
475
+ hourly_data["solar_azimuth"],
476
+ tilt_angle,
477
+ azimuth_angle
478
+ )
479
+
480
+ # Calculate PV array area (m²)
481
+ # Assuming standard test conditions (STC) irradiance of 1000 W/m²
482
+ array_area = (capacity_kw * 1000) / (1000 * module_efficiency)
483
+
484
+ # Calculate DC power output from PV array (W)
485
+ dc_power = incident_solar_pv * array_area * module_efficiency
486
+
487
+ # Calculate AC power output after inverter and system losses (W)
488
+ ac_power = dc_power * inverter_efficiency * (1 - system_losses)
489
+
490
+ # Ensure AC power does not exceed system capacity (kW -> W)
491
+ ac_power = np.minimum(ac_power, capacity_kw * 1000)
492
+
493
+ # Convert hourly AC power from W to kWh
494
+ hourly_pv_generation = ac_power / 1000
495
+
496
+ # Calculate monthly PV generation
497
+ monthly_pv_generation = calculate_monthly_totals(hourly_pv_generation)
498
+
499
+ # Calculate annual PV generation
500
+ annual_pv_generation = sum(monthly_pv_generation)
501
+
502
+ # Calculate capacity factor
503
+ max_possible_generation = capacity_kw * HOURS_IN_YEAR
504
+ capacity_factor = annual_pv_generation / max_possible_generation if max_possible_generation > 0 else 0
505
+
506
+ # Compile results
507
+ results = {
508
+ "hourly_pv_generation": hourly_pv_generation.tolist(),
509
+ "monthly_pv_generation": monthly_pv_generation,
510
+ "annual_pv_generation": annual_pv_generation,
511
+ "capacity_factor": capacity_factor,
512
+ "calculation_timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
513
+ }
514
+
515
+ logger.info("PV generation calculations completed.")
516
+ return results
517
+
518
+ # Helper function (can be moved to a utility module later)
519
+ def calculate_incident_solar_on_surface(dnr: pd.Series, dhr: pd.Series, solar_altitude: pd.Series, solar_azimuth: pd.Series, tilt: float, surface_azimuth: float) -> pd.Series:
520
+ """
521
+ Calculate total incident solar radiation on a tilted surface.
522
+ (Copied from hvac_loads.py for now, consider refactoring to a shared utility)
523
+ """
524
+ # Convert angles to radians
525
+ alt_rad = np.radians(solar_altitude)
526
+ az_rad = np.radians(solar_azimuth)
527
+ tilt_rad = np.radians(tilt)
528
+ surf_az_rad = np.radians(surface_azimuth)
529
+
530
+ # Angle of Incidence (theta)
531
+ cos_theta = np.cos(alt_rad) * np.sin(tilt_rad) * np.cos(az_rad - surf_az_rad) + \
532
+ np.sin(alt_rad) * np.cos(tilt_rad)
533
+ cos_theta = np.maximum(0, cos_theta) # Radiation only incident if cos_theta > 0
534
+
535
+ # Direct radiation component on tilted surface
536
+ direct_tilted = dnr * cos_theta
537
+
538
+ # Diffuse radiation component (simplified isotropic sky model)
539
+ sky_diffuse_tilted = dhr * (1 + np.cos(tilt_rad)) / 2
540
+
541
+ # Ground reflected component (simplified)
542
+ albedo = 0.2 # Typical ground reflectance
543
+ ground_reflected_tilted = (dnr * np.sin(alt_rad) + dhr) * albedo * (1 - np.cos(tilt_rad)) / 2
544
+
545
+ # Total incident solar radiation
546
+ total_incident = direct_tilted + sky_diffuse_tilted + ground_reflected_tilted
547
+ return total_incident
548
+
549
+ # Helper function (can be moved to a utility module later)
550
+ def calculate_average_daily_profile(hourly_data: List[float], days: int) -> List[float]:
551
+ """
552
+ Calculate average daily profile from hourly data for a month.
553
+ (Copied from building_energy.py for now, consider refactoring to a shared utility)
554
+ """
555
+ daily_profile = [0.0] * 24
556
+ hourly_data_np = np.array(hourly_data)
557
+
558
+ for hour in range(len(hourly_data_np)):
559
+ hour_of_day = hour % 24
560
+ daily_profile[hour_of_day] += hourly_data_np[hour]
561
+
562
+ # Calculate averages
563
+ for i in range(24):
564
+ daily_profile[i] /= days
565
+
566
+ return daily_profile
567
+
568
+ # Helper function (can be moved to a utility module later)
569
+ def calculate_monthly_totals(hourly_data: np.ndarray) -> List[float]:
570
+ """
571
+ Calculate monthly totals from hourly data.
572
+ (Copied from building_energy.py for now, consider refactoring to a shared utility)
573
+ """
574
+ monthly_totals = []
575
+ hour_index = 0
576
+
577
+ for days_in_month_count in DAYS_IN_MONTH:
578
+ hours_in_month = days_in_month_count * 24
579
+ month_total = sum(hourly_data[hour_index:hour_index + hours_in_month])
580
+ monthly_totals.append(month_total)
581
+ hour_index += hours_in_month
582
+
583
+ return monthly_totals
584
+
585
+ def display_renewable_energy_help():
586
+ """
587
+ Display help information for the renewable energy page.
588
+ """
589
+ st.markdown("""
590
+ ### Renewable Energy Systems Help
591
+
592
+ This section allows you to model renewable energy systems, primarily Solar Photovoltaics (PV), and analyze their impact on the building\s energy balance.
593
+
594
+ **Key Concepts:**
595
+
596
+ * **Solar PV System**: Converts sunlight into electricity.
597
+ * **System Capacity (kWp)**: Peak power output of the PV array under standard test conditions.
598
+ * **Module Efficiency**: Percentage of sunlight converted to DC electricity by the PV modules.
599
+ * **Inverter Efficiency**: Percentage of DC power converted to AC power by the inverter.
600
+ * **System Losses**: Reductions in PV output due to factors like wiring, soiling, temperature, and shading.
601
+ * **Tilt & Azimuth Angles**: Orientation of the PV panels, affecting solar energy capture.
602
+ * **Energy Offset**: Percentage of the building\s energy consumption met by on-site renewable generation.
603
+ * **Net-Zero Energy**: A building that produces as much energy as it consumes over a year.
604
+ * **Self-Consumption**: Portion of PV-generated electricity used directly by the building.
605
+ * **Grid Interaction**: Exchange of electricity with the utility grid (import and export).
606
+
607
+ **Workflow:**
608
+
609
+ 1. **Solar PV System Tab**:
610
+ * Configure the parameters of your PV system (capacity, efficiency, orientation, costs).
611
+ * Click "Calculate PV Generation" to simulate the system\s output based on climate data.
612
+ * Review the PV generation summary, monthly profiles, and typical daily output.
613
+
614
+ 2. **Energy Offset & Net Zero Tab**:
615
+ * Analyze how the PV generation offsets the building\s energy consumption (calculated in the Building Energy section).
616
+ * View the net energy balance (generation vs. consumption) on a monthly basis.
617
+ * Assess if the building achieves Net-Zero Energy status.
618
+ * Examine self-consumption rates and grid interaction patterns.
619
+
620
+ **Important:**
621
+
622
+ * Accurate climate data (especially solar radiation) is crucial for PV modeling.
623
+ * System losses can significantly impact actual PV generation.
624
+ * Net-Zero calculations here are based on annual energy balance; consider hourly dynamics for more detailed analysis.
625
+ * This module focuses on PV; other renewable technologies may require different modeling approaches.
626
+ """)