mabuseif commited on
Commit
cdcab9b
·
verified ·
1 Parent(s): aa676d7

Update utils/solar.py

Browse files
Files changed (1) hide show
  1. utils/solar.py +44 -38
utils/solar.py CHANGED
@@ -288,10 +288,10 @@ class SolarCalculations:
288
  ) -> List[Dict[str, Any]]:
289
  """
290
  Calculate solar angles, sol-air temperature, and solar heat gain for hourly data with global_horizontal_radiation > 0.
291
-
292
  Uses the Perez model for diffuse radiation on tilted surfaces, accounting for anisotropic sky conditions
293
  (circumsolar and horizon brightening). Direct and ground-reflected radiation follow ASHRAE isotropic models.
294
-
295
  Args:
296
  hourly_data (List[Dict]): Hourly weather data containing month, day, hour, global_horizontal_radiation,
297
  direct_normal_radiation, diffuse_horizontal_radiation, dry_bulb, dew_point,
@@ -302,21 +302,21 @@ class SolarCalculations:
302
  ground_reflectivity (float): Ground reflectivity (albedo, typically 0.2).
303
  components (Dict[str, List]): Dictionary of component lists (e.g., walls, windows) with id, area,
304
  type, facade, construction, fenestration, or door_material.
305
-
306
  Returns:
307
  List[Dict]: List of results for each hour with global_horizontal_radiation > 0, containing solar angles
308
  and per-component results (total_incident_radiation, sol_air_temp, solar_heat_gain, etc.).
309
-
310
  Raises:
311
  ValueError: If required weather data or component parameters are missing or invalid.
312
-
313
  References:
314
  ASHRAE Handbook—Fundamentals (2021), Chapters 14 and 15.
315
  Duffie & Beckman, Solar Engineering of Thermal Processes, 4th Ed., Section 2.16.
316
  """
317
  year = 2025 # Fixed year since not provided in data
318
  results = []
319
-
320
  # Validate input parameters
321
  if not -90 <= latitude <= 90:
322
  logger.warning(f"Invalid latitude {latitude}. Using default 0.0.")
@@ -330,12 +330,12 @@ class SolarCalculations:
330
  if not 0 <= ground_reflectivity <= 1:
331
  logger.warning(f"Invalid ground_reflectivity {ground_reflectivity}. Using default 0.2.")
332
  ground_reflectivity = 0.2
333
-
334
  logger.info(f"Using parameters: latitude={latitude}, longitude={longitude}, timezone={timezone}, "
335
  f"ground_reflectivity={ground_reflectivity}")
336
-
337
  lambda_std = 15 * timezone # Standard meridian longitude (°)
338
-
339
  # Cache facade azimuths (used only for walls, windows)
340
  building_info = components.get("_building_info", {})
341
  facade_cache = {
@@ -344,7 +344,7 @@ class SolarCalculations:
344
  "C": (building_info.get("orientation_angle", 0.0) + 180.0) % 360,
345
  "D": (building_info.get("orientation_angle", 0.0) + 270.0) % 360
346
  }
347
-
348
  for record in hourly_data:
349
  # Step 1: Extract and validate data
350
  month = record.get("month")
@@ -363,44 +363,44 @@ class SolarCalculations:
363
  logger.warning(f"Diffuse radiation {diffuse_horizontal_radiation} exceeds global {global_horizontal_radiation} "
364
  f"at {month}/{day}/{hour}. Capping diffuse to global.")
365
  diffuse_horizontal_radiation = global_horizontal_radiation
366
-
367
  if None in [month, day, hour, outdoor_temp]:
368
  logger.error(f"Missing required weather data for {month}/{day}/{hour}")
369
  raise ValueError(f"Missing required weather data for {month}/{day}/{hour}")
370
-
371
  if global_horizontal_radiation < 0 or direct_normal_radiation < 0 or diffuse_horizontal_radiation < 0:
372
  logger.error(f"Negative radiation values for {month}/{day}/{hour}")
373
  raise ValueError(f"Negative radiation values for {month}/{day}/{hour}")
374
-
375
  if global_horizontal_radiation <= 0:
