diff --git "a/app/internal_loads.py" "b/app/internal_loads.py" --- "a/app/internal_loads.py" +++ "b/app/internal_loads.py" @@ -1,9 +1,11 @@ """ -BuildSustain - Internal Loads Module +HVAC Load Calculator - Internal Loads Module (Refactored) -This module handles the internal loads functionality of the BuildSustain application, -allowing users to define occupancy, lighting, equipment, and other internal heat gains. -It provides schedule-based load profiles and integrates with building information. +This module handles the internal loads functionality of the HVAC Load Calculator application, +allowing users to define occupancy, lighting, equipment, ventilation, infiltration, and schedules. +It provides comprehensive load management with the new UI structure matching other modules. + +Refactored to fix bugs, adopt new UI structure, and integrate comprehensive data. Developed by: Dr Majed Abuseif, Deakin University © 2025 @@ -24,219 +26,178 @@ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %( logger = logging.getLogger(__name__) # Define constants -LOAD_TYPES = ["Occupancy", "Lighting", "Equipment", "Other"] -SCHEDULE_TYPES = ["Continuous", "Day/Night", "Custom"] -DAYS_OF_WEEK = ["Weekday", "Weekend"] - -# Building types with categories for display -BUILDING_TYPES = [ - "Residential - Single-Family Detached", - "Residential - Single-Family Attached", - "Residential - Multifamily (Low-rise and High-rise)", - "Residential - Dormitories", - "Residential - Hotels and Motels", - "Commercial/Retail - Strip Mall", - "Commercial/Retail - Enclosed Mall", - "Commercial/Retail - Department Store", - "Commercial/Retail - Supermarket", - "Commercial/Retail - Convenience Store", - "Commercial/Retail - Fast Food Restaurant", - "Commercial/Retail - Full-Service Restaurant", - "Office - Small Office (<1,000 m²)", - "Office - Medium Office (≈5,000 m²)", - "Office - Large Office (>10,000 m²)", - "Office - Call Centre", - "Educational - Primary School", - "Educational - Secondary School", - "Educational - University/College Classroom", - "Educational - Lecture Hall", - "Educational - Laboratory", - "Educational - Library", - "Healthcare - Hospital (Inpatient)", - "Healthcare - Outpatient Clinic/Medical Office", - "Healthcare - Nursing Home/Aged Care", - "Assembly - Auditorium", - "Assembly - Theatre/Performing Arts", - "Assembly - Convention Centre", - "Assembly - Gymnasium/Sports Arena", - "Assembly - Religious Building", - "Industrial - Light Manufacturing", - "Industrial - Heavy Manufacturing", - "Industrial - Warehouse (Unconditioned or Semi-conditioned)", - "Industrial - Data Centre/Server Room", - "Public and Institutional - Courthouse", - "Public and Institutional - Police Station", - "Public and Institutional - Fire Station", - "Public and Institutional - Post Office", - "Public and Institutional - Museum", - "Lodging - Hotel (Full-Service, Midscale, or Economy)", - "Lodging - Motel", - "Lodging - Resort", - "Transportation - Airport Terminal", - "Transportation - Train/Bus Station", - "Transportation - Car Park (Enclosed)", - "Other" -] - -# Default occupancy density by building type (m² per person) -DEFAULT_OCCUPANCY_DENSITIES = { - "Residential - Single-Family Detached": 40.0, - "Residential - Single-Family Attached": 30.0, - "Residential - Multifamily (Low-rise and High-rise)": 20.0, - "Residential - Dormitories": 10.0, - "Residential - Hotels and Motels": 15.0, - "Commercial/Retail - Strip Mall": 5.0, - "Commercial/Retail - Enclosed Mall": 4.0, - "Commercial/Retail - Department Store": 5.0, - "Commercial/Retail - Supermarket": 6.0, - "Commercial/Retail - Convenience Store": 5.0, - "Commercial/Retail - Fast Food Restaurant": 3.0, - "Commercial/Retail - Full-Service Restaurant": 2.5, - "Office - Small Office (<1,000 m²)": 10.0, - "Office - Medium Office (≈5,000 m²)": 10.0, - "Office - Large Office (>10,000 m²)": 10.0, - "Office - Call Centre": 6.0, - "Educational - Primary School": 4.0, - "Educational - Secondary School": 4.0, - "Educational - University/College Classroom": 3.5, - "Educational - Lecture Hall": 2.0, - "Educational - Laboratory": 8.0, - "Educational - Library": 5.0, - "Healthcare - Hospital (Inpatient)": 8.0, - "Healthcare - Outpatient Clinic/Medical Office": 7.0, - "Healthcare - Nursing Home/Aged Care": 10.0, - "Assembly - Auditorium": 2.0, - "Assembly - Theatre/Performing Arts": 2.0, - "Assembly - Convention Centre": 3.0, - "Assembly - Gymnasium/Sports Arena": 4.0, - "Assembly - Religious Building": 3.0, - "Industrial - Light Manufacturing": 15.0, - "Industrial - Heavy Manufacturing": 20.0, - "Industrial - Warehouse (Unconditioned or Semi-conditioned)": 25.0, - "Industrial - Data Centre/Server Room": 10.0, - "Public and Institutional - Courthouse": 5.0, - "Public and Institutional - Police Station": 8.0, - "Public and Institutional - Fire Station": 10.0, - "Public and Institutional - Post Office": 6.0, - "Public and Institutional - Museum": 5.0, - "Lodging - Hotel (Full-Service, Midscale, or Economy)": 15.0, - "Lodging - Motel": 15.0, - "Lodging - Resort": 12.0, - "Transportation - Airport Terminal": 3.0, - "Transportation - Train/Bus Station": 3.0, - "Transportation - Car Park (Enclosed)": 20.0, - "Other": 10.0 -} +LOAD_TYPES = ["People", "Lighting", "Equipment", "Ventilation & Infiltration", "Schedules"] -# Default lighting power density by building type (W/m²) -DEFAULT_LIGHTING_DENSITIES = { - "Residential - Single-Family Detached": 8.0, - "Residential - Single-Family Attached": 8.0, - "Residential - Multifamily (Low-rise and High-rise)": 8.0, - "Residential - Dormitories": 10.0, - "Residential - Hotels and Motels": 12.0, - "Commercial/Retail - Strip Mall": 15.0, - "Commercial/Retail - Enclosed Mall": 18.0, - "Commercial/Retail - Department Store": 16.0, - "Commercial/Retail - Supermarket": 18.0, - "Commercial/Retail - Convenience Store": 20.0, - "Commercial/Retail - Fast Food Restaurant": 14.0, - "Commercial/Retail - Full-Service Restaurant": 14.0, - "Office - Small Office (<1,000 m²)": 12.0, - "Office - Medium Office (≈5,000 m²)": 12.0, - "Office - Large Office (>10,000 m²)": 12.0, - "Office - Call Centre": 13.0, - "Educational - Primary School": 14.0, - "Educational - Secondary School": 14.0, - "Educational - University/College Classroom": 14.0, - "Educational - Lecture Hall": 15.0, - "Educational - Laboratory": 16.0, - "Educational - Library": 13.0, - "Healthcare - Hospital (Inpatient)": 15.0, - "Healthcare - Outpatient Clinic/Medical Office": 14.0, - "Healthcare - Nursing Home/Aged Care": 13.0, - "Assembly - Auditorium": 12.0, - "Assembly - Theatre/Performing Arts": 12.0, - "Assembly - Convention Centre": 14.0, - "Assembly - Gymnasium/Sports Arena": 15.0, - "Assembly - Religious Building": 12.0, - "Industrial - Light Manufacturing": 13.0, - "Industrial - Heavy Manufacturing": 13.0, - "Industrial - Warehouse (Unconditioned or Semi-conditioned)": 10.0, - "Industrial - Data Centre/Server Room": 15.0, - "Public and Institutional - Courthouse": 14.0, - "Public and Institutional - Police Station": 13.0, - "Public and Institutional - Fire Station": 13.0, - "Public and Institutional - Post Office": 14.0, - "Public and Institutional - Museum": 15.0, - "Lodging - Hotel (Full-Service, Midscale, or Economy)": 12.0, - "Lodging - Motel": 12.0, - "Lodging - Resort": 12.0, - "Transportation - Airport Terminal": 14.0, - "Transportation - Train/Bus Station": 14.0, - "Transportation - Car Park (Enclosed)": 8.0, - "Other": 12.0 +# People activity levels data as provided by user +PEOPLE_ACTIVITY_LEVELS = { + "Seated, at Rest (Quiet, Reading, Writing)": { + "metabolic_rate_met": 1.0, + "metabolic_rate_w": 110, + "sensible_min_w": 20, + "sensible_max_w": 24, + "latent_min_w": 9, + "latent_max_w": 12 + }, + "Seated, Light Office Work (Typing, Filing)": { + "metabolic_rate_met": 1.1, + "metabolic_rate_w": 125, + "sensible_min_w": 24, + "sensible_max_w": 27, + "latent_min_w": 12, + "latent_max_w": 15 + }, + "Standing, Light Work (Filing, Walking Slowly)": { + "metabolic_rate_met": 1.35, + "metabolic_rate_w": 155, + "sensible_min_w": 30, + "sensible_max_w": 35, + "latent_min_w": 18, + "latent_max_w": 24 + }, + "Walking, Moderate Pace (2–3 mph / 3.2–4.8 km/h)": { + "metabolic_rate_met": 2.0, + "metabolic_rate_w": 210, + "sensible_min_w": 41, + "sensible_max_w": 47, + "latent_min_w": 30, + "latent_max_w": 35 + }, + "Light Machine Work (Assembly, Small Tools)": { + "metabolic_rate_met": 2.35, + "metabolic_rate_w": 250, + "sensible_min_w": 47, + "sensible_max_w": 56, + "latent_min_w": 35, + "latent_max_w": 44 + }, + "Moderate Work (Walking with Loads, Lifting)": { + "metabolic_rate_met": 3.0, + "metabolic_rate_w": 310, + "sensible_min_w": 59, + "sensible_max_w": 68, + "latent_min_w": 50, + "latent_max_w": 59 + }, + "Heavy Work (Carrying Heavy Loads, Shoveling)": { + "metabolic_rate_met": 4.0, + "metabolic_rate_w": 425, + "sensible_min_w": 73, + "sensible_max_w": 88, + "latent_min_w": 73, + "latent_max_w": 88 + }, + "Dancing (Moderate to Vigorous)": { + "metabolic_rate_met": 3.5, + "metabolic_rate_w": 400, + "sensible_min_w": 59, + "sensible_max_w": 88, + "latent_min_w": 59, + "latent_max_w": 73 + }, + "Athletics/Exercise (Vigorous)": { + "metabolic_rate_met": 6.0, + "metabolic_rate_w": 600, + "sensible_min_w": 88, + "sensible_max_w": 117, + "latent_min_w": 88, + "latent_max_w": 117 + } } -# Default equipment power density by building type (W/m²) -DEFAULT_EQUIPMENT_DENSITIES = { - "Residential - Single-Family Detached": 5.0, - "Residential - Single-Family Attached": 5.0, - "Residential - Multifamily (Low-rise and High-rise)": 5.0, - "Residential - Dormitories": 8.0, - "Residential - Hotels and Motels": 10.0, - "Commercial/Retail - Strip Mall": 10.0, - "Commercial/Retail - Enclosed Mall": 12.0, - "Commercial/Retail - Department Store": 12.0, - "Commercial/Retail - Supermarket": 15.0, - "Commercial/Retail - Convenience Store": 15.0, - "Commercial/Retail - Fast Food Restaurant": 20.0, - "Commercial/Retail - Full-Service Restaurant": 25.0, - "Office - Small Office (<1,000 m²)": 15.0, - "Office - Medium Office (≈5,000 m²)": 15.0, - "Office - Large Office (>10,000 m²)": 15.0, - "Office - Call Centre": 20.0, - "Educational - Primary School": 8.0, - "Educational - Secondary School": 8.0, - "Educational - University/College Classroom": 10.0, - "Educational - Lecture Hall": 10.0, - "Educational - Laboratory": 20.0, - "Educational - Library": 10.0, - "Healthcare - Hospital (Inpatient)": 20.0, - "Healthcare - Outpatient Clinic/Medical Office": 15.0, - "Healthcare - Nursing Home/Aged Care": 12.0, - "Assembly - Auditorium": 8.0, - "Assembly - Theatre/Performing Arts": 8.0, - "Assembly - Convention Centre": 10.0, - "Assembly - Gymnasium/Sports Arena": 10.0, - "Assembly - Religious Building": 8.0, - "Industrial - Light Manufacturing": 20.0, - "Industrial - Heavy Manufacturing": 25.0, - "Industrial - Warehouse (Unconditioned or Semi-conditioned)": 8.0, - "Industrial - Data Centre/Server Room": 50.0, - "Public and Institutional - Courthouse": 12.0, - "Public and Institutional - Police Station": 15.0, - "Public and Institutional - Fire Station": 15.0, - "Public and Institutional - Post Office": 12.0, - "Public and Institutional - Museum": 10.0, - "Lodging - Hotel (Full-Service, Midscale, or Economy)": 10.0, - "Lodging - Motel": 10.0, - "Lodging - Resort": 12.0, - "Transportation - Airport Terminal": 12.0, - "Transportation - Train/Bus Station": 12.0, - "Transportation - Car Park (Enclosed)": 5.0, - "Other": 12.0 +# Default building internals data as provided by user +DEFAULT_BUILDING_INTERNALS = { + name: { + "lighting_density": data[0], # W/m² + "diversity_factor": data[1], + "equipment_heat_gains": { + "sensible": data[2], + "latent": data[3], + "convective": data[4], + "radiant": data[5] + }, + "ventilation_rate": data[6], # L/s·person or L/s·m² + "air_change_rate": data[7] # ACH + } for name, data in [ + ("Residential - Single-Family Detached", [8.0, 0.7, 3.5, 1.5, 2.0, 1.5, 7.5, 0.35]), + ("Residential - Single-Family Attached", [8.0, 0.7, 3.5, 1.5, 2.0, 1.5, 7.5, 0.35]), + ("Residential - Multifamily (Low-rise and High-rise)", [8.0, 0.75, 4.0, 2.0, 2.5, 1.5, 8.0, 0.35]), + ("Residential - Dormitories", [10.0, 0.8, 5.0, 2.0, 3.0, 2.0, 10.0, 0.5]), + ("Residential - Hotels and Motels", [12.0, 0.85, 6.0, 2.5, 3.5, 2.5, 10.0, 0.5]), + ("Commercial/Retail - Strip Mall", [15.0, 0.9, 12.0, 2.0, 6.0, 6.0, 10.0, 1.0]), + ("Commercial/Retail - Enclosed Mall", [18.0, 0.9, 15.0, 2.0, 8.0, 7.0, 10.0, 1.2]), + ("Commercial/Retail - Department Store", [16.0, 0.9, 14.0, 2.0, 7.0, 7.0, 10.0, 1.0]), + ("Commercial/Retail - Supermarket", [18.0, 0.95, 18.0, 3.0, 10.0, 8.0, 12.0, 1.2]), + ("Commercial/Retail - Convenience Store", [20.0, 0.95, 20.0, 4.0, 12.0, 8.0, 12.0, 1.5]), + ("Commercial/Retail - Fast Food Restaurant", [14.0, 0.85, 18.0, 6.0, 10.0, 8.0, 15.0, 1.5]), + ("Commercial/Retail - Full-Service Restaurant", [14.0, 0.85, 16.0, 5.0, 9.0, 7.0, 15.0, 1.5]), + ("Office - Small Office (<1,000 m²)", [12.0, 0.8, 10.0, 1.0, 5.0, 5.0, 10.0, 0.9]), + ("Office - Medium Office (≈5,000 m²)", [12.0, 0.8, 10.0, 1.0, 5.0, 5.0, 10.0, 1.0]), + ("Office - Large Office (>10,000 m²)", [12.0, 0.8, 10.0, 1.0, 5.0, 5.0, 10.0, 1.0]), + ("Office - Call Centre", [13.0, 0.85, 12.0, 1.5, 6.0, 6.0, 12.0, 1.2]), + ("Educational - Primary School", [14.0, 0.85, 6.0, 2.0, 3.0, 3.0, 10.0, 1.0]), + ("Educational - Secondary School", [14.0, 0.85, 6.5, 2.0, 3.5, 3.0, 10.0, 1.0]), + ("Educational - University/College Classroom", [14.0, 0.85, 7.0, 2.0, 4.0, 3.0, 10.0, 1.0]), + ("Educational - Lecture Hall", [15.0, 0.85, 8.0, 2.0, 4.0, 4.0, 10.0, 1.0]), + ("Educational - Laboratory", [16.0, 0.9, 20.0, 4.0, 12.0, 8.0, 15.0, 2.5]), + ("Educational - Library", [13.0, 0.8, 5.0, 1.0, 2.5, 2.5, 8.0, 0.7]), + ("Healthcare - Hospital (Inpatient)", [15.0, 0.95, 20.0, 5.0, 10.0, 10.0, 20.0, 2.0]), + ("Healthcare - Outpatient Clinic/Medical Office", [14.0, 0.9, 15.0, 3.0, 8.0, 7.0, 15.0, 1.5]), + ("Healthcare - Nursing Home/Aged Care", [13.0, 0.85, 10.0, 3.0, 5.0, 5.0, 12.0, 1.0]), + ("Assembly - Auditorium", [12.0, 0.9, 8.0, 2.0, 4.0, 4.0, 10.0, 1.0]), + ("Assembly - Theatre/Performing Arts", [12.0, 0.9, 10.0, 2.0, 5.0, 5.0, 10.0, 1.0]), + ("Assembly - Convention Centre", [14.0, 0.9, 12.0, 3.0, 6.0, 6.0, 12.0, 1.2]), + ("Assembly - Gymnasium/Sports Arena", [15.0, 0.9, 14.0, 4.0, 8.0, 6.0, 12.0, 1.5]), + ("Assembly - Religious Building", [12.0, 0.85, 6.0, 1.5, 3.0, 3.0, 10.0, 1.0]), + ("Industrial - Light Manufacturing", [13.0, 0.9, 20.0, 2.0, 10.0, 10.0, 15.0, 1.5]), + ("Industrial - Heavy Manufacturing", [13.0, 0.95, 30.0, 3.0, 15.0, 15.0, 20.0, 2.0]), + ("Industrial - Warehouse (Unconditioned or Semi-conditioned)", [10.0, 0.7, 8.0, 1.0, 4.0, 4.0, 6.0, 0.5]), + ("Industrial - Data Centre/Server Room", [15.0, 1.0, 80.0, 0.0, 80.0, 0.0, 20.0, 2.0]), + ("Public and Institutional - Courthouse", [14.0, 0.85, 8.0, 2.0, 4.0, 4.0, 10.0, 1.0]), + ("Public and Institutional - Police Station", [13.0, 0.85, 8.0, 2.0, 4.0, 4.0, 10.0, 1.0]), + ("Public and Institutional - Fire Station", [13.0, 0.85, 8.0, 2.0, 4.0, 4.0, 10.0, 1.0]), + ("Public and Institutional - Post Office", [14.0, 0.85, 10.0, 2.0, 5.0, 5.0, 10.0, 1.0]), + ("Public and Institutional - Museum", [15.0, 0.9, 12.0, 2.0, 6.0, 6.0, 12.0, 1.2]), + ("Lodging - Hotel (Full-Service, Midscale, or Economy)", [12.0, 0.85, 6.0, 2.0, 3.0, 3.0, 10.0, 0.8]), + ("Lodging - Motel", [12.0, 0.85, 5.0, 2.0, 3.0, 2.0, 8.0, 0.8]), + ("Lodging - Resort", [12.0, 0.85, 7.0, 2.5, 3.5, 3.5, 10.0, 0.8]), + ("Transportation - Airport Terminal", [14.0, 0.95, 10.0, 3.0, 6.0, 4.0, 15.0, 1.5]), + ("Transportation - Train/Bus Station", [14.0, 0.9, 9.0, 2.0, 5.0, 4.0, 12.0, 1.2]), + ("Transportation - Car Park (Enclosed)", [8.0, 0.7, 2.0, 0.0, 2.0, 0.0, 5.0, 0.5]), + ("Other", [12.0, 0.85, 10.0, 2.0, 5.0, 5.0, 10.0, 1.0]) + ] } -# Default schedules -DEFAULT_SCHEDULES = { +# Default schedule templates +DEFAULT_SCHEDULE_TEMPLATES = { "Continuous": { - "Weekday": [1.0] * 24, - "Weekend": [1.0] * 24 + "description": "24/7 operation at full capacity", + "weekday": [1.0] * 24, + "weekend": [1.0] * 24 + }, + "Office Hours": { + "description": "Standard office hours (8 AM - 6 PM)", + "weekday": [0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.2, 0.5, 1.0, 1.0, 1.0, 1.0, 0.8, 1.0, 1.0, 1.0, 1.0, 1.0, 0.5, 0.3, 0.2, 0.1, 0.1, 0.1], + "weekend": [0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.2, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.2, 0.2, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1] + }, + "Retail Hours": { + "description": "Retail store hours (9 AM - 9 PM)", + "weekday": [0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.2, 0.5, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.5, 0.2, 0.1], + "weekend": [0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.2, 0.5, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.5, 0.2, 0.1] + }, + "School Hours": { + "description": "School hours (8 AM - 4 PM)", + "weekday": [0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.2, 0.5, 1.0, 1.0, 1.0, 1.0, 0.8, 1.0, 1.0, 1.0, 0.5, 0.2, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1], + "weekend": [0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1] }, - "Day/Night": { - "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], - "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] + "Restaurant Hours": { + "description": "Restaurant hours with lunch and dinner peaks", + "weekday": [0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.8, 1.0, 0.7, 0.5, 0.4, 0.5, 0.7, 1.0, 0.9, 0.8, 0.6, 0.3, 0.2], + "weekend": [0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.2, 0.3, 0.4, 0.6, 0.8, 1.0, 0.8, 0.6, 0.5, 0.6, 0.8, 1.0, 0.9, 0.8, 0.6, 0.4, 0.2] + }, + "Hospital 24/7": { + "description": "Hospital operation with day/night variation", + "weekday": [0.6, 0.6, 0.6, 0.6, 0.6, 0.7, 0.8, 0.9, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.9, 0.8, 0.7, 0.7, 0.6, 0.6, 0.6], + "weekend": [0.6, 0.6, 0.6, 0.6, 0.6, 0.6, 0.7, 0.8, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.8, 0.7, 0.7, 0.6, 0.6, 0.6, 0.6] } } @@ -246,6 +207,16 @@ def display_internal_loads_page(): This is the main function called by main.py when the Internal Loads page is selected. """ st.title("Internal Loads") + st.write("Define internal heat gains from people, lighting, equipment, ventilation, and infiltration based on ASHRAE 2005 Handbook.") + + # Check if building type is set + building_type = st.session_state.building_info.get("building_type") + if not building_type: + st.error("Please select a building type in Building Information.") + if st.button("Go to Building Information", key="internal_to_building"): + st.session_state.current_page = "Building Information" + st.rerun() + return # Display help information in an expandable section with st.expander("Help & Information"): @@ -254,17 +225,29 @@ def display_internal_loads_page(): # Initialize internal loads in session state if not present initialize_internal_loads() + # Check if rerun is pending + if 'internal_loads_rerun_pending' not in st.session_state: + st.session_state.internal_loads_rerun_pending = False + + if st.session_state.internal_loads_rerun_pending: + st.session_state.internal_loads_rerun_pending = False + st.rerun() + # Create tabs for different load types tabs = st.tabs(LOAD_TYPES) for i, load_type in enumerate(LOAD_TYPES): with tabs[i]: - display_load_tab(load_type) - - # Summary tab - st.markdown("---") - st.subheader("Internal Loads Summary") - display_loads_summary() + if load_type == "People": + display_people_tab() + elif load_type == "Lighting": + display_lighting_tab() + elif load_type == "Equipment": + display_equipment_tab() + elif load_type == "Ventilation & Infiltration": + display_ventilation_infiltration_tab() + elif load_type == "Schedules": + display_schedules_tab() # Navigation buttons col1, col2 = st.columns(2) @@ -283,768 +266,1269 @@ def initialize_internal_loads(): """Initialize internal loads in session state if not present.""" if "internal_loads" not in st.session_state.project_data: st.session_state.project_data["internal_loads"] = { - "occupancy": [], + "people": [], "lighting": [], "equipment": [], - "other": [] - } - - # Initialize load editor state - if "load_editor" not in st.session_state: - st.session_state.load_editor = { - "type": LOAD_TYPES[0], - "name": "", - "zone": "Whole Building", - "area": get_building_area(), - "density": 0.0, - "total": 0.0, - "sensible_fraction": 0.7, - "latent_fraction": 0.3, - "radiative_fraction": 0.4, - "convective_fraction": 0.6, - "schedule_type": SCHEDULE_TYPES[0], - "custom_schedule": { - "Weekday": [0.0] * 24, - "Weekend": [0.0] * 24 - }, - "edit_mode": False, - "original_id": "" + "ventilation_infiltration": [], + "schedules": dict(DEFAULT_SCHEDULE_TEMPLATES) } - - # Initialize custom zones if not present - if "custom_zones" not in st.session_state.project_data: - st.session_state.project_data["custom_zones"] = ["Whole Building"] - -def display_load_tab(load_type: str): - """ - Display the content for a specific load type tab. - - Args: - load_type: The type of load (e.g., "Occupancy", "Lighting") - """ - st.subheader(f"{load_type} Loads") - - # Get loads of this type - load_key = load_type.lower() - loads = st.session_state.project_data["internal_loads"].get(load_key, []) - - # Display existing loads - if loads: - display_load_list(load_type, loads) - else: - st.info(f"No {load_type.lower()} loads added yet. Use the editor below to add loads.") - - # Load Editor - st.markdown("---") - st.subheader(f"{load_type} Load Editor") - display_load_editor(load_type) -def display_load_list(load_type: str, loads: List[Dict[str, Any]]): - """ - Display the list of existing loads for a given type. - - Args: - load_type: The type of load - loads: List of load dictionaries - """ - # Create a DataFrame for display - data = [] - for i, load in enumerate(loads): - record = { - "#": i + 1, - "Name": load["name"], - "Zone": load["zone"], - "Area (m²)": load["area"], - "Total (W)": load["total"], - "Density (W/m²)": load["density"], - "Schedule": load["schedule_type"] - } - - if load_type == "Occupancy": - record["Sensible (W)"] = load["total"] * load["sensible_fraction"] - record["Latent (W)"] = load["total"] * load["latent_fraction"] - else: - record["Radiative (W)"] = load["total"] * load["radiative_fraction"] - record["Convective (W)"] = load["total"] * load["convective_fraction"] - - data.append(record) - - df = pd.DataFrame(data) - st.dataframe(df, use_container_width=True, hide_index=True) - - # Edit and delete options - col1, col2 = st.columns(2) +def display_people_tab(): + """Display the people tab content with two-column layout.""" + # Split the display into two columns + col1, col2 = st.columns([3, 2]) with col1: - selected_index = st.selectbox( - f"Select {load_type} Load # to Edit", - range(1, len(loads) + 1), - key=f"edit_{load_type}_selector" - ) + st.subheader("Saved People Groups") - if st.button(f"Edit {load_type} Load", key=f"edit_{load_type}_button"): - # Load data into editor - load_data = loads[selected_index - 1] - st.session_state.load_editor = { - "type": load_type, - "name": load_data["name"], - "zone": load_data["zone"], - "area": load_data["area"], - "density": load_data["density"], - "total": load_data["total"], - "sensible_fraction": load_data.get("sensible_fraction", 0.7), - "latent_fraction": load_data.get("latent_fraction", 0.3), - "radiative_fraction": load_data.get("radiative_fraction", 0.4), - "convective_fraction": load_data.get("convective_fraction", 0.6), - "schedule_type": load_data["schedule_type"], - "custom_schedule": load_data.get("custom_schedule", { - "Weekday": [0.0] * 24, - "Weekend": [0.0] * 24 - }), - "edit_mode": True, - "original_id": load_data["id"] - } - st.success(f"{load_type} Load '{load_data['name']}' loaded for editing.") - st.rerun() + # Get people from session state + people_groups = st.session_state.project_data["internal_loads"]["people"] + + if people_groups: + display_people_table(people_groups) + else: + st.write("No people groups defined.") with col2: - selected_index_delete = st.selectbox( - f"Select {load_type} Load # to Delete", - range(1, len(loads) + 1), - key=f"delete_{load_type}_selector" - ) + st.subheader("People Group Editor/Creator") - if st.button(f"Delete {load_type} Load", key=f"delete_{load_type}_button"): - # Delete load - load_key = load_type.lower() - deleted_load = st.session_state.project_data["internal_loads"][load_key].pop(selected_index_delete - 1) - st.success(f"{load_type} Load '{deleted_load['name']}' deleted.") - logger.info(f"Deleted {load_type} Load '{deleted_load['name']}'") + # Check if we have an editor state for people + if "people_editor" not in st.session_state: + st.session_state.people_editor = {} + + # Display the people editor form + with st.form("people_editor_form", clear_on_submit=True): + editor_state = st.session_state.get("people_editor", {}) + is_edit = editor_state.get("is_edit", False) + + # Group name + name = st.text_input( + "Group Name", + value=editor_state.get("name", ""), + help="Enter a unique name for this people group." + ) + + # Number of people + num_people = st.number_input( + "Number of People", + min_value=1, + max_value=1000, + value=int(editor_state.get("num_people", 10)), + help="Number of people in this group." + ) + + # Activity level + activity_level = st.selectbox( + "Activity Level", + list(PEOPLE_ACTIVITY_LEVELS.keys()), + index=list(PEOPLE_ACTIVITY_LEVELS.keys()).index(editor_state.get("activity_level", list(PEOPLE_ACTIVITY_LEVELS.keys())[0])) if editor_state.get("activity_level") in PEOPLE_ACTIVITY_LEVELS else 0, + help="Select the activity level for this group." + ) + + # Clothing insulation + st.write("**Clothing Insulation:**") + col_summer, col_winter = st.columns(2) + + with col_summer: + clo_summer = st.number_input( + "Summer (clo)", + min_value=0.0, + max_value=2.0, + value=float(editor_state.get("clo_summer", 0.5)), + format="%.2f", + help="Clothing insulation for summer conditions." + ) + + with col_winter: + clo_winter = st.number_input( + "Winter (clo)", + min_value=0.0, + max_value=2.0, + value=float(editor_state.get("clo_winter", 1.0)), + format="%.2f", + help="Clothing insulation for winter conditions." + ) + + # Schedule selection + available_schedules = list(st.session_state.project_data["internal_loads"]["schedules"].keys()) + schedule = st.selectbox( + "Schedule", + available_schedules, + index=available_schedules.index(editor_state.get("schedule", available_schedules[0])) if editor_state.get("schedule") in available_schedules else 0, + help="Select the occupancy schedule for this group." + ) + + # Zone assignment + zone = st.text_input( + "Zone", + value=editor_state.get("zone", "Whole Building"), + help="Zone or area where this people group is located." + ) + + # Submit buttons + col1, col2 = st.columns(2) + with col1: + submit_label = "Update People Group" if is_edit else "Add People Group" + submit = st.form_submit_button(submit_label) + + with col2: + cancel = st.form_submit_button("Cancel") + + # Handle form submission + if submit: + # Validate inputs + if not name.strip(): + st.error("Group name is required.") + else: + # Get activity data + activity_data = PEOPLE_ACTIVITY_LEVELS[activity_level] + + # Create people group data + people_data = { + "id": str(uuid.uuid4()), + "name": name.strip(), + "num_people": num_people, + "activity_level": activity_level, + "activity_data": activity_data, + "clo_summer": clo_summer, + "clo_winter": clo_winter, + "schedule": schedule, + "zone": zone, + "sensible_heat_per_person": (activity_data["sensible_min_w"] + activity_data["sensible_max_w"]) / 2, + "latent_heat_per_person": (activity_data["latent_min_w"] + activity_data["latent_max_w"]) / 2, + "total_sensible_heat": num_people * (activity_data["sensible_min_w"] + activity_data["sensible_max_w"]) / 2, + "total_latent_heat": num_people * (activity_data["latent_min_w"] + activity_data["latent_max_w"]) / 2 + } + + # Check if editing or adding new + if is_edit and st.session_state.people_editor.get("edit_id"): + # Update existing people group + edit_id = st.session_state.people_editor["edit_id"] + people_groups = st.session_state.project_data["internal_loads"]["people"] + + for i, group in enumerate(people_groups): + if group.get("id") == edit_id: + people_groups[i] = people_data + break + + st.success(f"People group '{name}' updated successfully!") + else: + # Add new people group + st.session_state.project_data["internal_loads"]["people"].append(people_data) + st.success(f"People group '{name}' added successfully!") + + # Clear editor state + st.session_state.people_editor = {} + st.session_state.internal_loads_rerun_pending = True + st.rerun() + + if cancel: + # Clear editor state + st.session_state.people_editor = {} + st.session_state.internal_loads_rerun_pending = True st.rerun() -def display_load_editor(load_type: str): - """ - Display the editor form for a specific load type. +def display_lighting_tab(): + """Display the lighting tab content with two-column layout.""" + # Split the display into two columns + col1, col2 = st.columns([3, 2]) - Args: - load_type: The type of load - """ - # Check if the editor is currently set to this load type - if st.session_state.load_editor["type"] != load_type and not st.session_state.load_editor["edit_mode"]: - reset_load_editor(load_type) - - # Get building information - building_area = get_building_area() - building_type = get_building_type() + with col1: + st.subheader("Saved Lighting Systems") + + # Get lighting from session state + lighting_systems = st.session_state.project_data["internal_loads"]["lighting"] + + if lighting_systems: + display_lighting_table(lighting_systems) + else: + st.write("No lighting systems defined.") - with st.form(f"{load_type}_editor_form"): - # Load name - name = st.text_input( - "Load Name", - value=st.session_state.load_editor["name"], - help="Enter a unique name for this load." - ) + with col2: + st.subheader("Lighting System Editor/Creator") - # Create columns for layout - col1, col2 = st.columns(2) + # Check if we have an editor state for lighting + if "lighting_editor" not in st.session_state: + st.session_state.lighting_editor = {} - with col1: - # Zone selection - zones = st.session_state.project_data["custom_zones"] - zone = st.selectbox( - "Zone", - zones, - index=zones.index(st.session_state.load_editor["zone"]) if st.session_state.load_editor["zone"] in zones else 0, - help="Select the zone for this load." + # Get building type for default values + building_type = st.session_state.building_info.get("building_type", "Other") + default_lighting_density = DEFAULT_BUILDING_INTERNALS.get(building_type, DEFAULT_BUILDING_INTERNALS["Other"])["lighting_density"] + + # Display the lighting editor form + with st.form("lighting_editor_form", clear_on_submit=True): + editor_state = st.session_state.get("lighting_editor", {}) + is_edit = editor_state.get("is_edit", False) + + # System name + name = st.text_input( + "System Name", + value=editor_state.get("name", ""), + help="Enter a unique name for this lighting system." ) # Area area = st.number_input( "Area (m²)", - min_value=0.1, - max_value=float(building_area), - value=min(float(st.session_state.load_editor["area"]), float(building_area)), + min_value=1.0, + max_value=100000.0, + value=float(editor_state.get("area", st.session_state.building_info.get("floor_area", 100.0))), format="%.2f", - help="Floor area affected by this load." + help="Floor area served by this lighting system." ) - - with col2: - # Density or total selection - input_mode = st.radio( - "Input Mode", - ["Density (W/m²)", "Total Load (W)"], - horizontal=True, - help="Choose whether to input load density or total load." + + # Lighting power density + lpd = st.number_input( + "Lighting Power Density (W/m²)", + min_value=0.1, + max_value=50.0, + value=float(editor_state.get("lpd", default_lighting_density)), + format="%.2f", + help="Lighting power density in watts per square meter." ) - if input_mode == "Density (W/m²)": - # Set default density based on building type if not in edit mode - default_density = 0.0 - if not st.session_state.load_editor["edit_mode"]: - if load_type == "Occupancy": - # For occupancy, we need to convert from m²/person to W/m² - people_density = 1.0 / DEFAULT_OCCUPANCY_DENSITIES.get(building_type, 10.0) - default_density = people_density * 115.0 # 115W per person (sensible + latent) - elif load_type == "Lighting": - default_density = DEFAULT_LIGHTING_DENSITIES.get(building_type, 12.0) - elif load_type == "Equipment": - default_density = DEFAULT_EQUIPMENT_DENSITIES.get(building_type, 15.0) - else: # Other - default_density = 5.0 - else: - default_density = st.session_state.load_editor["density"] - - density = st.number_input( - f"{load_type} Density (W/m²)", + # Heat fractions + st.write("**Heat Distribution:**") + col_rad, col_conv = st.columns(2) + + with col_rad: + radiative_fraction = st.number_input( + "Radiative Fraction", min_value=0.0, - max_value=1000.0, - value=default_density, + max_value=1.0, + value=float(editor_state.get("radiative_fraction", 0.4)), format="%.2f", - help=f"Power density for {load_type.lower()}." + help="Fraction of heat released as radiation." ) - total = density * area - else: - # Total load input - default_total = st.session_state.load_editor["total"] if st.session_state.load_editor["edit_mode"] else 0.0 - total = st.number_input( - f"Total {load_type} Load (W)", + + with col_conv: + convective_fraction = st.number_input( + "Convective Fraction", min_value=0.0, - max_value=1000000.0, - value=default_total, - format="%.1f", - help=f"Total power for {load_type.lower()}." + max_value=1.0, + value=float(editor_state.get("convective_fraction", 0.6)), + format="%.2f", + help="Fraction of heat released as convection." ) - density = total / area if area > 0 else 0.0 + + # Validate fractions + if abs(radiative_fraction + convective_fraction - 1.0) > 0.01: + st.warning("Radiative and convective fractions should sum to 1.0") + + # Schedule selection + available_schedules = list(st.session_state.project_data["internal_loads"]["schedules"].keys()) + schedule = st.selectbox( + "Schedule", + available_schedules, + index=available_schedules.index(editor_state.get("schedule", available_schedules[0])) if editor_state.get("schedule") in available_schedules else 0, + help="Select the lighting schedule for this system." + ) + + # Zone assignment + zone = st.text_input( + "Zone", + value=editor_state.get("zone", "Whole Building"), + help="Zone or area where this lighting system is located." + ) + + # Submit buttons + col1, col2 = st.columns(2) + with col1: + submit_label = "Update Lighting System" if is_edit else "Add Lighting System" + submit = st.form_submit_button(submit_label) + + with col2: + cancel = st.form_submit_button("Cancel") - # Load-specific properties - st.subheader("Load Properties") + # Handle form submission + if submit: + # Validate inputs + if not name.strip(): + st.error("System name is required.") + elif abs(radiative_fraction + convective_fraction - 1.0) > 0.01: + st.error("Radiative and convective fractions must sum to 1.0") + else: + # Create lighting system data + lighting_data = { + "id": str(uuid.uuid4()), + "name": name.strip(), + "area": area, + "lpd": lpd, + "total_power": area * lpd, + "radiative_fraction": radiative_fraction, + "convective_fraction": convective_fraction, + "schedule": schedule, + "zone": zone + } + + # Check if editing or adding new + if is_edit and st.session_state.lighting_editor.get("edit_id"): + # Update existing lighting system + edit_id = st.session_state.lighting_editor["edit_id"] + lighting_systems = st.session_state.project_data["internal_loads"]["lighting"] + + for i, system in enumerate(lighting_systems): + if system.get("id") == edit_id: + lighting_systems[i] = lighting_data + break + + st.success(f"Lighting system '{name}' updated successfully!") + else: + # Add new lighting system + st.session_state.project_data["internal_loads"]["lighting"].append(lighting_data) + st.success(f"Lighting system '{name}' added successfully!") + + # Clear editor state + st.session_state.lighting_editor = {} + st.session_state.internal_loads_rerun_pending = True + st.rerun() - if load_type == "Occupancy": - col1, col2 = st.columns(2) + if cancel: + # Clear editor state + st.session_state.lighting_editor = {} + st.session_state.internal_loads_rerun_pending = True + st.rerun() + +def display_equipment_tab(): + """Display the equipment tab content with two-column layout.""" + # Split the display into two columns + col1, col2 = st.columns([3, 2]) + + with col1: + st.subheader("Saved Equipment") + + # Get equipment from session state + equipment_systems = st.session_state.project_data["internal_loads"]["equipment"] + + if equipment_systems: + display_equipment_table(equipment_systems) + else: + st.write("No equipment defined.") + + with col2: + st.subheader("Equipment Editor/Creator") + + # Check if we have an editor state for equipment + if "equipment_editor" not in st.session_state: + st.session_state.equipment_editor = {} + + # Get building type for default values + building_type = st.session_state.building_info.get("building_type", "Other") + default_equipment_data = DEFAULT_BUILDING_INTERNALS.get(building_type, DEFAULT_BUILDING_INTERNALS["Other"])["equipment_heat_gains"] + + # Display the equipment editor form + with st.form("equipment_editor_form", clear_on_submit=True): + editor_state = st.session_state.get("equipment_editor", {}) + is_edit = editor_state.get("is_edit", False) - with col1: - sensible_fraction = st.slider( - "Sensible Heat Fraction", + # Equipment name + name = st.text_input( + "Equipment Name", + value=editor_state.get("name", ""), + help="Enter a unique name for this equipment." + ) + + # Area + area = st.number_input( + "Area (m²)", + min_value=1.0, + max_value=100000.0, + value=float(editor_state.get("area", st.session_state.building_info.get("floor_area", 100.0))), + format="%.2f", + help="Floor area served by this equipment." + ) + + # Heat gains + st.write("**Heat Gains (W/m²):**") + col_sens, col_lat = st.columns(2) + + with col_sens: + sensible_gain = st.number_input( + "Sensible Heat Gain", min_value=0.0, - max_value=1.0, - value=float(st.session_state.load_editor["sensible_fraction"]), + max_value=200.0, + value=float(editor_state.get("sensible_gain", default_equipment_data["sensible"])), format="%.2f", - help="Fraction of heat that is sensible (affects air temperature)." + help="Sensible heat gain in watts per square meter." ) - with col2: - latent_fraction = st.slider( - "Latent Heat Fraction", + with col_lat: + latent_gain = st.number_input( + "Latent Heat Gain", min_value=0.0, - max_value=1.0, - value=float(st.session_state.load_editor["latent_fraction"]), + max_value=200.0, + value=float(editor_state.get("latent_gain", default_equipment_data["latent"])), format="%.2f", - help="Fraction of heat that is latent (affects humidity)." + help="Latent heat gain in watts per square meter." ) - # Ensure fractions sum to 1.0 - if abs(sensible_fraction + latent_fraction - 1.0) > 0.01: - st.warning("Sensible and latent fractions should sum to 1.0. Values will be normalized.") - total_fraction = sensible_fraction + latent_fraction - if total_fraction > 0: - sensible_fraction = sensible_fraction / total_fraction - latent_fraction = latent_fraction / total_fraction - else: - sensible_fraction = 0.7 - latent_fraction = 0.3 + # Heat distribution + st.write("**Heat Distribution:**") + col_rad, col_conv = st.columns(2) - radiative_fraction = 0.0 - convective_fraction = 0.0 - else: - col1, col2 = st.columns(2) - - with col1: - radiative_fraction = st.slider( - "Radiative Heat Fraction", + with col_rad: + radiative_fraction = st.number_input( + "Radiative Fraction", min_value=0.0, max_value=1.0, - value=float(st.session_state.load_editor["radiative_fraction"]), + value=float(editor_state.get("radiative_fraction", 0.5)), format="%.2f", - help="Fraction of heat that is radiative (affects surface temperatures)." + help="Fraction of sensible heat released as radiation." ) - with col2: - convective_fraction = st.slider( - "Convective Heat Fraction", + with col_conv: + convective_fraction = st.number_input( + "Convective Fraction", min_value=0.0, max_value=1.0, - value=float(st.session_state.load_editor["convective_fraction"]), + value=float(editor_state.get("convective_fraction", 0.5)), format="%.2f", - help="Fraction of heat that is convective (affects air temperature)." + help="Fraction of sensible heat released as convection." ) - # Ensure fractions sum to 1.0 + # Validate fractions if abs(radiative_fraction + convective_fraction - 1.0) > 0.01: - st.warning("Radiative and convective fractions should sum to 1.0. Values will be normalized.") - total_fraction = radiative_fraction + convective_fraction - if total_fraction > 0: - radiative_fraction = radiative_fraction / total_fraction - convective_fraction = convective_fraction / total_fraction - else: - radiative_fraction = 0.4 - convective_fraction = 0.6 + st.warning("Radiative and convective fractions should sum to 1.0") - sensible_fraction = 1.0 - latent_fraction = 0.0 - - # Schedule - st.subheader("Schedule") - - schedule_type = st.selectbox( - "Schedule Type", - SCHEDULE_TYPES, - index=SCHEDULE_TYPES.index(st.session_state.load_editor["schedule_type"]) if st.session_state.load_editor["schedule_type"] in SCHEDULE_TYPES else 0, - help="Select the schedule type for this load." - ) - - if schedule_type == "Custom": - st.write("Define custom schedule for each day type:") - - # Initialize custom schedule if not present - if "custom_schedule" not in st.session_state.load_editor or not st.session_state.load_editor["custom_schedule"]: - st.session_state.load_editor["custom_schedule"] = { - "Weekday": [0.0] * 24, - "Weekend": [0.0] * 24 - } + # Schedule selection + available_schedules = list(st.session_state.project_data["internal_loads"]["schedules"].keys()) + schedule = st.selectbox( + "Schedule", + available_schedules, + index=available_schedules.index(editor_state.get("schedule", available_schedules[0])) if editor_state.get("schedule") in available_schedules else 0, + help="Select the equipment schedule." + ) - # Create tabs for day types - day_tabs = st.tabs(DAYS_OF_WEEK) + # Zone assignment + zone = st.text_input( + "Zone", + value=editor_state.get("zone", "Whole Building"), + help="Zone or area where this equipment is located." + ) - for i, day_type in enumerate(DAYS_OF_WEEK): - with day_tabs[i]: - # Get current schedule values - current_values = st.session_state.load_editor["custom_schedule"].get(day_type, [0.0] * 24) - - # Create sliders for each hour - for hour in range(0, 24, 3): - cols = st.columns(3) - for j in range(3): - if hour + j < 24: - with cols[j]: - hour_label = f"{hour + j:02d}:00 - {hour + j + 1:02d}:00" - current_values[hour + j] = st.slider( - hour_label, - min_value=0.0, - max_value=1.0, - value=float(current_values[hour + j]), - format="%.2f", - key=f"schedule_{day_type}_{hour + j}" - ) + # Submit buttons + col1, col2 = st.columns(2) + with col1: + submit_label = "Update Equipment" if is_edit else "Add Equipment" + submit = st.form_submit_button(submit_label) + + with col2: + cancel = st.form_submit_button("Cancel") + + # Handle form submission + if submit: + # Validate inputs + if not name.strip(): + st.error("Equipment name is required.") + elif abs(radiative_fraction + convective_fraction - 1.0) > 0.01: + st.error("Radiative and convective fractions must sum to 1.0") + else: + # Create equipment data + equipment_data = { + "id": str(uuid.uuid4()), + "name": name.strip(), + "area": area, + "sensible_gain": sensible_gain, + "latent_gain": latent_gain, + "total_sensible_power": area * sensible_gain, + "total_latent_power": area * latent_gain, + "radiative_fraction": radiative_fraction, + "convective_fraction": convective_fraction, + "schedule": schedule, + "zone": zone + } + + # Check if editing or adding new + if is_edit and st.session_state.equipment_editor.get("edit_id"): + # Update existing equipment + edit_id = st.session_state.equipment_editor["edit_id"] + equipment_systems = st.session_state.project_data["internal_loads"]["equipment"] - # Update custom schedule - st.session_state.load_editor["custom_schedule"][day_type] = current_values + for i, system in enumerate(equipment_systems): + if system.get("id") == edit_id: + equipment_systems[i] = equipment_data + break - # Display schedule as a chart - fig = px.bar( - x=list(range(24)), - y=current_values, - labels={"x": "Hour", "y": "Load Factor"}, - title=f"{day_type} Schedule" - ) - fig.update_layout(height=300) - st.plotly_chart(fig, use_container_width=True) - else: - # Display predefined schedule - if schedule_type in DEFAULT_SCHEDULES: - schedule_data = DEFAULT_SCHEDULES[schedule_type] - - # Create tabs for day types - day_tabs = st.tabs(DAYS_OF_WEEK) + st.success(f"Equipment '{name}' updated successfully!") + else: + # Add new equipment + st.session_state.project_data["internal_loads"]["equipment"].append(equipment_data) + st.success(f"Equipment '{name}' added successfully!") - for i, day_type in enumerate(DAYS_OF_WEEK): - with day_tabs[i]: - # Display schedule as a chart - fig = px.bar( - x=list(range(24)), - y=schedule_data[day_type], - labels={"x": "Hour", "y": "Load Factor"}, - title=f"{day_type} Schedule" - ) - fig.update_layout(height=300) - st.plotly_chart(fig, use_container_width=True) - - # Form submission buttons - col1, col2 = st.columns(2) + # Clear editor state + st.session_state.equipment_editor = {} + st.session_state.internal_loads_rerun_pending = True + st.rerun() - with col1: - submit_button = st.form_submit_button("Save Load") - - with col2: - clear_button = st.form_submit_button("Clear Form") - - # Handle form submission - if submit_button: - # Validate inputs - validation_errors = validate_load( - load_type, name, zone, area, density, total, - sensible_fraction, latent_fraction, radiative_fraction, convective_fraction, - schedule_type, st.session_state.load_editor["custom_schedule"] if schedule_type == "Custom" else None, - st.session_state.load_editor["edit_mode"], st.session_state.load_editor["original_id"] - ) - - if validation_errors: - # Display validation errors - for error in validation_errors: - st.error(error) - else: - # Create load data - load_data = { - "id": st.session_state.load_editor["original_id"] if st.session_state.load_editor["edit_mode"] else str(uuid.uuid4()), - "name": name, - "type": load_type, - "zone": zone, - "area": area, - "density": density, - "total": total, - "schedule_type": schedule_type - } - - # Add load-specific properties - if load_type == "Occupancy": - load_data["sensible_fraction"] = sensible_fraction - load_data["latent_fraction"] = latent_fraction - else: - load_data["radiative_fraction"] = radiative_fraction - load_data["convective_fraction"] = convective_fraction - - # Add custom schedule if applicable - if schedule_type == "Custom": - load_data["custom_schedule"] = st.session_state.load_editor["custom_schedule"] - - # Handle edit mode - load_key = load_type.lower() - if st.session_state.load_editor["edit_mode"]: - # Find and update the load - loads = st.session_state.project_data["internal_loads"][load_key] - for i, load in enumerate(loads): - if load["id"] == st.session_state.load_editor["original_id"]: - loads[i] = load_data - break - st.success(f"{load_type} Load '{name}' updated successfully.") - logger.info(f"Updated {load_type} Load '{name}'") - else: - # Add new load - st.session_state.project_data["internal_loads"][load_key].append(load_data) - st.success(f"{load_type} Load '{name}' added successfully.") - logger.info(f"Added new {load_type} Load '{name}'") - - # Reset editor - reset_load_editor(load_type) + if cancel: + # Clear editor state + st.session_state.equipment_editor = {} + st.session_state.internal_loads_rerun_pending = True st.rerun() - - # Handle clear button - if clear_button: - reset_load_editor(load_type) - st.rerun() -def display_loads_summary(): - """Display a summary of all internal loads.""" - # Get all loads - all_loads = [] - for load_type in LOAD_TYPES: - load_key = load_type.lower() - loads = st.session_state.project_data["internal_loads"].get(load_key, []) - for load in loads: - all_loads.append({ - "Type": load_type, - "Name": load["name"], - "Zone": load["zone"], - "Total (W)": load["total"] - }) +def display_ventilation_infiltration_tab(): + """Display the ventilation and infiltration tab content with two-column layout.""" + # Split the display into two columns + col1, col2 = st.columns([3, 2]) - if all_loads: - # Create a DataFrame for display - df = pd.DataFrame(all_loads) + with col1: + st.subheader("Saved Ventilation & Infiltration") - # Calculate totals by type - totals_by_type = df.groupby("Type")["Total (W)"].sum().reset_index() + # Get ventilation/infiltration from session state + vent_inf_systems = st.session_state.project_data["internal_loads"]["ventilation_infiltration"] - # Display summary table - st.subheader("Total Internal Loads by Type") - st.dataframe(totals_by_type, use_container_width=True, hide_index=True) + if vent_inf_systems: + display_ventilation_infiltration_table(vent_inf_systems) + else: + st.write("No ventilation or infiltration systems defined.") + + with col2: + st.subheader("Ventilation/Infiltration Editor/Creator") - # Display pie chart - fig = px.pie( - totals_by_type, - values="Total (W)", - names="Type", - title="Internal Loads Distribution" - ) - st.plotly_chart(fig, use_container_width=True) + # Check if we have an editor state for ventilation/infiltration + if "vent_inf_editor" not in st.session_state: + st.session_state.vent_inf_editor = {} - # Calculate peak load profiles - st.subheader("Peak Load Profiles") + # Get building type for default values + building_type = st.session_state.building_info.get("building_type", "Other") + default_building_data = DEFAULT_BUILDING_INTERNALS.get(building_type, DEFAULT_BUILDING_INTERNALS["Other"]) - # Create tabs for day types - day_tabs = st.tabs(DAYS_OF_WEEK) + # Display the ventilation/infiltration editor form + with st.form("vent_inf_editor_form", clear_on_submit=True): + editor_state = st.session_state.get("vent_inf_editor", {}) + is_edit = editor_state.get("is_edit", False) + + # System name + name = st.text_input( + "System Name", + value=editor_state.get("name", ""), + help="Enter a unique name for this ventilation/infiltration system." + ) + + # System type + system_type = st.selectbox( + "System Type", + ["Ventilation", "Infiltration"], + index=["Ventilation", "Infiltration"].index(editor_state.get("system_type", "Ventilation")) if editor_state.get("system_type") in ["Ventilation", "Infiltration"] else 0, + help="Select whether this is ventilation or infiltration." + ) + + # Area + area = st.number_input( + "Area (m²)", + min_value=1.0, + max_value=100000.0, + value=float(editor_state.get("area", st.session_state.building_info.get("floor_area", 100.0))), + format="%.2f", + help="Floor area served by this system." + ) + + if system_type == "Ventilation": + # Ventilation rate + ventilation_rate = st.number_input( + "Ventilation Rate (L/s·m²)", + min_value=0.1, + max_value=50.0, + value=float(editor_state.get("ventilation_rate", default_building_data["ventilation_rate"])), + format="%.2f", + help="Ventilation rate in liters per second per square meter." + ) + + air_change_rate = 0.0 # Not used for ventilation + else: + # Air change rate for infiltration + air_change_rate = st.number_input( + "Air Change Rate (ACH)", + min_value=0.0, + max_value=10.0, + value=float(editor_state.get("air_change_rate", default_building_data["air_change_rate"])), + format="%.2f", + help="Air change rate in air changes per hour." + ) + + ventilation_rate = 0.0 # Not used for infiltration + + # Schedule selection + available_schedules = list(st.session_state.project_data["internal_loads"]["schedules"].keys()) + schedule = st.selectbox( + "Schedule", + available_schedules, + index=available_schedules.index(editor_state.get("schedule", available_schedules[0])) if editor_state.get("schedule") in available_schedules else 0, + help="Select the operation schedule for this system." + ) + + # Zone assignment + zone = st.text_input( + "Zone", + value=editor_state.get("zone", "Whole Building"), + help="Zone or area where this system operates." + ) + + # Submit buttons + col1, col2 = st.columns(2) + with col1: + submit_label = "Update System" if is_edit else "Add System" + submit = st.form_submit_button(submit_label) + + with col2: + cancel = st.form_submit_button("Cancel") - for i, day_type in enumerate(DAYS_OF_WEEK): - with day_tabs[i]: - # Calculate hourly profiles for each load type - hourly_data = calculate_hourly_profiles(day_type) + # Handle form submission + if submit: + # Validate inputs + if not name.strip(): + st.error("System name is required.") + else: + # Create ventilation/infiltration data + vent_inf_data = { + "id": str(uuid.uuid4()), + "name": name.strip(), + "system_type": system_type, + "area": area, + "ventilation_rate": ventilation_rate, + "air_change_rate": air_change_rate, + "schedule": schedule, + "zone": zone + } - if hourly_data: - # Create a stacked area chart - fig = go.Figure() + # Check if editing or adding new + if is_edit and st.session_state.vent_inf_editor.get("edit_id"): + # Update existing system + edit_id = st.session_state.vent_inf_editor["edit_id"] + vent_inf_systems = st.session_state.project_data["internal_loads"]["ventilation_infiltration"] - for load_type in LOAD_TYPES: - if load_type in hourly_data: - fig.add_trace(go.Scatter( - x=list(range(24)), - y=hourly_data[load_type], - mode='lines', - name=load_type, - stackgroup='one', - line=dict(width=0.5) - )) + for i, system in enumerate(vent_inf_systems): + if system.get("id") == edit_id: + vent_inf_systems[i] = vent_inf_data + break - fig.update_layout( - title=f"{day_type} Hourly Load Profile", - xaxis_title="Hour", - yaxis_title="Load (W)", - legend_title="Load Type", - height=400 - ) - - st.plotly_chart(fig, use_container_width=True) + st.success(f"{system_type} system '{name}' updated successfully!") else: - st.info("No load profiles available. Add loads to see hourly profiles.") - else: - st.info("No internal loads defined yet. Add loads in the tabs above to see a summary.") + # Add new system + st.session_state.project_data["internal_loads"]["ventilation_infiltration"].append(vent_inf_data) + st.success(f"{system_type} system '{name}' added successfully!") + + # Clear editor state + st.session_state.vent_inf_editor = {} + st.session_state.internal_loads_rerun_pending = True + st.rerun() + + if cancel: + # Clear editor state + st.session_state.vent_inf_editor = {} + st.session_state.internal_loads_rerun_pending = True + st.rerun() -def calculate_hourly_profiles(day_type: str) -> Dict[str, List[float]]: - """ - Calculate hourly load profiles for each load type. +def display_schedules_tab(): + """Display the schedules tab content with comprehensive schedule management.""" + # Split the display into two columns + col1, col2 = st.columns([3, 2]) - Args: - day_type: Day type ("Weekday" or "Weekend") + with col1: + st.subheader("Saved Schedules") - Returns: - Dictionary of load type to hourly values - """ - hourly_data = {} + # Get schedules from session state + schedules = st.session_state.project_data["internal_loads"]["schedules"] + + if schedules: + display_schedules_table(schedules) + else: + st.write("No schedules available.") - for load_type in LOAD_TYPES: - load_key = load_type.lower() - loads = st.session_state.project_data["internal_loads"].get(load_key, []) + with col2: + st.subheader("Schedule Editor/Creator") - if loads: - hourly_values = [0.0] * 24 + # Check if we have an editor state for schedules + if "schedule_editor" not in st.session_state: + st.session_state.schedule_editor = {} + + # Display the schedule editor form + with st.form("schedule_editor_form", clear_on_submit=True): + editor_state = st.session_state.get("schedule_editor", {}) + is_edit = editor_state.get("is_edit", False) + + # Schedule name + name = st.text_input( + "Schedule Name", + value=editor_state.get("name", ""), + help="Enter a unique name for this schedule." + ) + + # Description + description = st.text_area( + "Description", + value=editor_state.get("description", ""), + help="Brief description of this schedule." + ) - for load in loads: - # Get schedule values - schedule_values = [] + # Template selection for new schedules + if not is_edit: + template = st.selectbox( + "Start from Template", + ["Custom"] + list(DEFAULT_SCHEDULE_TEMPLATES.keys()), + help="Select a template to start with, or choose Custom for blank schedule." + ) + else: + template = "Custom" + + # Schedule values + st.write("**Hourly Schedule Values (0.0 - 1.0):**") + + # Initialize schedule values + if template != "Custom" and not is_edit: + weekday_values = DEFAULT_SCHEDULE_TEMPLATES[template]["weekday"] + weekend_values = DEFAULT_SCHEDULE_TEMPLATES[template]["weekend"] + else: + weekday_values = editor_state.get("weekday", [0.0] * 24) + weekend_values = editor_state.get("weekend", [0.0] * 24) + + # Weekday schedule + st.write("**Weekday Schedule:**") + weekday_cols = st.columns(6) + weekday_schedule = [] + + for hour in range(24): + col_idx = hour % 6 + with weekday_cols[col_idx]: + value = st.number_input( + f"H{hour:02d}", + min_value=0.0, + max_value=1.0, + value=float(weekday_values[hour]), + format="%.2f", + key=f"weekday_{hour}", + help=f"Hour {hour}:00 weekday value" + ) + weekday_schedule.append(value) + + # Weekend schedule + st.write("**Weekend Schedule:**") + weekend_cols = st.columns(6) + weekend_schedule = [] + + for hour in range(24): + col_idx = hour % 6 + with weekend_cols[col_idx]: + value = st.number_input( + f"H{hour:02d}", + min_value=0.0, + max_value=1.0, + value=float(weekend_values[hour]), + format="%.2f", + key=f"weekend_{hour}", + help=f"Hour {hour}:00 weekend value" + ) + weekend_schedule.append(value) + + # Submit buttons + col1, col2 = st.columns(2) + with col1: + submit_label = "Update Schedule" if is_edit else "Add Schedule" + submit = st.form_submit_button(submit_label) + + with col2: + cancel = st.form_submit_button("Cancel") + + # Handle form submission + if submit: + # Validate inputs + if not name.strip(): + st.error("Schedule name is required.") + elif name in schedules and not is_edit: + st.error("A schedule with this name already exists.") + else: + # Create schedule data + schedule_data = { + "description": description, + "weekday": weekday_schedule, + "weekend": weekend_schedule + } - if load["schedule_type"] == "Custom" and "custom_schedule" in load: - schedule_values = load["custom_schedule"].get(day_type, [0.0] * 24) - elif load["schedule_type"] in DEFAULT_SCHEDULES: - schedule_values = DEFAULT_SCHEDULES[load["schedule_type"]].get(day_type, [0.0] * 24) + # Check if editing or adding new + if is_edit and st.session_state.schedule_editor.get("original_name"): + # Update existing schedule + original_name = st.session_state.schedule_editor["original_name"] + + # If name changed, remove old and add new + if original_name != name and original_name in schedules: + del schedules[original_name] + + schedules[name] = schedule_data + st.success(f"Schedule '{name}' updated successfully!") else: - schedule_values = [1.0] * 24 + # Add new schedule + schedules[name] = schedule_data + st.success(f"Schedule '{name}' added successfully!") - # Apply schedule to load - for hour in range(24): - hourly_values[hour] += load["total"] * schedule_values[hour] - - hourly_data[load_type] = hourly_values - - return hourly_data + # Clear editor state + st.session_state.schedule_editor = {} + st.session_state.internal_loads_rerun_pending = True + st.rerun() + + if cancel: + # Clear editor state + st.session_state.schedule_editor = {} + st.session_state.internal_loads_rerun_pending = True + st.rerun() -def get_building_area() -> float: - """ - Get the total floor area from building information. +def display_people_table(people_groups: List[Dict[str, Any]]): + """Display people groups in a table format with edit/delete buttons.""" + if not people_groups: + return - Returns: - Total floor area in m² - """ - if "building_info" in st.session_state.project_data and "floor_area" in st.session_state.project_data["building_info"]: - return st.session_state.project_data["building_info"]["floor_area"] - return 100.0 # Default value - -def get_building_type() -> str: - """ - Get the building type from building information. + # Create table data + table_data = [] + for group in people_groups: + table_data.append({ + "Name": group.get("name", "Unknown"), + "Number": group.get("num_people", 0), + "Activity": group.get("activity_level", "Unknown"), + "Sensible (W)": f"{group.get('total_sensible_heat', 0):.1f}", + "Latent (W)": f"{group.get('total_latent_heat', 0):.1f}", + "Zone": group.get("zone", "Unknown"), + "Schedule": group.get("schedule", "Unknown"), + "Actions": group.get("id", "") + }) - Returns: - Building type - """ - if "building_info" in st.session_state.project_data and "building_type" in st.session_state.project_data["building_info"]: - return st.session_state.project_data["building_info"]["building_type"] - return "Other" # Default value + if table_data: + df = pd.DataFrame(table_data) + + # Display table + st.dataframe(df.drop('Actions', axis=1), use_container_width=True) + + # Display action buttons + st.write("**Actions:**") + for i, row in enumerate(table_data): + group_id = row["Actions"] + group_name = row["Name"] + + col1, col2, col3 = st.columns([2, 1, 1]) + + with col1: + st.write(f"{i+1}. {group_name}") + + with col2: + if st.button("Edit", key=f"edit_people_{group_id}_{i}"): + # Set up editor for editing + group = next((g for g in people_groups if g.get("id") == group_id), None) + if group: + st.session_state.people_editor = { + "is_edit": True, + "edit_id": group_id, + "name": group.get("name", ""), + "num_people": group.get("num_people", 10), + "activity_level": group.get("activity_level", list(PEOPLE_ACTIVITY_LEVELS.keys())[0]), + "clo_summer": group.get("clo_summer", 0.5), + "clo_winter": group.get("clo_winter", 1.0), + "schedule": group.get("schedule", "Continuous"), + "zone": group.get("zone", "Whole Building") + } + st.session_state.internal_loads_rerun_pending = True + st.rerun() + + with col3: + if st.button("Delete", key=f"delete_people_{group_id}_{i}"): + # Delete people group + st.session_state.project_data["internal_loads"]["people"] = [ + g for g in people_groups if g.get("id") != group_id + ] + st.success(f"People group '{group_name}' deleted!") + st.session_state.internal_loads_rerun_pending = True + st.rerun() -def validate_load( - load_type: str, name: str, zone: str, area: float, density: float, total: float, - sensible_fraction: float, latent_fraction: float, radiative_fraction: float, convective_fraction: float, - schedule_type: str, custom_schedule: Optional[Dict[str, List[float]]], edit_mode: bool, original_id: str -) -> List[str]: - """ - Validate load inputs. +def display_lighting_table(lighting_systems: List[Dict[str, Any]]): + """Display lighting systems in a table format with edit/delete buttons.""" + if not lighting_systems: + return - Args: - load_type: Type of load - name: Load name - zone: Zone name - area: Floor area - density: Power density - total: Total power - sensible_fraction: Sensible heat fraction - latent_fraction: Latent heat fraction - radiative_fraction: Radiative heat fraction - convective_fraction: Convective heat fraction - schedule_type: Schedule type - custom_schedule: Custom schedule data - edit_mode: Whether in edit mode - original_id: Original ID if in edit mode - - Returns: - List of validation error messages, empty if all inputs are valid - """ - errors = [] + # Create table data + table_data = [] + for system in lighting_systems: + table_data.append({ + "Name": system.get("name", "Unknown"), + "Area (m²)": f"{system.get('area', 0):.1f}", + "LPD (W/m²)": f"{system.get('lpd', 0):.2f}", + "Total Power (W)": f"{system.get('total_power', 0):.1f}", + "Zone": system.get("zone", "Unknown"), + "Schedule": system.get("schedule", "Unknown"), + "Actions": system.get("id", "") + }) - # Validate name - if not name or name.strip() == "": - errors.append("Load name is required.") + if table_data: + df = pd.DataFrame(table_data) + + # Display table + st.dataframe(df.drop('Actions', axis=1), use_container_width=True) + + # Display action buttons + st.write("**Actions:**") + for i, row in enumerate(table_data): + system_id = row["Actions"] + system_name = row["Name"] + + col1, col2, col3 = st.columns([2, 1, 1]) + + with col1: + st.write(f"{i+1}. {system_name}") + + with col2: + if st.button("Edit", key=f"edit_lighting_{system_id}_{i}"): + # Set up editor for editing + system = next((s for s in lighting_systems if s.get("id") == system_id), None) + if system: + st.session_state.lighting_editor = { + "is_edit": True, + "edit_id": system_id, + "name": system.get("name", ""), + "area": system.get("area", 100.0), + "lpd": system.get("lpd", 12.0), + "radiative_fraction": system.get("radiative_fraction", 0.4), + "convective_fraction": system.get("convective_fraction", 0.6), + "schedule": system.get("schedule", "Continuous"), + "zone": system.get("zone", "Whole Building") + } + st.session_state.internal_loads_rerun_pending = True + st.rerun() + + with col3: + if st.button("Delete", key=f"delete_lighting_{system_id}_{i}"): + # Delete lighting system + st.session_state.project_data["internal_loads"]["lighting"] = [ + s for s in lighting_systems if s.get("id") != system_id + ] + st.success(f"Lighting system '{system_name}' deleted!") + st.session_state.internal_loads_rerun_pending = True + st.rerun() + +def display_equipment_table(equipment_systems: List[Dict[str, Any]]): + """Display equipment systems in a table format with edit/delete buttons.""" + if not equipment_systems: + return - # Check for name uniqueness within the same load type - load_key = load_type.lower() - loads = st.session_state.project_data["internal_loads"].get(load_key, []) + # Create table data + table_data = [] + for system in equipment_systems: + table_data.append({ + "Name": system.get("name", "Unknown"), + "Area (m²)": f"{system.get('area', 0):.1f}", + "Sensible (W)": f"{system.get('total_sensible_power', 0):.1f}", + "Latent (W)": f"{system.get('total_latent_power', 0):.1f}", + "Zone": system.get("zone", "Unknown"), + "Schedule": system.get("schedule", "Unknown"), + "Actions": system.get("id", "") + }) - for load in loads: - if load["name"] == name and (not edit_mode or load["id"] != original_id): - errors.append(f"{load_type} Load name '{name}' already exists.") - break + if table_data: + df = pd.DataFrame(table_data) + + # Display table + st.dataframe(df.drop('Actions', axis=1), use_container_width=True) + + # Display action buttons + st.write("**Actions:**") + for i, row in enumerate(table_data): + system_id = row["Actions"] + system_name = row["Name"] + + col1, col2, col3 = st.columns([2, 1, 1]) + + with col1: + st.write(f"{i+1}. {system_name}") + + with col2: + if st.button("Edit", key=f"edit_equipment_{system_id}_{i}"): + # Set up editor for editing + system = next((s for s in equipment_systems if s.get("id") == system_id), None) + if system: + st.session_state.equipment_editor = { + "is_edit": True, + "edit_id": system_id, + "name": system.get("name", ""), + "area": system.get("area", 100.0), + "sensible_gain": system.get("sensible_gain", 10.0), + "latent_gain": system.get("latent_gain", 2.0), + "radiative_fraction": system.get("radiative_fraction", 0.5), + "convective_fraction": system.get("convective_fraction", 0.5), + "schedule": system.get("schedule", "Continuous"), + "zone": system.get("zone", "Whole Building") + } + st.session_state.internal_loads_rerun_pending = True + st.rerun() + + with col3: + if st.button("Delete", key=f"delete_equipment_{system_id}_{i}"): + # Delete equipment system + st.session_state.project_data["internal_loads"]["equipment"] = [ + s for s in equipment_systems if s.get("id") != system_id + ] + st.success(f"Equipment '{system_name}' deleted!") + st.session_state.internal_loads_rerun_pending = True + st.rerun() + +def display_ventilation_infiltration_table(vent_inf_systems: List[Dict[str, Any]]): + """Display ventilation/infiltration systems in a table format with edit/delete buttons.""" + if not vent_inf_systems: + return - # Validate zone - if not zone: - errors.append("Zone selection is required.") + # Create table data + table_data = [] + for system in vent_inf_systems: + if system.get("system_type") == "Ventilation": + rate_info = f"{system.get('ventilation_rate', 0):.2f} L/s·m²" + else: + rate_info = f"{system.get('air_change_rate', 0):.2f} ACH" + + table_data.append({ + "Name": system.get("name", "Unknown"), + "Type": system.get("system_type", "Unknown"), + "Area (m²)": f"{system.get('area', 0):.1f}", + "Rate": rate_info, + "Zone": system.get("zone", "Unknown"), + "Schedule": system.get("schedule", "Unknown"), + "Actions": system.get("id", "") + }) - # Validate area - if area <= 0: - errors.append("Area must be greater than zero.") + if table_data: + df = pd.DataFrame(table_data) + + # Display table + st.dataframe(df.drop('Actions', axis=1), use_container_width=True) + + # Display action buttons + st.write("**Actions:**") + for i, row in enumerate(table_data): + system_id = row["Actions"] + system_name = row["Name"] + + col1, col2, col3 = st.columns([2, 1, 1]) + + with col1: + st.write(f"{i+1}. {system_name}") + + with col2: + if st.button("Edit", key=f"edit_vent_inf_{system_id}_{i}"): + # Set up editor for editing + system = next((s for s in vent_inf_systems if s.get("id") == system_id), None) + if system: + st.session_state.vent_inf_editor = { + "is_edit": True, + "edit_id": system_id, + "name": system.get("name", ""), + "system_type": system.get("system_type", "Ventilation"), + "area": system.get("area", 100.0), + "ventilation_rate": system.get("ventilation_rate", 10.0), + "air_change_rate": system.get("air_change_rate", 1.0), + "schedule": system.get("schedule", "Continuous"), + "zone": system.get("zone", "Whole Building") + } + st.session_state.internal_loads_rerun_pending = True + st.rerun() + + with col3: + if st.button("Delete", key=f"delete_vent_inf_{system_id}_{i}"): + # Delete ventilation/infiltration system + st.session_state.project_data["internal_loads"]["ventilation_infiltration"] = [ + s for s in vent_inf_systems if s.get("id") != system_id + ] + st.success(f"{system.get('system_type', 'System')} '{system_name}' deleted!") + st.session_state.internal_loads_rerun_pending = True + st.rerun() + +def display_schedules_table(schedules: Dict[str, Any]): + """Display schedules in a table format with edit/delete buttons.""" + if not schedules: + return - # Validate density and total - if density <= 0 and total <= 0: - errors.append("Either density or total load must be greater than zero.") + # Create table data + table_data = [] + for name, schedule in schedules.items(): + weekday_peak = max(schedule.get("weekday", [0])) + weekend_peak = max(schedule.get("weekend", [0])) + + table_data.append({ + "Name": name, + "Description": schedule.get("description", "No description"), + "Weekday Peak": f"{weekday_peak:.2f}", + "Weekend Peak": f"{weekend_peak:.2f}", + "Actions": name + }) - # Validate fractions - if load_type == "Occupancy": - if abs(sensible_fraction + latent_fraction - 1.0) > 0.01: - errors.append("Sensible and latent fractions should sum to 1.0.") - else: - if abs(radiative_fraction + convective_fraction - 1.0) > 0.01: - errors.append("Radiative and convective fractions should sum to 1.0.") + if table_data: + df = pd.DataFrame(table_data) + + # Display table + st.dataframe(df.drop('Actions', axis=1), use_container_width=True) + + # Display action buttons + st.write("**Actions:**") + for i, row in enumerate(table_data): + schedule_name = row["Actions"] + + col1, col2, col3, col4 = st.columns([2, 1, 1, 1]) + + with col1: + st.write(f"{i+1}. {schedule_name}") + + with col2: + if st.button("View", key=f"view_schedule_{schedule_name}_{i}"): + # Display schedule chart + schedule_data = schedules[schedule_name] + display_schedule_chart(schedule_name, schedule_data) + + with col3: + if st.button("Edit", key=f"edit_schedule_{schedule_name}_{i}"): + # Set up editor for editing + schedule_data = schedules[schedule_name] + st.session_state.schedule_editor = { + "is_edit": True, + "original_name": schedule_name, + "name": schedule_name, + "description": schedule_data.get("description", ""), + "weekday": schedule_data.get("weekday", [0.0] * 24), + "weekend": schedule_data.get("weekend", [0.0] * 24) + } + st.session_state.internal_loads_rerun_pending = True + st.rerun() + + with col4: + # Don't allow deletion of default templates + if schedule_name not in DEFAULT_SCHEDULE_TEMPLATES: + if st.button("Delete", key=f"delete_schedule_{schedule_name}_{i}"): + # Delete schedule + del schedules[schedule_name] + st.success(f"Schedule '{schedule_name}' deleted!") + st.session_state.internal_loads_rerun_pending = True + st.rerun() + else: + st.write("(Default)") + +def display_schedule_chart(schedule_name: str, schedule_data: Dict[str, Any]): + """Display a chart for the given schedule.""" + hours = list(range(24)) + weekday_values = schedule_data.get("weekday", [0.0] * 24) + weekend_values = schedule_data.get("weekend", [0.0] * 24) - # Validate schedule - if schedule_type == "Custom": - if not custom_schedule: - errors.append("Custom schedule data is missing.") - else: - for day_type in DAYS_OF_WEEK: - if day_type not in custom_schedule or len(custom_schedule[day_type]) != 24: - errors.append(f"Custom schedule for {day_type} must have 24 hourly values.") + fig = go.Figure() - return errors - -def reset_load_editor(load_type: str): - """ - Reset the load editor to default values for the given type. + fig.add_trace(go.Scatter( + x=hours, + y=weekday_values, + mode='lines+markers', + name='Weekday', + line=dict(color='blue') + )) - Args: - load_type: The type of load - """ - # Get building information - building_area = get_building_area() - building_type = get_building_type() + fig.add_trace(go.Scatter( + x=hours, + y=weekend_values, + mode='lines+markers', + name='Weekend', + line=dict(color='red') + )) - # Set default density based on building type - default_density = 0.0 - if load_type == "Occupancy": - # For occupancy, we need to convert from m²/person to W/m² - people_density = 1.0 / DEFAULT_OCCUPANCY_DENSITIES.get(building_type, 10.0) - default_density = people_density * 115.0 # 115W per person (sensible + latent) - elif load_type == "Lighting": - default_density = DEFAULT_LIGHTING_DENSITIES.get(building_type, 12.0) - elif load_type == "Equipment": - default_density = DEFAULT_EQUIPMENT_DENSITIES.get(building_type, 15.0) - else: # Other - default_density = 5.0 + fig.update_layout( + title=f"Schedule: {schedule_name}", + xaxis_title="Hour of Day", + yaxis_title="Fraction (0.0 - 1.0)", + xaxis=dict(tickmode='linear', tick0=0, dtick=2), + yaxis=dict(range=[0, 1.1]), + height=400 + ) - st.session_state.load_editor = { - "type": load_type, - "name": "", - "zone": "Whole Building", - "area": building_area, - "density": default_density, - "total": default_density * building_area, - "sensible_fraction": 0.7, - "latent_fraction": 0.3, - "radiative_fraction": 0.4, - "convective_fraction": 0.6, - "schedule_type": SCHEDULE_TYPES[0], - "custom_schedule": { - "Weekday": [0.0] * 24, - "Weekend": [0.0] * 24 - }, - "edit_mode": False, - "original_id": "" - } + st.plotly_chart(fig, use_container_width=True) def display_internal_loads_help(): - """ - Display help information for the internal loads page. - """ + """Display help information for the internal loads page.""" st.markdown(""" ### Internal Loads Help - This section allows you to define the internal heat gains in your building from occupants, lighting, equipment, and other sources. - - **Key Concepts:** - - * **Internal Loads**: Heat gains inside the building that contribute to cooling loads and reduce heating loads. - * **Occupancy Loads**: Heat generated by people, including both sensible heat (affects air temperature) and latent heat (affects humidity). - * **Lighting Loads**: Heat generated by lighting fixtures, primarily as a combination of radiative and convective heat. - * **Equipment Loads**: Heat generated by appliances, computers, and other electrical equipment. - * **Schedules**: Time-based patterns that define when loads are active throughout the day. + This page allows you to define internal heat gains from various sources within the building. - **Load Properties:** + #### People + - Define occupancy groups with different activity levels + - Activity levels determine metabolic heat generation + - Clothing insulation affects comfort calculations + - Schedules control when people are present - * **Density**: Power per unit area (W/m²). - * **Total**: Total power for the load (W). - * **Sensible Heat**: Heat that directly affects air temperature. - * **Latent Heat**: Heat that affects humidity (moisture in the air). - * **Radiative Heat**: Heat transferred by radiation to surfaces. - * **Convective Heat**: Heat transferred directly to the air. + #### Lighting + - Define lighting systems with power densities + - Heat distribution between radiative and convective components + - Schedules control lighting operation - **Schedules:** + #### Equipment + - Define equipment heat gains (computers, appliances, etc.) + - Separate sensible and latent heat gains + - Heat distribution between radiative and convective components - * **Continuous**: Load is constant throughout the day. - * **Day/Night**: Load varies between day and night hours. - * **Custom**: Define your own hourly schedule for weekdays and weekends. + #### Ventilation & Infiltration + - **Ventilation**: Controlled outdoor air introduction + - **Infiltration**: Uncontrolled air leakage + - Rates based on building type and design standards - **Workflow:** + #### Schedules + - Define hourly operation profiles (0.0 = off, 1.0 = full operation) + - Separate weekday and weekend schedules + - Templates available for common building types + - Custom schedules can be created for specific needs - 1. Select the tab for the load type you want to define (e.g., "Occupancy"). - 2. Use the editor to add new loads: - * Give the load a unique name. - * Select the zone it applies to. - * Enter the area and either the density or total load. - * Set the appropriate heat fractions. - * Choose or define a schedule. - 3. Save the load. - 4. Repeat for all internal load sources. - 5. Review the summary to see the total internal loads and hourly profiles. - - **Important:** - - * Internal loads are a significant factor in cooling load calculations. - * Accurate schedules are essential for proper load calculations. - * The summary section shows the combined effect of all internal loads. + #### Usage Tips + 1. Start with building type defaults and adjust as needed + 2. Create realistic schedules based on actual building operation + 3. Consider diversity factors for large buildings + 4. Validate total loads against design expectations """) -BUILDING_TYPES = list(DEFAULT_OCCUPANCY_DENSITIES.keys()) - +# Helper functions for backward compatibility +def get_internal_loads_summary(): + """Get a summary of all internal loads for use in other modules.""" + summary = { + "people": { + "total_sensible": 0.0, + "total_latent": 0.0, + "count": 0 + }, + "lighting": { + "total_power": 0.0, + "count": 0 + }, + "equipment": { + "total_sensible": 0.0, + "total_latent": 0.0, + "count": 0 + }, + "ventilation_infiltration": { + "total_ventilation": 0.0, + "total_infiltration": 0.0, + "count": 0 + } + } + + if "internal_loads" in st.session_state.project_data: + internal_loads = st.session_state.project_data["internal_loads"] + + # People summary + for group in internal_loads.get("people", []): + summary["people"]["total_sensible"] += group.get("total_sensible_heat", 0.0) + summary["people"]["total_latent"] += group.get("total_latent_heat", 0.0) + summary["people"]["count"] += 1 + + # Lighting summary + for system in internal_loads.get("lighting", []): + summary["lighting"]["total_power"] += system.get("total_power", 0.0) + summary["lighting"]["count"] += 1 + + # Equipment summary + for system in internal_loads.get("equipment", []): + summary["equipment"]["total_sensible"] += system.get("total_sensible_power", 0.0) + summary["equipment"]["total_latent"] += system.get("total_latent_power", 0.0) + summary["equipment"]["count"] += 1 + + # Ventilation/Infiltration summary + for system in internal_loads.get("ventilation_infiltration", []): + if system.get("system_type") == "Ventilation": + summary["ventilation_infiltration"]["total_ventilation"] += system.get("ventilation_rate", 0.0) * system.get("area", 0.0) + else: + summary["ventilation_infiltration"]["total_infiltration"] += system.get("air_change_rate", 0.0) + summary["ventilation_infiltration"]["count"] += 1 + + return summary \ No newline at end of file