mabuseif commited on
Commit
f1d9964
·
verified ·
1 Parent(s): 932295a

Update utils/heating_load.py

Browse files
Files changed (1) hide show
  1. utils/heating_load.py +413 -423
utils/heating_load.py CHANGED
@@ -1,518 +1,508 @@
1
  """
2
- Heating load calculation module for HVAC Load Calculator.
3
- Implements ASHRAE steady-state methods with optional thermal lag for energy analysis.
 
4
  """
5
 
6
- from typing import Dict, List, Any, Optional, Tuple
7
  import math
8
  import numpy as np
9
- from enum import Enum
10
- from dataclasses import dataclass
11
-
12
- # Import utility modules
13
  from utils.psychrometrics import Psychrometrics
14
- from utils.heat_transfer import HeatTransferCalculations
15
-
16
- # Import data modules
17
- from data.building_components import Wall, Roof, Floor, Window, Door, Orientation, ComponentType
18
 
19
- # Safely import streamlit for debug mode
20
- try:
21
- import streamlit as st
22
- except ImportError:
23
- st = None
24
 
25
- class HeatingLoadCalculator:
26
- """Class for heating load calculations based on ASHRAE steady-state methods."""
27
 
28
  def __init__(self):
29
- """Initialize heating load calculator with psychrometric and heat transfer calculations."""
30
- self.psychrometrics = Psychrometrics()
31
- self.heat_transfer = HeatTransferCalculations()
32
- self.safety_factor = 1.15 # 15% safety factor for design loads
33
- self.time_step = 24.0 # Daily time step for thermal lag in hours
34
-
35
- def validate_inputs(self, components: Dict[str, List[Any]], outdoor_temp: float, indoor_temp: float) -> None:
36
  """
37
- Validate input parameters for heating load calculations.
38
 
39
  Args:
40
- components: Dictionary of building components
41
- outdoor_temp: Outdoor design temperature in °C
42
- indoor_temp: Indoor design temperature in °C
 
43
 
44
  Raises:
45
- ValueError: If inputs are invalid
46
- """
47
- if not components:
48
- raise ValueError("Building components dictionary cannot be empty")
49
- for component_type, comp_list in components.items():
50
- if not isinstance(comp_list, list):
51
- raise ValueError(f"Components for {component_type} must be a list")
52
- for comp in comp_list:
53
- if not hasattr(comp, 'area') or comp.area <= 0:
54
- raise ValueError(f"Invalid area for {component_type}: {comp.name}")
55
- if not hasattr(comp, 'u_value') or comp.u_value <= 0:
56
- raise ValueError(f"Invalid U-value for {component_type}: {comp.name}")
57
- if not -50 <= outdoor_temp <= 60 or not -50 <= indoor_temp <= 60:
58
- raise ValueError("Temperatures must be between -50°C and 60°C")
59
- if indoor_temp - outdoor_temp < 1:
60
- raise ValueError("Indoor temperature must be at least 1°C above outdoor temperature for heating")
61
-
62
- def calculate_wall_heating_load(self, wall: Wall, outdoor_temp: float, indoor_temp: float, apply_thermal_lag: bool = False) -> float:
63
  """
64
- Calculate heating load for a wall, with optional thermal lag for energy analysis.
65
 
66
  Args:
67
- wall: Wall component
68
- outdoor_temp: Outdoor temperature in °C
69
- indoor_temp: Indoor temperature in °C
70
- apply_thermal_lag: Apply thermal lag for transient calculations
71
 
72
  Returns:
73
- Heating load in W
74
- """
75
- delta_t = indoor_temp - outdoor_temp
76
- if delta_t <= 1:
77
- return 0.0
78
-
79
- lag_factor = 1.0
80
- if apply_thermal_lag and wall.material_layers:
81
- # Calculate total thermal mass (J/m²·K)
82
- total_thermal_mass = sum(layer.thermal_mass for layer in wall.material_layers if layer.thermal_mass is not None)
83
- if total_thermal_mass:
84
- # Thermal mass per component (J/K)
85
- component_thermal_mass = total_thermal_mass * wall.area
86
- # Time constant: Assume R-value-based estimation (h)
87
- total_r = wall.total_r_value_from_layers or wall.r_value
88
- time_constant = total_thermal_mass * total_r / 3600 # Convert J/m²·K * m²·K/W to hours
89
- lag_factor = self.heat_transfer.thermal_lag_factor(component_thermal_mass, time_constant, self.time_step)
90
-
91
- adjusted_delta_t = delta_t * lag_factor
92
- load = self.heat_transfer.conduction_heat_transfer(wall.u_value, wall.area, adjusted_delta_t)
93
- return max(0, load)
94
-
95
- def calculate_roof_heating_load(self, roof: Roof, outdoor_temp: float, indoor_temp: float, apply_thermal_lag: bool = False) -> float:
96
  """
97
- Calculate heating load for a roof, with optional thermal lag for energy analysis.
 
 
 
 
 
 
 
 
 
 
 
 
98
 
99
  Args:
100
- roof: Roof component
101
- outdoor_temp: Outdoor temperature in °C
102
- indoor_temp: Indoor temperature in °C
103
- apply_thermal_lag: Apply thermal lag for transient calculations
104
 
105
  Returns:
106
- Heating load in W
107
- """
108
- delta_t = indoor_temp - outdoor_temp
109
- if delta_t <= 1:
110
- return 0.0
111
-
112
- lag_factor = 1.0
113
- if apply_thermal_lag and roof.material_layers:
114
- total_thermal_mass = sum(layer.thermal_mass for layer in roof.material_layers if layer.thermal_mass is not None)
115
- if total_thermal_mass:
116
- component_thermal_mass = total_thermal_mass * roof.area
117
- total_r = roof.total_r_value_from_layers or roof.r_value
118
- time_constant = total_thermal_mass * total_r / 3600
119
- lag_factor = self.heat_transfer.thermal_lag_factor(component_thermal_mass, time_constant, self.time_step)
120
-
121
- adjusted_delta_t = delta_t * lag_factor
122
- load = self.heat_transfer.conduction_heat_transfer(roof.u_value, roof.area, adjusted_delta_t)
123
- return max(0, load)
124
-
125
- def calculate_floor_heating_load(self, floor: Floor, ground_temp: float, indoor_temp: float) -> float:
126
  """
127
- Calculate heating load for a floor, using dynamic F-factor for ground contact.
 
 
 
 
 
 
128
 
129
  Args:
130
- floor: Floor component
131
- ground_temp: Ground temperature in °C
132
- indoor_temp: Indoor temperature in °C
133
 
134
  Returns:
135
- Heating load in W
136
- """
137
- delta_t = indoor_temp - ground_temp
138
- if delta_t <= 1:
139
- return 0.0
140
-
141
- if floor.is_ground_contact:
142
- # Infer insulation from material layers
143
- f_factor = 0.3 if (floor.total_r_value_from_layers and floor.total_r_value_from_layers > 2.0) else 0.73 # W/m·K
144
- load = f_factor * floor.perimeter_length * delta_t
145
- else:
146
- load = self.heat_transfer.conduction_heat_transfer(floor.u_value, floor.area, delta_t)
147
-
148
- debug_mode = False
149
- if st is not None and hasattr(st, 'session_state') and hasattr(st.session_state, 'debug_mode'):
150
- debug_mode = st.session_state.debug_mode
151
- if debug_mode:
152
- print(f"Debug: Floor {floor.name} load: {load:.2f} W, Delta T: {delta_t:.2f}°C, F-factor: {f_factor:.2f}")
153
-
154
- return max(0, load)
155
-
156
- def calculate_window_heating_load(self, window: Window, outdoor_temp: float, indoor_temp: float) -> float:
157
  """
158
- Calculate heating load for a window.
159
 
160
  Args:
161
- window: Window component
162
- outdoor_temp: Outdoor temperature in °C
163
- indoor_temp: Indoor temperature in °C
 
164
 
165
  Returns:
166
- Heating load in W
167
  """