376
  logger.info(f"Skipping hour {month}/{day}/{hour} due to global_horizontal_radiation={global_horizontal_radiation} <= 0")
377
  continue # Skip hours with no solar radiation
378
-
379
  logger.info(f"Processing solar for {month}/{day}/{hour} with global_horizontal_radiation={global_horizontal_radiation}, "
380
  f"direct_normal_radiation={direct_normal_radiation}, diffuse_horizontal_radiation={diffuse_horizontal_radiation}, "
381
  f"dry_bulb={outdoor_temp}, dew_point={dew_point}, wind_speed={wind_speed}, "
382
  f"total_sky_cover={total_sky_cover}")
383
-
384
  # Step 2: Local Solar Time (LST) with Equation of Time
385
  n = self.day_of_year(month, day, year)
386
  EOT = self.equation_of_time(n)
387
  standard_time = hour - 1 + 0.5 # Convert to decimal, assume mid-hour
388
  LST = standard_time + (4 * (lambda_std - longitude) + EOT) / 60
389
-
390
  # Step 3: Solar Declination (δ)
391
  delta = 23.45 * math.sin(math.radians(360 / 365 * (284 + n)))
392
-
393
  # Step 4: Hour Angle (HRA)
394
  hra = 15 * (LST - 12)
395
-
396
  # Step 5: Solar Altitude (α) and Azimuth (ψ)
397
  phi = math.radians(latitude)
398
  delta_rad = math.radians(delta)
399
  hra_rad = math.radians(hra)
400
-
401
  sin_alpha = math.sin(phi) * math.sin(delta_rad) + math.cos(phi) * math.cos(delta_rad) * math.cos(hra_rad)
402
  alpha = math.degrees(math.asin(sin_alpha))
403
-
404
  if abs(math.cos(math.radians(alpha))) < 0.01:
405
  azimuth = 0 # North at sunrise/sunset
406
  else:
@@ -409,10 +409,10 @@ class SolarCalculations:
409
  azimuth = math.degrees(math.atan2(sin_az, cos_az))
410
  if hra > 0: # Afternoon
411
  azimuth = 360 - azimuth if azimuth > 0 else -azimuth
412
-
413
  logger.info(f"Solar angles for {month}/{day}/{hour}: declination={delta:.2f}, LST={LST:.2f}, "
414
  f"HRA={hra:.2f}, altitude={alpha:.2f}, azimuth={azimuth:.2f}")
415
-
416
  # Calculate clearness index (kt) and Perez coefficients once per hour
417
  zenith_deg = 90 - alpha # Zenith angle = 90 - altitude
418
  if global_horizontal_radiation > 0 and zenith_deg < 90:
@@ -421,7 +421,7 @@ class SolarCalculations:
421
  f1, f2 = self.calculate_perez_coefficients(kt, zenith_deg)
422
  else:
423
  kt, f1, f2 = 0.0, 0.0, 0.0
424
-
425
  # Step 6: Component-specific calculations
426
  component_results = []
427
  for comp_type, comp_list in components.items():
@@ -433,7 +433,7 @@ class SolarCalculations:
433
  if comp.get('adiabatic', False) and comp.get('ground_contact', False):
434
  logger.warning(f"Component {comp.get('name', 'unknown_component')} has both adiabatic and ground_contact set to True. Treating as adiabatic, setting ground_contact to False.")
435
  comp['ground_contact'] = False
436
-
437
  # Get surface parameters
438
  surface_tilt, surface_azimuth, h_o, emissivity, absorptivity = \
439
  self.get_surface_parameters(comp, building_info)
@@ -441,17 +441,17 @@ class SolarCalculations:
441
  # For windows/skylights, get SHGC from component
442
  shgc = comp.get('shgc', 0.7)
443
  fenestration_name = comp.get('fenestration', None)
444
-
445
  # Calculate angle of incidence (θ)
446
  cos_theta = (math.sin(math.radians(alpha)) * math.cos(math.radians(surface_tilt)) +
447
  math.cos(math.radians(alpha)) * math.sin(math.radians(surface_tilt)) *
448
  math.cos(math.radians(azimuth - surface_azimuth)))
449
  cos_theta = max(min(cos_theta, 1.0), 0.0) # Clamp to [0, 1]