168
- delta_t = indoor_temp - outdoor_temp
169
- if delta_t <= 1:
170
- return 0.0
171
-
172
- # Use effective U-value with drapery if applicable
173
- u_value = window.get_effective_u_value()
174
- load = self.heat_transfer.conduction_heat_transfer(u_value, window.area, delta_t)
175
- return max(0, load)
176
-
177
- def calculate_door_heating_load(self, door: Door, outdoor_temp: float, indoor_temp: float) -> float:
 
 
 
 
 
 
 
 
 
 
178
  """
179
- Calculate heating load for a door.
180
 
181
  Args:
182
- door: Door component
183
- outdoor_temp: Outdoor temperature in °C
184
- indoor_temp: Indoor temperature in °C
 
185
 
186
  Returns:
187
- Heating load in W
188
  """
189
- delta_t = indoor_temp - outdoor_temp
190
- if delta_t <= 1:
191
- return 0.0
192
-
193
- load = self.heat_transfer.conduction_heat_transfer(door.u_value, door.area, delta_t)
194
- return max(0, load)
195
-
196
- def calculate_infiltration_heating_load(self, indoor_conditions: Dict[str, float],
197
- outdoor_conditions: Dict[str, float],
198
- infiltration: Dict[str, float],
199
- building_height: float) -> Tuple[float, float]:
 
 
 
 
200
  """
201
- Calculate sensible and latent heating loads due to infiltration.
202
 
203
  Args:
204
- indoor_conditions: Indoor conditions (temperature, relative_humidity)
205
- outdoor_conditions: Outdoor conditions (design_temperature, design_relative_humidity, wind_speed)
206
- infiltration: Infiltration parameters (flow_rate, crack_length, height)
207
- building_height: Building height in m
208
 
209
  Returns:
210
- Tuple of sensible and latent loads in W
211
- """
212
- delta_t = indoor_conditions['temperature'] - outdoor_conditions['design_temperature']
213
- if delta_t <= 1:
214
- return 0.0, 0.0
215
-
216
- # Calculate pressure differences
217
- wind_pd = self.heat_transfer.wind_pressure_difference(outdoor_conditions['wind_speed'])
218
- stack_pd = self.heat_transfer.stack_pressure_difference(
219
- building_height,
220
- indoor_conditions['temperature'] + 273.15,
221
- outdoor_conditions['design_temperature'] + 273.15
222
- )
223
- total_pd = self.heat_transfer.combined_pressure_difference(wind_pd, stack_pd)
224
-
225
- # Calculate infiltration flow rate with adjusted coefficient
226
- crack_length = infiltration.get('crack_length', 20.0)
227
- flow_rate = self.heat_transfer.crack_method_infiltration(crack_length, 0.00031, total_pd)
228
-
229
- # Calculate humidity ratio difference
230
- w_indoor = self.psychrometrics.humidity_ratio(
231
- indoor_conditions['temperature'],
232
- indoor_conditions['relative_humidity']
233
- )
234
- w_outdoor = self.psychrometrics.humidity_ratio(
235
- outdoor_conditions['design_temperature'],
236
- outdoor_conditions['design_relative_humidity']
237
- )
238
- delta_w = max(0, w_indoor - w_outdoor)
239
-
240
- # Calculate sensible and latent loads
241
- sensible_load = self.heat_transfer.infiltration_heat_transfer(flow_rate, delta_t)
242
- latent_load = self.heat_transfer.infiltration_latent_heat_transfer(flow_rate, delta_w)
243
-
244
- debug_mode = False
245
- if st is not None and hasattr(st, 'session_state') and hasattr(st.session_state, 'debug_mode'):
246
- debug_mode = st.session_state.debug_mode
247
- if debug_mode:
248
- print(f"Debug: Infiltration flow rate: {flow_rate:.6f} m³/s, Sensible load: {sensible_load:.2f} W, Latent load: {latent_load:.2f} W")
249
-
250
- return max(0, sensible_load), max(0, latent_load)
251
-
252
- def calculate_ventilation_heating_load(self, ventilation: Dict[str, float],
253
- indoor_conditions: Dict[str, float],
254
- outdoor_conditions: Dict[str, float]) -> Tuple[float, float]:
255
  """
256
- Calculate sensible and latent heating loads due to ventilation.
 
 
 
 
 
 
 
 
 
257
 
258
  Args:
259
- ventilation: Ventilation parameters (flow_rate)
260
- indoor_conditions: Indoor conditions (temperature, relative_humidity)
261
- outdoor_conditions: Outdoor conditions (design_temperature, design_relative_humidity)
262
 
263
  Returns:
264
- Tuple of sensible and latent loads in W
265
  """