450
-
451
  logger.info(f" Component {comp.get('name', 'unknown_component')} at {month}/{day}/{hour}: "
452
  f"surface_tilt={surface_tilt:.2f}, surface_azimuth={surface_azimuth:.2f}, "
453
- f"cos_theta={cos_theta:.2f}, h_o={h_o:.2f}")
454
-
455
  # Calculate total incident radiation (I_t) with Perez model
456
  view_factor = (1 - math.cos(math.radians(surface_tilt))) / 2
457
  ground_reflected = ground_reflectivity * global_horizontal_radiation * view_factor
@@ -466,7 +466,7 @@ class SolarCalculations:
466
  direct_tilted = direct_normal_radiation * max(cos_theta, 0.0)
467
  I_t = direct_tilted + diffuse_tilted + ground_reflected
468
  I_t = max(I_t, 0.0) # Ensure non-negative radiation
469
-
470
  # Initialize result
471
  comp_result = {
472
  "component_id": comp.get('name', 'unknown_component'),
@@ -474,13 +474,13 @@ class SolarCalculations:
474
  "absorptivity": round(absorptivity, 2),
475
  "emissivity": round(emissivity, 2) if emissivity is not None else None
476
  }
477
-
478
  # Skip calculations for adiabatic surfaces
479
  if comp.get('adiabatic', False):
480
  logger.info(f"Skipping solar calculations for adiabatic component {comp_result['component_id']} at {month}/{day}/{hour}")
481
  component_results.append(comp_result)
482
  continue
483
-
484
  # Handle ground-contact surfaces
485
  if comp.get('ground_contact', False):
486
  # Validate component type
@@ -490,7 +490,7 @@ class SolarCalculations:
490
  logger.warning(f"Invalid ground-contact component type '{component_type}' for {comp_result['component_id']}. Skipping ground temperature assignment.")
491
  component_results.append(comp_result)
492
  continue
493
-
494
  # Retrieve ground temperature
495
  climate_data = st.session_state.project_data.get("climate_data", {})
496
  ground_temperatures = climate_data.get("ground_temperatures", {})
@@ -504,7 +504,7 @@ class SolarCalculations:
504
  logger.info(f"Ground-contact component {comp_result['component_id']} at {month}/{day}/{hour}: ground_temp={ground_temp:.2f}°C")
505
  component_results.append(comp_result)
506
  continue
507
-
508
  # Calculate sol-air temperature for opaque surfaces (non-ground-contact)
509
  if comp.get('type', '').lower() in ['walls', 'roofs'] and not comp.get('ground_contact', False):
510
  T_sol_air = CTFCalculator.calculate_sol_air_temperature(
@@ -513,26 +513,32 @@ class SolarCalculations:
513
  )
514
  comp_result["sol_air_temp"] = round(T_sol_air, 2)
515
  logger.info(f"Sol-air temp for {comp_result['component_id']} at {month}/{day}/{hour}: {T_sol_air:.2f}°C")
516
-
517
  # Calculate solar heat gain for fenestration
518
  elif comp.get('type', '').lower() in ['windows', 'skylights']:
519
  glazing_type = self.GLAZING_TYPE_MAPPING.get(comp.get('fenestration', ''), 'Single Clear')
520
  iac = comp.get('shading_coefficient', 1.0)
 
 
 
 
 
 
521
  shgc_dynamic = shgc * self.calculate_dynamic_shgc(glazing_type, cos_theta)
522
  solar_heat_gain = comp.get('area', 0.0) * shgc_dynamic * I_t * iac / 1000 # kW
523
  comp_result["solar_heat_gain"] = round(solar_heat_gain, 2)
524
  comp_result["shgc_dynamic"] = round(shgc_dynamic, 2)
525
  logger.info(f"Solar heat gain for {comp_result['component_id']} at {month}/{day}/{hour}: "
526
  f"{solar_heat_gain:.2f} kW (area={comp.get('area', 0.0)}, shgc_dynamic={shgc_dynamic:.2f}, "
527
- f"I_t={I_t:.2f}, iac={iac})")
528
 
529
  component_results.append(comp_result)
530
-
531
  except Exception as e:
532
  component_name = comp.get('name', 'unknown_component')
533
  logger.error(f"Error processing component {component_name} at {month}/{day}/{hour}: {str(e)}")
534
  continue
535
-
536
  # Store results for this hour
537
  result = {
538
  "month": month,
@@ -546,5 +552,5 @@ class SolarCalculations:
546
  "component_results": component_results
547
  }
548
  results.append(result)
549
-
550
  return results
 
288
  ) -> List[Dict[str, Any]]:
289
  """
290
  Calculate solar angles, sol-air temperature, and solar heat gain for hourly data with global_horizontal_radiation > 0.
291
+
292
  Uses the Perez model for diffuse radiation on tilted surfaces, accounting for anisotropic sky conditions
293
  (circumsolar and horizon brightening). Direct and ground-reflected radiation follow ASHRAE isotropic models.
294
+
295
  Args:
296
  hourly_data (List[Dict]): Hourly weather data containing month, day, hour, global_horizontal_radiation,
297
  direct_normal_radiation, diffuse_horizontal_radiation, dry_bulb, dew_point,
 
302
  ground_reflectivity (float): Ground reflectivity (albedo, typically 0.2).
303
  components (Dict[str, List]): Dictionary of component lists (e.g., walls, windows) with id, area,
304
  type, facade, construction, fenestration, or door_material.
305
+
306
  Returns:
307
  List[Dict]: List of results for each hour with global_horizontal_radiation > 0, containing solar angles
308
  and per-component results (total_incident_radiation, sol_air_temp, solar_heat_gain, etc.).
309
+
310
  Raises:
311
  ValueError: If required weather data or component parameters are missing or invalid.
312
+
313
  References:
314
  ASHRAE Handbook—Fundamentals (2021), Chapters 14 and 15.
315
  Duffie & Beckman, Solar Engineering of Thermal Processes, 4th Ed., Section 2.16.
316
  """
317
  year = 2025 # Fixed year since not provided in data
318
  results = []
319
+
320
  # Validate input parameters
321
  if not -90 <= latitude <= 90:
322
  logger.warning(f"Invalid latitude {latitude}. Using default 0.0.")
 
330
  if not 0 <= ground_reflectivity <= 1:
331
  logger.warning(f"Invalid ground_reflectivity {ground_reflectivity}. Using default 0.2.")
332
  ground_reflectivity = 0.2
333
+
334
  logger.info(f"Using parameters: latitude={latitude}, longitude={longitude}, timezone={timezone}, "
335
  f"ground_reflectivity={ground_reflectivity}")
336
+
337
  lambda_std = 15 * timezone # Standard meridian longitude (°)
338
+
339
  # Cache facade azimuths (used only for walls, windows)
340
  building_info = components.get("_building_info", {})
341
  facade_cache = {
 
344
  "C": (building_info.get("orientation_angle", 0.0) + 180.0) % 360,
345
  "D": (building_info.get("orientation_angle", 0.0) + 270.0) % 360
346
  }
347
+
348
  for record in hourly_data:
349
  # Step 1: Extract and validate data
350
  month = record.get("month")
 
363
  logger.warning(f"Diffuse radiation {diffuse_horizontal_radiation} exceeds global {global_horizontal_radiation} "
364
  f"at {month}/{day}/{hour}. Capping diffuse to global.")
365
  diffuse_horizontal_radiation = global_horizontal_radiation
366
+
367
  if None in [month, day, hour, outdoor_temp]:
368
  logger.error(f"Missing required weather data for {month}/{day}/{hour}")
369
  raise ValueError(f"Missing required weather data for {month}/{day}/{hour}")
370
+
371
  if global_horizontal_radiation < 0 or direct_normal_radiation < 0 or diffuse_horizontal_radiation < 0:
372
  logger.error(f"Negative radiation values for {month}/{day}/{hour}")
373
  raise ValueError(f"Negative radiation values for {month}/{day}/{hour}")
374
+
375
  if global_horizontal_radiation <= 0:
376
  logger.info(f"Skipping hour {month}/{day}/{hour} due to global_horizontal_radiation={global_horizontal_radiation} <= 0")