266
- delta_t = indoor_conditions['temperature'] - outdoor_conditions['design_temperature']
267
- if delta_t <= 1:
268
- return 0.0, 0.0
269
-
270
- flow_rate = ventilation['flow_rate']
271
-
272
- w_indoor = self.psychrometrics.humidity_ratio(
273
- indoor_conditions['temperature'],
274
- indoor_conditions['relative_humidity']
275
- )
276
- w_outdoor = self.psychrometrics.humidity_ratio(
277
- outdoor_conditions['design_temperature'],
278
- outdoor_conditions['design_relative_humidity']
279
- )
280
- delta_w = max(0, w_indoor - w_outdoor)
281
-
282
- sensible_load = self.heat_transfer.infiltration_heat_transfer(flow_rate, delta_t)
283
- latent_load = self.heat_transfer.infiltration_latent_heat_transfer(flow_rate, delta_w)
284
-
285
- return max(0, sensible_load), max(0, latent_load)
286
-
287
- def calculate_internal_gains(self, internal_loads: Dict[str, Any]) -> float:
288
  """
289
- Calculate internal heat gains from people, lighting, and equipment.
290
 
291
  Args:
292
- internal_loads: Internal loads (people, lights, equipment)
 
 
 
293
 
294
  Returns:
295
- Total internal gains in W
296
  """
297
- total_gains = 0.0
298
-
299
- # People gains
300
- people = internal_loads.get('people', {})
301
- if people.get('number', 0) > 0:
302
- sensible_gain = people.get('sensible_gain', 70.0)
303
- total_gains += people['number'] * sensible_gain
304
-
305
- # Lighting gains
306
- lights = internal_loads.get('lights', {})
307
- if lights.get('power', 0) > 0:
308
- total_gains += lights['power'] * lights.get('use_factor', 0.8)
309
-
310
- # Equipment gains
311
- equipment = internal_loads.get('equipment', {})
312
- if equipment.get('power', 0) > 0:
313
- total_gains += equipment['power'] * equipment.get('use_factor', 0.7)
314
-
315
- return max(0, total_gains)
316
 
317
- def calculate_design_heating_load(self, building_components: Dict[str, List[Any]],
318
- outdoor_conditions: Dict[str, float],
319
- indoor_conditions: Dict[str, float],
320
- internal_loads: Dict[str, Any]) -> Dict[str, float]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
321
  """
322
- Calculate design heating loads for all components.
323
 
324
  Args:
325
- building_components: Dictionary of building components
326
- outdoor_conditions: Outdoor conditions (design_temperature, design_relative_humidity, ground_temperature, wind_speed)
327
- indoor_conditions: Indoor conditions (temperature, relative_humidity)
328
- internal_loads: Internal loads (people, lights, equipment, infiltration, ventilation)
329
 
330
  Returns:
331
- Dictionary of design loads in W
332
- """
333
- try:
334
- self.validate_inputs(building_components, outdoor_conditions['design_temperature'], indoor_conditions['temperature'])
335
- except ValueError as e:
336
- raise ValueError(f"Input validation failed: {str(e)}")
337
-
338
- loads = {
339
- 'walls': 0.0,
340
- 'roofs': 0.0,
341
- 'floors': 0.0,
342
- 'windows': 0.0,
343
- 'doors': 0.0,
344
- 'infiltration_sensible': 0.0,
345
- 'infiltration_latent': 0.0,
346
- 'ventilation_sensible': 0.0,
347
- 'ventilation_latent': 0.0,
348
- 'internal_gains': 0.0
349
- }
350
-
351
- # Calculate envelope loads
352
- for wall in building_components.get('walls', []):
353
- loads['walls'] += self.calculate_wall_heating_load(wall, outdoor_conditions['design_temperature'], indoor_conditions['temperature'])
354
-
355
- for roof in building_components.get('roofs', []):
356
- loads['roofs'] += self.calculate_roof_heating_load(roof, outdoor_conditions['design_temperature'], indoor_conditions['temperature'])
357
-
358
- for floor in building_components.get('floors', []):
359
- loads['floors'] += self.calculate_floor_heating_load(floor, outdoor_conditions['ground_temperature'], indoor_conditions['temperature'])
360
-
361
- for window in building_components.get('windows', []):
362
- loads['windows'] += self.calculate_window_heating_load(window, outdoor_conditions['design_temperature'], indoor_conditions['temperature'])
363
-
364
- for door in building_components.get('doors', []):
365
- loads['doors'] += self.calculate_door_heating_load(door, outdoor_conditions['design_temperature'], indoor_conditions['temperature'])
366
-
367
- # Calculate infiltration and ventilation loads
368
- building_height = internal_loads.get('infiltration', {}).get('height', 3.0)
369
- infiltration_sensible, infiltration_latent = self.calculate_infiltration_heating_load(
370
- indoor_conditions, outdoor_conditions, internal_loads.get('infiltration', {}), building_height
371
- )
372
- loads['infiltration_sensible'] = infiltration_sensible
373
- loads['infiltration_latent'] = infiltration_latent
374
-
375
- ventilation_sensible, ventilation_latent = self.calculate_ventilation_heating_load(
376
- internal_loads.get('ventilation', {}), indoor_conditions, outdoor_conditions
377
- )
378
- loads['ventilation_sensible'] = ventilation_sensible
379
- loads['ventilation_latent'] = ventilation_latent
380
-
381
- # Calculate internal gains (negative for heating)
382
- loads['internal_gains'] = -self.calculate_internal_gains(internal_loads)
383
-
384
- return loads
385
-
386
- def calculate_heating_load_summary(self, design_loads: Dict[str, float]) -> Dict[str, float]:
387
  """
388
- Summarize heating loads with safety factor.
 
 
 
 
 
 
 
389
 
390
  Args:
391
- design_loads: Dictionary of design loads in W
 
 
392
 
393
  Returns:
394
- Summary dictionary with total, subtotal, and safety factor
395
- """
396
- subtotal = sum(
397
- load for key, load in design_loads.items()
398
- if key not in ['internal_gains'] and load > 0
399
- )
400
- internal_gains = design_loads.get('internal_gains', 0)
401
-
402
- total = max(0, subtotal + internal_gains) * self.safety_factor
403
-
404
- return {
405
- 'subtotal': subtotal,
406
- 'internal_gains': internal_gains,
407
- 'total': total,
408
- 'safety_factor': self.safety_factor
409
- }
410
-
411
- def calculate_heating_degree_days(self, base_temp: float, monthly_temps: Dict[str, float]) -> float:
412
  """
413
- Calculate heating degree days for a year.
 
 
 
 
 
 
 
414
 
415
  Args:
416
- base_temp: Base temperature for HDD calculation in °C
417
- monthly_temps: Dictionary of monthly average temperatures
 
 
418
 
419
  Returns:
420
- Total heating degree days
 
 
 
 
 
 
 
 
 
 
 
 
421
  """