377
  continue # Skip hours with no solar radiation
378
+
379
  logger.info(f"Processing solar for {month}/{day}/{hour} with global_horizontal_radiation={global_horizontal_radiation}, "
380
  f"direct_normal_radiation={direct_normal_radiation}, diffuse_horizontal_radiation={diffuse_horizontal_radiation}, "
381
  f"dry_bulb={outdoor_temp}, dew_point={dew_point}, wind_speed={wind_speed}, "
382
  f"total_sky_cover={total_sky_cover}")
383
+
384
  # Step 2: Local Solar Time (LST) with Equation of Time
385
  n = self.day_of_year(month, day, year)
386
  EOT = self.equation_of_time(n)
387
  standard_time = hour - 1 + 0.5 # Convert to decimal, assume mid-hour
388
  LST = standard_time + (4 * (lambda_std - longitude) + EOT) / 60
389
+
390
  # Step 3: Solar Declination (δ)
391
  delta = 23.45 * math.sin(math.radians(360 / 365 * (284 + n)))
392
+
393
  # Step 4: Hour Angle (HRA)
394
  hra = 15 * (LST - 12)
395
+
396
  # Step 5: Solar Altitude (α) and Azimuth (ψ)
397
  phi = math.radians(latitude)
398
  delta_rad = math.radians(delta)
399
  hra_rad = math.radians(hra)
400
+
401
  sin_alpha = math.sin(phi) * math.sin(delta_rad) + math.cos(phi) * math.cos(delta_rad) * math.cos(hra_rad)
402
  alpha = math.degrees(math.asin(sin_alpha))
403
+
404
  if abs(math.cos(math.radians(alpha))) < 0.01:
405
  azimuth = 0 # North at sunrise/sunset
406
  else:
 
409
  azimuth = math.degrees(math.atan2(sin_az, cos_az))
410
  if hra > 0: # Afternoon
411
  azimuth = 360 - azimuth if azimuth > 0 else -azimuth
412
+
413
  logger.info(f"Solar angles for {month}/{day}/{hour}: declination={delta:.2f}, LST={LST:.2f}, "
414
  f"HRA={hra:.2f}, altitude={alpha:.2f}, azimuth={azimuth:.2f}")
415
+
416
  # Calculate clearness index (kt) and Perez coefficients once per hour
417
  zenith_deg = 90 - alpha # Zenith angle = 90 - altitude
418
  if global_horizontal_radiation > 0 and zenith_deg < 90:
 
421
  f1, f2 = self.calculate_perez_coefficients(kt, zenith_deg)
422
  else:
423
  kt, f1, f2 = 0.0, 0.0, 0.0
424
+
425
  # Step 6: Component-specific calculations
426
  component_results = []
427
  for comp_type, comp_list in components.items():
 
433
  if comp.get('adiabatic', False) and comp.get('ground_contact', False):
434
  logger.warning(f"Component {comp.get('name', 'unknown_component')} has both adiabatic and ground_contact set to True. Treating as adiabatic, setting ground_contact to False.")
435
  comp['ground_contact'] = False
436
+
437
  # Get surface parameters
438
  surface_tilt, surface_azimuth, h_o, emissivity, absorptivity = \
439
  self.get_surface_parameters(comp, building_info)
 
441
  # For windows/skylights, get SHGC from component
442
  shgc = comp.get('shgc', 0.7)
443
  fenestration_name = comp.get('fenestration', None)
444
+
445
  # Calculate angle of incidence (θ)
446
  cos_theta = (math.sin(math.radians(alpha)) * math.cos(math.radians(surface_tilt)) +
447
  math.cos(math.radians(alpha)) * math.sin(math.radians(surface_tilt)) *
448
  math.cos(math.radians(azimuth - surface_azimuth)))
449
  cos_theta = max(min(cos_theta, 1.0), 0.0) # Clamp to [0, 1]
450
+
451
  logger.info(f" Component {comp.get('name', 'unknown_component')} at {month}/{day}/{hour}: "
452
  f"surface_tilt={surface_tilt:.2f}, surface_azimuth={surface_azimuth:.2f}, "
453
+ f"cos_theta={cos_theta:.2f}")
454
+
455
  # Calculate total incident radiation (I_t) with Perez model