422
- hdd = 0.0
423
- days_per_month = {
424
- 'Jan': 31, 'Feb': 28, 'Mar': 31, 'Apr': 30, 'May': 31, 'Jun': 30,
425
- 'Jul': 31, 'Aug': 31, 'Sep': 30, 'Oct': 31, 'Nov': 30, 'Dec': 31
426
- }
427
 
428
- for month, temp in monthly_temps.items():
429
- if temp < base_temp:
430
- hdd += (base_temp - temp) * days_per_month[month]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
431
 
432
- return hdd
433
-
434
- def calculate_annual_heating_energy(self, design_loads: Dict[str, float],
435
- monthly_temps: Dict[str, float],
436
- indoor_temp: float,
437
- operating_hours: str) -> float:
438
  """
439
- Calculate annual heating energy consumption.
 
 
 
 
 
 
 
440
 
441
  Args:
442
- design_loads: Dictionary of design loads in W
443
- monthly_temps: Dictionary of monthly average temperatures
444
- indoor_temp: Indoor design temperature in °C
445
- operating_hours: Operating hours (e.g., '8:00-18:00')
446
 
447
  Returns:
448
- Annual heating energy in kWh
 
 
 
 
 
 
 
449
  """
450
- base_temp = indoor_temp
451
- hdd = self.calculate_heating_degree_days(base_temp, monthly_temps)
452
 
453
- # Parse operating hours
454
- start_hour, end_hour = map(lambda x: int(x.split(':')[0]), operating_hours.split('-'))
455
- daily_hours = end_hour - start_hour
 
 
 
 
 
 
 
 
456
 
457
- # Calculate design condition degree days
458
- design_temp = min(monthly_temps.values())
459
- design_delta_t = indoor_temp - design_temp
460
- if design_delta_t <= 1:
461
- return 0.0
 
462
 
463
- total_load = self.calculate_heating_load_summary(design_loads)['total']
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
464
 
465
- # Scale load by HDD and operating hours
466
- annual_energy = (total_load / design_delta_t) * hdd * (daily_hours / 24) / 1000 # kWh
 
 
 
 
 
 
 
 
 
 
 
 
 
467
 
468
- return max(0, annual_energy)
469
-
470
- def calculate_monthly_heating_loads(self, building_components: Dict[str, List[Any]],
471
- outdoor_conditions: Dict[str, float],
472
- indoor_conditions: Dict[str, float],
473
- internal_loads: Dict[str, Any],
474
- monthly_temps: Dict[str, float]) -> Dict[str, float]:
 
 
 
 
 
 
 
 
 
 
 
 
 
475
  """
476
- Calculate monthly heating loads with thermal lag for walls and roofs.
477
 
478
  Args:
479
- building_components: Dictionary of building components
480
- outdoor_conditions: Outdoor conditions
481
- indoor_conditions: Indoor conditions
482
- internal_loads: Internal loads
483
- monthly_temps: Dictionary of monthly average temperatures
484
 
485
  Returns:
486
- Dictionary of monthly heating loads in kW
487
- """
488
- monthly_loads = {}
489
- days_per_month = {
490
- 'Jan': 31, 'Feb': 28, 'Mar': 31, 'Apr': 30, 'May': 31, 'Jun': 30,
491
- 'Jul': 31, 'Aug': 31, 'Sep': 30, 'Oct': 31, 'Nov': 30, 'Dec': 31
492
- }
493
-
494
- for month, temp in monthly_temps.items():
495
- modified_outdoor = outdoor_conditions.copy()
496
- modified_outdoor['design_temperature'] = temp
497
- modified_outdoor['ground_temperature'] = temp
 
 
 
 
 
 
 
 
 
 
 
 
498
 
499
- try:
500
- # Apply thermal lag for walls and roofs in monthly calculations
501
- design_loads = self.calculate_design_heating_load(
502
- building_components, modified_outdoor, indoor_conditions, internal_loads
503
- )
504
- # Recalculate wall and roof loads with thermal lag
505
- design_loads['walls'] = sum(
506
- self.calculate_wall_heating_load(wall, temp, indoor_conditions['temperature'], apply_thermal_lag=True)
507
- for wall in building_components.get('walls', [])
508
- )
509
- design_loads['roofs'] = sum(
510
- self.calculate_roof_heating_load(roof, temp, indoor_conditions['temperature'], apply_thermal_lag=True)
511
- for roof in building_components.get('roofs', [])
512
- )
513
- summary = self.calculate_heating_load_summary(design_loads)
514
- monthly_loads[month] = summary['total'] / 1000 # kW
515
- except ValueError:
516
- monthly_loads[month] = 0.0 # Skip invalid months
517
-
518
- return monthly_loads
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
+ Heat transfer calculation module for HVAC Load Calculator.
3
+ This module provides enhanced calculations for conduction, convection, radiation,
4
+ infiltration, and solar geometry, with improved modularity and error handling.
5
  """
6
 
7
+ from typing import Optional, Tuple
8
  import math
9
  import numpy as np
 
 
 
 
10
  from utils.psychrometrics import Psychrometrics
 
 
 
 
11
 
 
 
 
 
 
12
 
13
+ class SolarCalculations:
14
+ """Class for solar geometry and irradiance calculations."""
15
 
16
  def __init__(self):
17
+ """Initialize solar calculations with cached values."""
18
+ self._declination_cache = {} # Cache for declination by day of year
19
+
20
+ def validate_angle(self, angle: float, name: str, min_val: float, max_val: float) -> None:
 
 
 
21
  """
22
+ Validate an angle input.
23
 
24
  Args:
25
+ angle: Angle in degrees
26
+ name: Name of the angle for error messages
27
+ min_val: Minimum allowed value
28
+ max_val: Maximum allowed value
29
 
30
  Raises:
31
+ ValueError: If angle is out of range
32
+ """
33
+ if not min_val <= angle <= max_val:
34
+ raise ValueError(f"{name} {angle}° is outside valid range ({min_val} to {max_val}°)")
35
+
36
+ def solar_declination(self, day_of_year: int) -> float:
 
 
 
 
 
 
 
 
 
 
 
 
37
  """
38
+ Calculate solar declination angle for a given day of the year.
39
 
40
  Args:
41
+ day_of_year: Day of the year (1-365)
 
 
 
42
 
43
  Returns:
44
+ Solar declination angle in degrees
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  """
46
+ if not 1 <= day_of_year <= 365:
47
+ raise ValueError(f"Day of year {day_of_year} must be between 1 and 365")
48
+
49
+ if day_of_year in self._declination_cache:
50
+ return self._declination_cache[day_of_year]
51
+
52
+ declination = 23.45 * math.sin(math.radians(360 * (284 + day_of_year) / 365))
53
+ self._declination_cache[day_of_year] = declination
54
+ return declination
55
+
56
+ def solar_hour_angle(self, hour: float) -> float:
57
+ """
58
+ Calculate solar hour angle for a given hour of the day.
59
 
60
  Args:
61
+ hour: Hour of the day (0-23)
 
 
 
62
 
63
  Returns:
64
+ Solar hour angle in degrees
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  """
66
+ if not 0 <= hour <= 23:
67
+ raise ValueError(f"Hour {hour} must be between 0 and 23")
68
+ return 15 * (hour - 12)
69
+
70
+ def solar_altitude(self, latitude: float, declination: float, hour_angle: float) -> float:
71
+ """
72
+ Calculate solar altitude angle.
73
 
74
  Args:
75
+ latitude: Latitude in degrees
76
+ declination: Solar declination angle in degrees
77
+ hour_angle: Solar hour angle in degrees
78
 
79
  Returns:
80
+ Solar altitude angle in degrees
81
+ """
82
+ self.validate_angle(latitude, "Latitude", -90, 90)
83
+ self.validate_angle(declination, "Declination", -90, 90)
84
+ self.validate_angle(hour_angle, "Hour angle", -180, 180)
85
+
86
+ lat_rad = math.radians(latitude)
87
+ dec_rad = math.radians(declination)
88
+ ha_rad = math.radians(hour_angle)
89
+
90
+ sin_alt = math.sin(lat_rad) * math.sin(dec_rad) + math.cos(lat_rad) * math.cos(dec_rad) * math.cos(ha_rad)
91
+ altitude = math.degrees(math.asin(sin_alt))
92
+ return max(0, altitude)
93
+
94
+ def solar_azimuth(self, latitude: float, declination: float, hour_angle: float, altitude: float) -> float:
 
 
 
 
 
 
 
95
  """
96
+ Calculate solar azimuth angle.
97
 
98
  Args:
99
+ latitude: Latitude in degrees
100
+ declination: Solar declination angle in degrees
101
+ hour_angle: Solar hour angle in degrees
102
+ altitude: Solar altitude angle in degrees
103
 
104
  Returns:
105
+ Solar azimuth angle in degrees
106
  """
107
+ self.validate_angle(latitude, "Latitude", -90, 90)
108
+ self.validate_angle(declination, "Declination", -90, 90)
109
+ self.validate_angle(hour_angle, "Hour angle", -180, 180)
110
+ self.validate_angle(altitude, "Altitude", 0, 90)
111
+
112
+ lat_rad = math.radians(latitude)
113
+ dec_rad = math.radians(declination)
114
+ ha_rad = math.radians(hour_angle)
115
+ alt_rad = math.radians(altitude)
116
+
117
+ cos_az = (math.sin(alt_rad) * math.sin(lat_rad) - math.sin(dec_rad)) / (math.cos(alt_rad) * math.cos(lat_rad))
118
+ cos_az = max(-1, min(1, cos_az))
119
+ azimuth = math.degrees(math.acos(cos_az))
120
+
121
+ if hour_angle > 0:
122
+ azimuth = 360 - azimuth
123
+ return azimuth
124
+
125
+ def incident_angle(self, surface_tilt: float, surface_azimuth: float,
126
+ solar_altitude: float, solar_azimuth: float) -> float:
127
  """