456
  view_factor = (1 - math.cos(math.radians(surface_tilt))) / 2
457
  ground_reflected = ground_reflectivity * global_horizontal_radiation * view_factor
 
466
  direct_tilted = direct_normal_radiation * max(cos_theta, 0.0)
467
  I_t = direct_tilted + diffuse_tilted + ground_reflected
468
  I_t = max(I_t, 0.0) # Ensure non-negative radiation
469
+
470
  # Initialize result
471
  comp_result = {
472
  "component_id": comp.get('name', 'unknown_component'),
 
474
  "absorptivity": round(absorptivity, 2),
475
  "emissivity": round(emissivity, 2) if emissivity is not None else None
476
  }
477
+
478
  # Skip calculations for adiabatic surfaces
479
  if comp.get('adiabatic', False):
480
  logger.info(f"Skipping solar calculations for adiabatic component {comp_result['component_id']} at {month}/{day}/{hour}")
481
  component_results.append(comp_result)
482
  continue
483
+
484
  # Handle ground-contact surfaces
485
  if comp.get('ground_contact', False):
486
  # Validate component type
 
490
  logger.warning(f"Invalid ground-contact component type '{component_type}' for {comp_result['component_id']}. Skipping ground temperature assignment.")
491
  component_results.append(comp_result)
492
  continue
493
+
494
  # Retrieve ground temperature
495
  climate_data = st.session_state.project_data.get("climate_data", {})
496
  ground_temperatures = climate_data.get("ground_temperatures", {})
 
504
  logger.info(f"Ground-contact component {comp_result['component_id']} at {month}/{day}/{hour}: ground_temp={ground_temp:.2f}°C")
505
  component_results.append(comp_result)
506
  continue
507
+
508
  # Calculate sol-air temperature for opaque surfaces (non-ground-contact)
509
  if comp.get('type', '').lower() in ['walls', 'roofs'] and not comp.get('ground_contact', False):
510
  T_sol_air = CTFCalculator.calculate_sol_air_temperature(
 
513
  )
514
  comp_result["sol_air_temp"] = round(T_sol_air, 2)
515
  logger.info(f"Sol-air temp for {comp_result['component_id']} at {month}/{day}/{hour}: {T_sol_air:.2f}°C")
516
+
517
  # Calculate solar heat gain for fenestration
518
  elif comp.get('type', '').lower() in ['windows', 'skylights']:
519
  glazing_type = self.GLAZING_TYPE_MAPPING.get(comp.get('fenestration', ''), 'Single Clear')
520
  iac = comp.get('shading_coefficient', 1.0)
521
+ # Adjust shading coefficient based on shading type
522
+ shading_type = comp.get('shading_type', 'No shading')
523
+ if shading_type == "External Shading":
524
+ iac *= 0.6
525
+ elif shading_type == "Internal Shading":
526
+ iac *= 0.8
527
  shgc_dynamic = shgc * self.calculate_dynamic_shgc(glazing_type, cos_theta)
528
  solar_heat_gain = comp.get('area', 0.0) * shgc_dynamic * I_t * iac / 1000 # kW
529
  comp_result["solar_heat_gain"] = round(solar_heat_gain, 2)
530
  comp_result["shgc_dynamic"] = round(shgc_dynamic, 2)
531
  logger.info(f"Solar heat gain for {comp_result['component_id']} at {month}/{day}/{hour}: "
532
  f"{solar_heat_gain:.2f} kW (area={comp.get('area', 0.0)}, shgc_dynamic={shgc_dynamic:.2f}, "
533
+ f"I_t={I_t:.2f}, iac={iac}, shading_type={shading_type})")
534
 
535
  component_results.append(comp_result)
536
+
537
  except Exception as e:
538
  component_name = comp.get('name', 'unknown_component')
539
  logger.error(f"Error processing component {component_name} at {month}/{day}/{hour}: {str(e)}")
540
  continue
541
+
542
  # Store results for this hour
543
  result = {
544
  "month": month,
 
552
  "component_results": component_results
553
  }
554
  results.append(result)
555
+
556
  return results