128
+ Calculate angle of incidence for a surface.
129
 
130
  Args:
131
+ surface_tilt: Surface tilt angle in degrees (0=horizontal, 90=vertical)
132
+ surface_azimuth: Surface azimuth angle in degrees
133
+ solar_altitude: Solar altitude angle in degrees
134
+ solar_azimuth: Solar azimuth angle in degrees
135
 
136
  Returns:
137
+ Angle of incidence in degrees
138
  """
139
+ self.validate_angle(surface_tilt, "Surface tilt", 0, 180)
140
+ self.validate_angle(surface_azimuth, "Surface azimuth", 0, 360)
141
+ self.validate_angle(solar_altitude, "Solar altitude", 0, 90)
142
+ self.validate_angle(solar_azimuth, "Solar azimuth", 0, 360)
143
+
144
+ tilt_rad = math.radians(surface_tilt)
145
+ az_diff_rad = math.radians(solar_azimuth - surface_azimuth)
146
+ alt_rad = math.radians(solar_altitude)
147
+
148
+ cos_theta = (math.sin(alt_rad) * math.cos(tilt_rad) +
149
+ math.cos(alt_rad) * math.sin(tilt_rad) * math.cos(az_diff_rad))
150
+ cos_theta = max(0, min(1, cos_theta))
151
+ return math.degrees(math.acos(cos_theta))
152
+
153
+ def direct_normal_irradiance(self, solar_altitude: float) -> float:
154
  """
155
+ Calculate direct normal irradiance.
156
 
157
  Args:
158
+ solar_altitude: Solar altitude angle in degrees
 
 
 
159
 
160
  Returns:
161
+ Direct normal irradiance in W/m^2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  """
163
+ self.validate_angle(solar_altitude, "Solar altitude", 0, 90)
164
+ if solar_altitude <= 0:
165
+ return 0
166
+ air_mass = 1 / math.cos(math.radians(90 - solar_altitude))
167
+ dni = 1367 * (1 - 0.14 * air_mass) # Simplified model
168
+ return max(0, dni)
169
+
170
+ def diffuse_horizontal_irradiance(self, dni: float, solar_altitude: float) -> float:
171
+ """
172
+ Calculate diffuse horizontal irradiance.
173
 
174
  Args:
175
+ dni: Direct normal irradiance in W/m^2
176
+ solar_altitude: Solar altitude angle in degrees
 
177
 
178
  Returns:
179
+ Diffuse horizontal irradiance in W/m^2
180
  """
181
+ self.validate_angle(solar_altitude, "Solar altitude", 0, 90)
182
+ if solar_altitude <= 0:
183
+ return 0
184
+ return 0.1 * dni # Simplified model
185
+
186
+ def irradiance_on_surface(self, dni: float, dhi: float, incident_angle: float, surface_tilt: float) -> float:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
  """
188
+ Calculate total irradiance on a tilted surface.
189
 
190
  Args:
191
+ dni: Direct normal irradiance in W/m^2
192
+ dhi: Diffuse horizontal irradiance in W/m^2
193
+ incident_angle: Angle of incidence in degrees
194
+ surface_tilt: Surface tilt angle in degrees
195
 
196
  Returns:
197
+ Total irradiance in W/m^2
198
  """
199
+ self.validate_angle(incident_angle, "Incident angle", 0, 90)
200
+ self.validate_angle(surface_tilt, "Surface tilt", 0, 180)
201
+ if dni < 0 or dhi < 0:
202
+ raise ValueError("Irradiance values cannot be negative")
203
+
204
+ direct = dni * math.cos(math.radians(incident_angle))
205
+ diffuse = dhi * (1 + math.cos(math.radians(surface_tilt))) / 2
206
+ return max(0, direct + diffuse)
207
+
 
 
 
 
 
 
 
 
 
 
208
 
209
+ class HeatTransferCalculations:
210
+ """Class for heat transfer calculations."""
211
+
212
+ def __init__(self):
213
+ """Initialize heat transfer calculations with solar and psychrometric calculations."""
214
+ self.solar = SolarCalculations()
215
+ self.psychrometrics = Psychrometrics()
216
+
217
+ def validate_inputs(self, temp: float, area: float = 0.0, flow_rate: float = 0.0) -> None:
218
+ """
219
+ Validate input parameters for heat transfer calculations.
220
+
221
+ Args:
222
+ temp: Temperature in °C
223
+ area: Area in m^2
224
+ flow_rate: Flow rate in m^3/s
225
+
226
+ Raises:
227
+ ValueError: If inputs are out of acceptable ranges
228
+ """
229
+ if not -50 <= temp <= 60:
230
+ raise ValueError(f"Temperature {temp}°C is outside valid range (-50 to 60°C)")
231
+ if area < 0:
232
+ raise ValueError(f"Area {area}m^2 cannot be negative")
233
+ if flow_rate < 0:
234
+ raise ValueError(f"Flow rate {flow_rate}m^3/s cannot be negative")
235
+
236
+ def conduction_heat_transfer(self, u_value: float, area: float, delta_t: float) -> float:
237
  """
238
+ Calculate heat transfer by conduction.
239
 
240
  Args:
241
+ u_value: Overall heat transfer coefficient in W/(m^2·K)
242
+ area: Surface area in m^2
243
+ delta_t: Temperature difference in °C
 
244
 
245
  Returns:
246
+ Heat transfer rate in W
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
  """
248
+ if u_value < 0:
249
+ raise ValueError(f"U-value {u_value} W/(m^2·K) cannot be negative")
250
+ self.validate_inputs(delta_t, area)
251
+ return u_value * area * delta_t
252
+
253
+ def convection_heat_transfer(self, h: float, area: float, delta_t: float) -> float:
254
+ """
255
+ Calculate heat transfer by convection.
256
 
257
  Args:
258
+ h: Convective heat transfer coefficient in W/(m^2·K)
259
+ area: Surface area in m^2
260
+ delta_t: Temperature difference in °C
261
 
262
  Returns:
263
+ Heat transfer rate in W
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
  """
265
+ if h < 0:
266
+ raise ValueError(f"Convective coefficient {h} W/(m^2·K) cannot be negative")
267
+ self.validate_inputs(delta_t, area)
268
+ return h * area * delta_t
269
+
270
+ def radiation_heat_transfer(self, emissivity: float, area: float, t_surface: float, t_surroundings: float) -> float:
271
+ """
272
+ Calculate heat transfer by radiation using Stefan-Boltzmann law.
273
 
274
  Args:
275
+ emissivity: Surface emissivity (0-1)
276
+ area: Surface area in m^2
277
+ t_surface: Surface temperature in °C
278
+ t_surroundings: Surroundings temperature in °C
279
 
280
  Returns:
281
+ Heat transfer rate in W
282
+ """
283
+ if not 0 <= emissivity <= 1:
284
+ raise ValueError(f"Emissivity {emissivity} must be between 0 and 1")
285
+ self.validate_inputs(t_surface, area)
286
+ self.validate_inputs(t_surroundings)
287
+
288
+ sigma = 5.67e-8 # Stefan-Boltzmann constant in W/(m^2·K^4)
289
+ t_s = t_surface + 273.15
290
+ t_sur = t_surroundings + 273.15
291
+ return emissivity * sigma * area * (t_s**4 - t_sur**4)
292
+
293
+ def thermal_lag_factor(self, thermal_mass: float, time_constant: float, time_step: float) -> float:
294
  """
295
+ Calculate thermal lag factor for transient heat transfer.
 
 
 
 
296
 
297
+ Args:
298
+ thermal_mass: Thermal mass in J/K
299
+ time_constant: Time constant in hours
300
+ time_step: Time step in hours
301
+
302
+ Returns:
303
+ Thermal lag factor (0-1)
304
+ """
305
+ if thermal_mass < 0:
306
+ raise ValueError(f"Thermal mass {thermal_mass} J/K cannot be negative")
307
+ if time_constant <= 0:
308
+ raise ValueError(f"Time constant {time_constant} hours must be positive")
309
+ if time_step < 0:
310
+ raise ValueError(f"Time step {time_step} hours cannot be negative")
311
+
312
+ return math.exp(-time_step / time_constant)
313
+
314
+ def infiltration_heat_transfer(self, flow_rate: float, delta_t: float) -> float:
315
+ """
316
+ Calculate sensible heat transfer due to infiltration or ventilation.
317
 
318
+ Args:
319
+ flow_rate: Air flow rate in m^3/s
320
+ delta_t: Temperature difference in °C
321
+
322
+ Returns:
323
+ Sensible heat transfer rate in W
324
  """
325
+ self.validate_inputs(delta_t, flow_rate=flow_rate)
326
+ rho = 1.2 # Air density in kg/m^3
327
+ cp = 1005 # Specific heat of air in J/(kg·K)
328
+ return flow_rate * rho * cp * delta_t
329
+
330
+ def infiltration_latent_heat_transfer(self, flow_rate: float, delta_w: float) -> float:
331
+ """
332
+ Calculate latent heat transfer due to infiltration or ventilation.
333
 
334
  Args:
335
+ flow_rate: Air flow rate in m^3/s
336
+ delta_w: Humidity ratio difference in kg/kg
 
 
337
 
338
  Returns:
339
+ Latent heat transfer rate in W
340
+ """
341
+ self.validate_inputs(0, flow_rate=flow_rate)
342
+ rho = 1.2 # Air density in kg/m^3
343
+ h_fg = 2501000 # Latent heat of vaporization in J/kg
344
+ return flow_rate * rho * h_fg * delta_w
345
+
346
+ def wind_pressure_difference(self, wind_speed: float, wind_coefficient: float = 0.4) -> float:
347
  """
348
+ Calculate pressure difference due to wind.
 
349
 
350
+ Args:
351
+ wind_speed: Wind speed in m/s
352
+ wind_coefficient: Wind pressure coefficient
353
+
354
+ Returns:
355
+ Pressure difference in Pa
356
+ """
357
+ if wind_speed < 0:
358
+ raise ValueError(f"Wind speed {wind_speed} m/s cannot be negative")
359
+ if not 0 <= wind_coefficient <= 1:
360
+ raise ValueError(f"Wind coefficient {wind_coefficient} must be between 0 and 1")
361
 
362
+ rho = 1.2 # Air density in kg/m^3
363
+ return 0.5 * wind_coefficient * rho * wind_speed**2
364
+
365
+ def stack_pressure_difference(self, height: float, indoor_temp: float, outdoor_temp: float) -> float:
366
+ """
367
+ Calculate pressure difference due to stack effect.
368
 
369
+ Args:
370
+ height: Height difference in m
371
+ indoor_temp: Indoor temperature in K
372
+ outdoor_temp: Outdoor temperature in K
373
+
374
+ Returns:
375
+ Pressure difference in Pa
376
+ """
377
+ if height < 0:
378
+ raise ValueError(f"Height {height} m cannot be negative")
379
+ if indoor_temp <= 0 or outdoor_temp <= 0:
380
+ raise ValueError("Temperatures must be positive in Kelvin")
381
+
382
+ g = 9.81 # Gravitational acceleration in m/s^2
383
+ rho = 1.2 # Air density in kg/m^3
384
+ delta_t = abs(indoor_temp - outdoor_temp)
385
+ t_avg = (indoor_temp + outdoor_temp) / 2
386
+ return rho * g * height * delta_t / t_avg
387
+
388
+ def combined_pressure_difference(self, wind_pd: float, stack_pd: float) -> float:
389
+ """
390
+ Calculate combined pressure difference from wind and stack effects.
391
 
392
+ Args:
393
+ wind_pd: Wind pressure difference in Pa
394
+ stack_pd: Stack pressure difference in Pa
395
+
396
+ Returns:
397
+ Combined pressure difference in Pa
398
+ """
399
+ if wind_pd < 0 or stack_pd < 0:
400
+ raise ValueError("Pressure differences cannot be negative")
401
+ return math.sqrt(wind_pd**2 + stack_pd**2)
402
+
403
+ def crack_method_infiltration(self, crack_length: float, coefficient: float,
404
+ pressure_difference: float) -> float:
405
+ """
406
+ Calculate infiltration flow rate using crack method.
407
 
408
+ Args:
409
+ crack_length: Total crack length in m
410
+ coefficient: Flow coefficient in m^3/(s·m·Pa^n)
411
+ pressure_difference: Pressure difference in Pa
412
+
413
+ Returns:
414
+ Infiltration flow rate in m^3/s
415
+ """
416
+ if crack_length < 0:
417
+ raise ValueError(f"Crack length {crack_length} m cannot be negative")
418
+ if coefficient < 0:
419
+ raise ValueError(f"Coefficient {coefficient} cannot be negative")
420
+ if pressure_difference < 0:
421
+ raise ValueError(f"Pressure difference {pressure_difference} Pa cannot be negative")
422
+
423
+ n = 0.65 # Flow exponent
424
+ return coefficient * crack_length * pressure_difference**n
425
+
426
+ def sol_air_temperature(self, outdoor_temp: float, solar_irradiance: float,
427
+ surface_absorptivity: float, surface_resistance: float) -> float:
428
  """
429
+ Calculate sol-air temperature for a surface.
430
 
431
  Args:
432
+ outdoor_temp: Outdoor air temperature in °C
433
+ solar_irradiance: Solar irradiance on surface in W/m^2
434
+ surface_absorptivity: Surface absorptivity (0-1)
435
+ surface_resistance: Surface resistance in m^2·K/W
 
436
 
437
  Returns:
438
+ Sol-air temperature in °C
439
+ """
440
+ self.validate_inputs(outdoor_temp)
441
+ if solar_irradiance < 0:
442
+ raise ValueError(f"Solar irradiance {solar_irradiance} W/m^2 cannot be negative")
443
+ if not 0 <= surface_absorptivity <= 1:
444
+ raise ValueError(f"Surface absorptivity {surface_absorptivity} must be between 0 and 1")
445
+ if surface_resistance < 0:
446
+ raise ValueError(f"Surface resistance {surface_resistance} m^2·K/W cannot be negative")
447
+
448
+ h_ext = 1 / surface_resistance # External convective coefficient
449
+ delta_t_rad = surface_absorptivity * solar_irradiance / h_ext
450
+ return outdoor_temp + delta_t_rad
451
+
452
+ def solar_heat_gain(self, irradiance: float, area: float, shgc: float,
453
+ shading_coefficient: float = 1.0) -> float:
454
+ """
455
+ Calculate solar heat gain through a surface.
456
+
457
+ Args:
458
+ irradiance: Solar irradiance on surface in W/m^2
459
+ area: Surface area in m^2
460
+ shgc: Solar heat gain coefficient (0-1)
461
+ shading_coefficient: Shading coefficient (0-1)
462
 
463
+ Returns:
464
+ Solar heat gain in W
465
+ """
466
+ self.validate_inputs(0, area)
467
+ if irradiance < 0:
468
+ raise ValueError(f"Irradiance {irradiance} W/m^2 cannot be negative")
469
+ if not 0 <= shgc <= 1:
470
+ raise ValueError(f"SHGC {shgc} must be between 0 and 1")
471
+ if not 0 <= shading_coefficient <= 1:
472
+ raise ValueError(f"Shading coefficient {shading_coefficient} must be between 0 and 1")
473
+
474
+ return irradiance * area * shgc * shading_coefficient
475
+
476
+
477
+ # Create a singleton instance
478
+ heat_transfer_calculator = HeatTransferCalculations()
479
+
480
+ # Example usage
481
+ if __name__ == "__main__":
482
+ # Example solar calculations
483
+ latitude = 40.0
484
+ day_of_year = 204
485
+ hour = 12.0
486
+
487
+ declination = heat_transfer_calculator.solar.solar_declination(day_of_year)
488
+ hour_angle = heat_transfer_calculator.solar.solar_hour_angle(hour)
489
+ altitude = heat_transfer_calculator.solar.solar_altitude(latitude, declination, hour_angle)
490
+ azimuth = heat_transfer_calculator.solar.solar_azimuth(latitude, declination, hour_angle, altitude)
491
+
492
+ print(f"Solar Declination: {declination:.2f}°")
493
+ print(f"Solar Hour Angle: {hour_angle:.2f}°")
494
+ print(f"Solar Altitude: {altitude:.2f}°")
495
+ print(f"Solar Azimuth: {azimuth:.2f}°")
496
+
497
+ # Example heat transfer calculation
498
+ u_value = 0.5 # W/(m^2·K)
499
+ area = 20.0 # m^2
500
+ delta_t = 10.0 # °C
501
+ conduction = heat_transfer_calculator.conduction_heat_transfer(u_value, area, delta_t)
502
+ print(f"Conduction Heat Transfer: {conduction:.2f} W")
503
+
504
+ # Example psychrometric calculation
505
+ temp = 25.0 # °C
506
+ rh = 50.0 # %
507
+ humidity_ratio = heat_transfer_calculator.psychrometrics.humidity_ratio(temp, rh)
508
+ print(f"Humidity Ratio: {humidity_ratio:.6f} kg/kg")