voxcity 0.3.25__py3-none-any.whl → 0.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of voxcity might be problematic. Click here for more details.

@@ -7,7 +7,7 @@ import pytz
7
7
  from astral import Observer
8
8
  from astral.sun import elevation, azimuth
9
9
 
10
- from .view import trace_ray_generic, compute_vi_map_generic, get_sky_view_factor_map
10
+ from .view import trace_ray_generic, compute_vi_map_generic, get_sky_view_factor_map, get_building_surface_svf
11
11
  from ..utils.weather import get_nearest_epw_from_climate_onebuilding, read_epw_for_solar_simulation
12
12
  from ..exporter.obj import grid_to_obj, export_obj
13
13
 
@@ -703,13 +703,11 @@ def get_global_solar_irradiance_using_epw(
703
703
  )
704
704
 
705
705
  # Read EPW data
706
- df, lat, lon, tz, elevation_m = read_epw_for_solar_simulation(epw_file_path)
706
+ df, lon, lat, tz, elevation_m = read_epw_for_solar_simulation(epw_file_path)
707
707
  if df.empty:
708
708
  raise ValueError("No data in EPW file.")
709
709
 
710
710
  if calc_type == 'instantaneous':
711
- if df.empty:
712
- raise ValueError("No data in EPW file.")
713
711
 
714
712
  calc_time = kwargs.get("calc_time", "01-01 12:00:00")
715
713
 
@@ -734,7 +732,7 @@ def get_global_solar_irradiance_using_epw(
734
732
  df_period_utc = df_period_local.tz_convert(pytz.UTC)
735
733
 
736
734
  # Compute solar positions
737
- solar_positions = get_solar_positions_astral(df_period_utc.index, lat, lon)
735
+ solar_positions = get_solar_positions_astral(df_period_utc.index, lon, lat)
738
736
  direct_normal_irradiance = df_period_utc.iloc[0]['DNI']
739
737
  diffuse_irradiance = df_period_utc.iloc[0]['DHI']
740
738
  azimuth_degrees = solar_positions.iloc[0]['azimuth']
@@ -760,8 +758,659 @@ def get_global_solar_irradiance_using_epw(
760
758
  solar_map = get_cumulative_global_solar_irradiance(
761
759
  voxel_data,
762
760
  meshsize,
763
- df_filtered, lat, lon, tz,
761
+ df_filtered, lon, lat, tz,
764
762
  **kwargs
765
763
  )
766
764
 
767
- return solar_map
765
+ return solar_map
766
+
767
+ import numpy as np
768
+ import trimesh
769
+ import time
770
+ from numba import njit
771
+
772
+ ##############################################################################
773
+ # 1) New Numba helper: per-face solar irradiance computation
774
+ ##############################################################################
775
+ @njit
776
+ def compute_solar_irradiance_for_all_faces(
777
+ face_centers,
778
+ face_normals,
779
+ face_svf_values,
780
+ sun_direction,
781
+ direct_normal_irradiance,
782
+ diffuse_irradiance,
783
+ voxel_data,
784
+ meshsize,
785
+ tree_k,
786
+ tree_lad,
787
+ hit_values,
788
+ inclusion_mode,
789
+ grid_bounds_real,
790
+ boundary_epsilon
791
+ ):
792
+ """
793
+ Numba-compiled function to compute direct, diffuse, and global solar irradiance
794
+ for each face in the mesh.
795
+
796
+ Args:
797
+ face_centers (float64[:, :]): (N x 3) array of face center points
798
+ face_normals (float64[:, :]): (N x 3) array of face normals
799
+ face_svf_values (float64[:]): (N) array of SVF values for each face
800
+ sun_direction (float64[:]): (3) array for sun direction (dx, dy, dz)
801
+ direct_normal_irradiance (float): Direct normal irradiance (DNI) in W/m²
802
+ diffuse_irradiance (float): Diffuse horizontal irradiance (DHI) in W/m²
803
+ voxel_data (ndarray): 3D array of voxel values
804
+ meshsize (float): Size of each voxel in meters
805
+ tree_k (float): Tree extinction coefficient
806
+ tree_lad (float): Leaf area density
807
+ hit_values (tuple): Values considered 'sky' (e.g. (0,))
808
+ inclusion_mode (bool): Whether we want to "include" or "exclude" these hit_values
809
+ grid_bounds_real (float64[2,3]): [[x_min, y_min, z_min],[x_max, y_max, z_max]]
810
+ boundary_epsilon (float): Distance threshold for bounding-box check
811
+
812
+ Returns:
813
+ (direct_irr, diffuse_irr, global_irr) as three float64[N] arrays
814
+ """
815
+ n_faces = face_centers.shape[0]
816
+
817
+ face_direct = np.zeros(n_faces, dtype=np.float64)
818
+ face_diffuse = np.zeros(n_faces, dtype=np.float64)
819
+ face_global = np.zeros(n_faces, dtype=np.float64)
820
+
821
+ x_min, y_min, z_min = grid_bounds_real[0, 0], grid_bounds_real[0, 1], grid_bounds_real[0, 2]
822
+ x_max, y_max, z_max = grid_bounds_real[1, 0], grid_bounds_real[1, 1], grid_bounds_real[1, 2]
823
+
824
+ for fidx in range(n_faces):
825
+ center = face_centers[fidx]
826
+ normal = face_normals[fidx]
827
+ svf = face_svf_values[fidx]
828
+
829
+ # -- 1) Check for vertical boundary face
830
+ is_vertical = (abs(normal[2]) < 0.01)
831
+
832
+ on_x_min = (abs(center[0] - x_min) < boundary_epsilon)
833
+ on_y_min = (abs(center[1] - y_min) < boundary_epsilon)
834
+ on_x_max = (abs(center[0] - x_max) < boundary_epsilon)
835
+ on_y_max = (abs(center[1] - y_max) < boundary_epsilon)
836
+
837
+ is_boundary_vertical = is_vertical and (on_x_min or on_y_min or on_x_max or on_y_max)
838
+
839
+ if is_boundary_vertical:
840
+ face_direct[fidx] = np.nan
841
+ face_diffuse[fidx] = np.nan
842
+ face_global[fidx] = np.nan
843
+ continue
844
+
845
+ # If SVF is NaN, skip (means it was set to boundary or invalid earlier)
846
+ if svf != svf: # NaN check in Numba
847
+ face_direct[fidx] = np.nan
848
+ face_diffuse[fidx] = np.nan
849
+ face_global[fidx] = np.nan
850
+ continue
851
+
852
+ # -- 2) Direct irradiance (if face is oriented towards sun)
853
+ cos_incidence = normal[0]*sun_direction[0] + \
854
+ normal[1]*sun_direction[1] + \
855
+ normal[2]*sun_direction[2]
856
+
857
+ direct_val = 0.0
858
+ if cos_incidence > 0.0:
859
+ # Offset ray origin slightly to avoid self-intersection
860
+ offset_vox = 0.1
861
+ ray_origin_x = center[0]/meshsize + normal[0]*offset_vox
862
+ ray_origin_y = center[1]/meshsize + normal[1]*offset_vox
863
+ ray_origin_z = center[2]/meshsize + normal[2]*offset_vox
864
+
865
+ # Single ray toward the sun
866
+ hit_detected, transmittance = trace_ray_generic(
867
+ voxel_data,
868
+ np.array([ray_origin_x, ray_origin_y, ray_origin_z], dtype=np.float64),
869
+ sun_direction,
870
+ hit_values,
871
+ meshsize,
872
+ tree_k,
873
+ tree_lad,
874
+ inclusion_mode
875
+ )
876
+ if not hit_detected:
877
+ direct_val = direct_normal_irradiance * cos_incidence * transmittance
878
+
879
+ # -- 3) Diffuse irradiance from sky: use SVF * DHI
880
+ diffuse_val = svf * diffuse_irradiance
881
+ if diffuse_val > diffuse_irradiance:
882
+ diffuse_val = diffuse_irradiance
883
+
884
+ # -- 4) Sum up
885
+ face_direct[fidx] = direct_val
886
+ face_diffuse[fidx] = diffuse_val
887
+ face_global[fidx] = direct_val + diffuse_val
888
+
889
+ return face_direct, face_diffuse, face_global
890
+
891
+
892
+ ##############################################################################
893
+ # 2) Modified get_building_solar_irradiance: main Python wrapper
894
+ ##############################################################################
895
+ def get_building_solar_irradiance(
896
+ voxel_data,
897
+ meshsize,
898
+ building_svf_mesh,
899
+ azimuth_degrees,
900
+ elevation_degrees,
901
+ direct_normal_irradiance,
902
+ diffuse_irradiance,
903
+ **kwargs
904
+ ):
905
+ """
906
+ Calculate solar irradiance on building surfaces using SVF,
907
+ with the numeric per-face loop accelerated by Numba.
908
+
909
+ Args:
910
+ voxel_data (ndarray): 3D array of voxel values.
911
+ meshsize (float): Size of each voxel in meters.
912
+ building_svf_mesh (trimesh.Trimesh): Building mesh with SVF values in metadata.
913
+ azimuth_degrees (float): Sun azimuth angle in degrees (0=North, 90=East).
914
+ elevation_degrees (float): Sun elevation angle in degrees above horizon.
915
+ direct_normal_irradiance (float): DNI in W/m².
916
+ diffuse_irradiance (float): DHI in W/m².
917
+ **kwargs: Additional parameters, e.g. tree_k, tree_lad, progress_report, obj_export, etc.
918
+
919
+ Returns:
920
+ trimesh.Trimesh: A copy of the input mesh with direct/diffuse/global irradiance stored in metadata.
921
+ """
922
+ import time
923
+
924
+ tree_k = kwargs.get("tree_k", 0.6)
925
+ tree_lad = kwargs.get("tree_lad", 1.0)
926
+ progress_report = kwargs.get("progress_report", False)
927
+
928
+ # Sky detection
929
+ hit_values = (0,) # '0' = sky
930
+ inclusion_mode = False
931
+
932
+ # Convert angles -> direction
933
+ az_rad = np.deg2rad(180 - azimuth_degrees)
934
+ el_rad = np.deg2rad(elevation_degrees)
935
+ sun_dx = np.cos(el_rad) * np.sin(az_rad)
936
+ sun_dy = np.cos(el_rad) * np.cos(az_rad)
937
+ sun_dz = np.sin(el_rad)
938
+ sun_direction = np.array([sun_dx, sun_dy, sun_dz], dtype=np.float64)
939
+
940
+ # Extract mesh data
941
+ face_centers = building_svf_mesh.triangles_center
942
+ face_normals = building_svf_mesh.face_normals
943
+
944
+ # Get SVF from metadata
945
+ if hasattr(building_svf_mesh, 'metadata') and ('svf_values' in building_svf_mesh.metadata):
946
+ face_svf_values = building_svf_mesh.metadata['svf_values']
947
+ else:
948
+ face_svf_values = np.zeros(len(building_svf_mesh.faces), dtype=np.float64)
949
+
950
+ # Prepare boundary checks
951
+ grid_shape = voxel_data.shape
952
+ grid_bounds_voxel = np.array([[0,0,0],[grid_shape[0], grid_shape[1], grid_shape[2]]], dtype=np.float64)
953
+ grid_bounds_real = grid_bounds_voxel * meshsize
954
+ boundary_epsilon = meshsize * 0.05
955
+
956
+ # Call Numba-compiled function
957
+ t0 = time.time()
958
+ face_direct, face_diffuse, face_global = compute_solar_irradiance_for_all_faces(
959
+ face_centers,
960
+ face_normals,
961
+ face_svf_values,
962
+ sun_direction,
963
+ direct_normal_irradiance,
964
+ diffuse_irradiance,
965
+ voxel_data,
966
+ meshsize,
967
+ tree_k,
968
+ tree_lad,
969
+ hit_values,
970
+ inclusion_mode,
971
+ grid_bounds_real,
972
+ boundary_epsilon
973
+ )
974
+ if progress_report:
975
+ elapsed = time.time() - t0
976
+ print(f"Numba-based solar irradiance calculation took {elapsed:.2f} seconds")
977
+
978
+ # Create a copy of the mesh
979
+ irradiance_mesh = building_svf_mesh.copy()
980
+ if not hasattr(irradiance_mesh, 'metadata'):
981
+ irradiance_mesh.metadata = {}
982
+
983
+ # Store results
984
+ irradiance_mesh.metadata['svf'] = face_svf_values
985
+ irradiance_mesh.metadata['direct'] = face_direct
986
+ irradiance_mesh.metadata['diffuse'] = face_diffuse
987
+ irradiance_mesh.metadata['global'] = face_global
988
+
989
+ irradiance_mesh.name = "Solar Irradiance (W/m²)"
990
+
991
+ # # Optional OBJ export
992
+ # obj_export = kwargs.get("obj_export", False)
993
+ # if obj_export:
994
+ # _export_solar_irradiance_mesh(
995
+ # irradiance_mesh,
996
+ # face_global,
997
+ # **kwargs
998
+ # )
999
+
1000
+ return irradiance_mesh
1001
+
1002
+
1003
+ # ##############################################################################
1004
+ # # 3) Small helper for OBJ export & color-mapping
1005
+ # ##############################################################################
1006
+ # def _export_solar_irradiance_mesh(mesh_obj, face_global_values, **kwargs):
1007
+ # import os
1008
+ # import matplotlib.cm as cm
1009
+ # import matplotlib.colors as mcolors
1010
+
1011
+ # output_dir = kwargs.get("output_directory", "output")
1012
+ # output_file_name = kwargs.get("output_file_name", "building_solar_irradiance")
1013
+ # os.makedirs(output_dir, exist_ok=True)
1014
+
1015
+ # vmin = kwargs.get("vmin", 0.0)
1016
+ # vmax = kwargs.get("vmax", None)
1017
+ # valid_mask = ~np.isnan(face_global_values)
1018
+ # if vmax is None and np.any(valid_mask):
1019
+ # vmax = max(np.nanmax(face_global_values), 0.1)
1020
+ # elif vmax is None:
1021
+ # vmax = 1.0 # fallback
1022
+
1023
+ # colormap = kwargs.get("colormap", "magma")
1024
+ # cmap = cm.get_cmap(colormap)
1025
+ # norm = mcolors.Normalize(vmin=vmin, vmax=vmax)
1026
+
1027
+ # face_colors = np.zeros((len(face_global_values), 4))
1028
+ # nan_mask = np.isnan(face_global_values)
1029
+
1030
+ # # Use 'gray' or user-specified color for NaN
1031
+ # nan_color = kwargs.get("nan_color", "gray")
1032
+ # if isinstance(nan_color, str):
1033
+ # nan_rgba = np.array(mcolors.to_rgba(nan_color))
1034
+ # else:
1035
+ # nan_rgba = np.array(nan_color)
1036
+
1037
+ # face_colors[valid_mask] = cmap(norm(face_global_values[valid_mask]))
1038
+ # face_colors[nan_mask] = nan_rgba
1039
+
1040
+ # # Apply and export
1041
+ # export_mesh = mesh_obj.copy()
1042
+ # export_mesh.visual.face_colors = face_colors
1043
+ # try:
1044
+ # export_mesh.export(f"{output_dir}/{output_file_name}.obj")
1045
+ # print(f"Exported solar irradiance mesh to {output_dir}/{output_file_name}.obj")
1046
+ # except Exception as e:
1047
+ # print(f"Error exporting mesh: {e}")
1048
+
1049
+
1050
+ ##############################################################################
1051
+ # 4) Modified get_cumulative_building_solar_irradiance
1052
+ ##############################################################################
1053
+ def get_cumulative_building_solar_irradiance(
1054
+ voxel_data,
1055
+ meshsize,
1056
+ building_svf_mesh,
1057
+ weather_df,
1058
+ lon, lat, tz,
1059
+ **kwargs
1060
+ ):
1061
+ """
1062
+ Calculate cumulative solar irradiance on building surfaces over a time period.
1063
+ Uses the Numba-accelerated get_building_solar_irradiance for each time step.
1064
+
1065
+ Args:
1066
+ voxel_data (ndarray): 3D array of voxel values.
1067
+ meshsize (float): Size of each voxel in meters.
1068
+ building_svf_mesh (trimesh.Trimesh): Mesh with pre-calculated SVF in metadata.
1069
+ weather_df (DataFrame): Weather data with DNI (W/m²) and DHI (W/m²).
1070
+ lon (float): Longitude in degrees.
1071
+ lat (float): Latitude in degrees.
1072
+ tz (float): Timezone offset in hours.
1073
+ **kwargs: Additional parameters for time range, scaling, OBJ export, etc.
1074
+
1075
+ Returns:
1076
+ trimesh.Trimesh: A mesh with cumulative (Wh/m²) irradiance in metadata.
1077
+ """
1078
+ import pytz
1079
+ from datetime import datetime
1080
+
1081
+ period_start = kwargs.get("period_start", "01-01 00:00:00")
1082
+ period_end = kwargs.get("period_end", "12-31 23:59:59")
1083
+ time_step_hours = kwargs.get("time_step_hours", 1.0)
1084
+ direct_normal_irradiance_scaling = kwargs.get("direct_normal_irradiance_scaling", 1.0)
1085
+ diffuse_irradiance_scaling = kwargs.get("diffuse_irradiance_scaling", 1.0)
1086
+
1087
+ # Parse times, create local tz
1088
+ try:
1089
+ start_dt = datetime.strptime(period_start, "%m-%d %H:%M:%S")
1090
+ end_dt = datetime.strptime(period_end, "%m-%d %H:%M:%S")
1091
+ except ValueError as ve:
1092
+ raise ValueError("Time must be in format 'MM-DD HH:MM:SS'") from ve
1093
+
1094
+ offset_minutes = int(tz * 60)
1095
+ local_tz = pytz.FixedOffset(offset_minutes)
1096
+
1097
+ # Filter weather_df
1098
+ df_period = weather_df[
1099
+ ((weather_df.index.month > start_dt.month) |
1100
+ ((weather_df.index.month == start_dt.month) &
1101
+ (weather_df.index.day >= start_dt.day) &
1102
+ (weather_df.index.hour >= start_dt.hour))) &
1103
+ ((weather_df.index.month < end_dt.month) |
1104
+ ((weather_df.index.month == end_dt.month) &
1105
+ (weather_df.index.day <= end_dt.day) &
1106
+ (weather_df.index.hour <= end_dt.hour)))
1107
+ ]
1108
+ if df_period.empty:
1109
+ raise ValueError("No weather data in specified period.")
1110
+
1111
+ # Convert to local time, then to UTC
1112
+ df_period_local = df_period.copy()
1113
+ df_period_local.index = df_period_local.index.tz_localize(local_tz)
1114
+ df_period_utc = df_period_local.tz_convert(pytz.UTC)
1115
+
1116
+ # Get solar positions
1117
+ # You presumably have a get_solar_positions_astral(...) that returns az/elev
1118
+ solar_positions = get_solar_positions_astral(df_period_utc.index, lon, lat)
1119
+
1120
+ # Prepare arrays for accumulation
1121
+ n_faces = len(building_svf_mesh.faces)
1122
+ face_cum_direct = np.zeros(n_faces, dtype=np.float64)
1123
+ face_cum_diffuse = np.zeros(n_faces, dtype=np.float64)
1124
+ face_cum_global = np.zeros(n_faces, dtype=np.float64)
1125
+
1126
+ boundary_mask = None
1127
+
1128
+ # Iterate over each timestep
1129
+ for idx, (time_utc, row) in enumerate(df_period_utc.iterrows()):
1130
+ DNI = row['DNI'] * direct_normal_irradiance_scaling
1131
+ DHI = row['DHI'] * diffuse_irradiance_scaling
1132
+
1133
+ # Sun angles
1134
+ az_deg = solar_positions.loc[time_utc, 'azimuth']
1135
+ el_deg = solar_positions.loc[time_utc, 'elevation']
1136
+
1137
+ # Skip if sun below horizon
1138
+ if el_deg <= 0:
1139
+ continue
1140
+
1141
+ # Call instantaneous function (Numba-accelerated inside)
1142
+ irr_mesh = get_building_solar_irradiance(
1143
+ voxel_data,
1144
+ meshsize,
1145
+ building_svf_mesh,
1146
+ az_deg,
1147
+ el_deg,
1148
+ DNI,
1149
+ DHI,
1150
+ show_plot=False, # or any other flags
1151
+ **kwargs
1152
+ )
1153
+
1154
+ # Extract arrays
1155
+ face_dir = irr_mesh.metadata['direct']
1156
+ face_diff = irr_mesh.metadata['diffuse']
1157
+ face_glob = irr_mesh.metadata['global']
1158
+
1159
+ # If first time, note boundary mask from NaNs
1160
+ if boundary_mask is None:
1161
+ boundary_mask = np.isnan(face_glob)
1162
+
1163
+ # Convert from W/m² to Wh/m² by multiplying time_step_hours
1164
+ face_cum_direct += np.nan_to_num(face_dir) * time_step_hours
1165
+ face_cum_diffuse += np.nan_to_num(face_diff) * time_step_hours
1166
+ face_cum_global += np.nan_to_num(face_glob) * time_step_hours
1167
+
1168
+ # Reapply NaN for boundary
1169
+ if boundary_mask is not None:
1170
+ face_cum_direct[boundary_mask] = np.nan
1171
+ face_cum_diffuse[boundary_mask] = np.nan
1172
+ face_cum_global[boundary_mask] = np.nan
1173
+
1174
+ # Create a new mesh with cumulative results
1175
+ cumulative_mesh = building_svf_mesh.copy()
1176
+ if not hasattr(cumulative_mesh, 'metadata'):
1177
+ cumulative_mesh.metadata = {}
1178
+
1179
+ # If original mesh had SVF
1180
+ if 'svf_values' in building_svf_mesh.metadata:
1181
+ cumulative_mesh.metadata['svf'] = building_svf_mesh.metadata['svf_values']
1182
+
1183
+ cumulative_mesh.metadata['direct'] = face_cum_direct
1184
+ cumulative_mesh.metadata['diffuse'] = face_cum_diffuse
1185
+ cumulative_mesh.metadata['global'] = face_cum_global
1186
+
1187
+ cumulative_mesh.name = "Cumulative Solar Irradiance (Wh/m²)"
1188
+
1189
+ # Optional export
1190
+ obj_export = kwargs.get("obj_export", False)
1191
+ if obj_export:
1192
+ _export_solar_irradiance_mesh(
1193
+ cumulative_mesh,
1194
+ face_cum_global,
1195
+ **kwargs
1196
+ )
1197
+
1198
+ return cumulative_mesh
1199
+
1200
+ def get_building_global_solar_irradiance_using_epw(
1201
+ voxel_data,
1202
+ meshsize,
1203
+ calc_type='instantaneous',
1204
+ direct_normal_irradiance_scaling=1.0,
1205
+ diffuse_irradiance_scaling=1.0,
1206
+ **kwargs
1207
+ ):
1208
+ """
1209
+ Compute global solar irradiance on building surfaces using EPW weather data, either for a single time or cumulatively.
1210
+
1211
+ The function:
1212
+ 1. Optionally downloads and reads EPW weather data
1213
+ 2. Handles timezone conversions and solar position calculations
1214
+ 3. Computes either instantaneous or cumulative irradiance on building surfaces
1215
+ 4. Supports visualization and export options
1216
+
1217
+ Args:
1218
+ voxel_data (ndarray): 3D array of voxel values.
1219
+ meshsize (float): Size of each voxel in meters.
1220
+ building_svf_mesh (trimesh.Trimesh): Building mesh with pre-calculated SVF values in metadata.
1221
+ calc_type (str): 'instantaneous' or 'cumulative'.
1222
+ direct_normal_irradiance_scaling (float): Scaling factor for direct normal irradiance.
1223
+ diffuse_irradiance_scaling (float): Scaling factor for diffuse horizontal irradiance.
1224
+ **kwargs: Additional arguments including:
1225
+ - download_nearest_epw (bool): Whether to download nearest EPW file
1226
+ - epw_file_path (str): Path to EPW file
1227
+ - rectangle_vertices (list): List of (lon,lat) coordinates for EPW download
1228
+ - output_dir (str): Directory for EPW download
1229
+ - calc_time (str): Time for instantaneous calculation ('MM-DD HH:MM:SS')
1230
+ - period_start (str): Start time for cumulative calculation ('MM-DD HH:MM:SS')
1231
+ - period_end (str): End time for cumulative calculation ('MM-DD HH:MM:SS')
1232
+ - time_step_hours (float): Time step for cumulative calculation
1233
+ - tree_k (float): Tree extinction coefficient
1234
+ - tree_lad (float): Leaf area density in m^-1
1235
+ - show_each_timestep (bool): Whether to show plots for each timestep
1236
+ - nan_color (str): Color for NaN values in visualization
1237
+ - colormap (str): Matplotlib colormap name
1238
+ - vmin (float): Minimum value for colormap
1239
+ - vmax (float): Maximum value for colormap
1240
+ - obj_export (bool): Whether to export as OBJ file
1241
+ - output_directory (str): Directory for OBJ export
1242
+ - output_file_name (str): Filename for OBJ export
1243
+
1244
+ Returns:
1245
+ trimesh.Trimesh: Building mesh with irradiance values stored in metadata.
1246
+ """
1247
+ import numpy as np
1248
+ import pytz
1249
+ from datetime import datetime
1250
+
1251
+ # Get EPW file
1252
+ download_nearest_epw = kwargs.get("download_nearest_epw", False)
1253
+ rectangle_vertices = kwargs.get("rectangle_vertices", None)
1254
+ epw_file_path = kwargs.get("epw_file_path", None)
1255
+ building_id_grid = kwargs.get("building_id_grid", None)
1256
+
1257
+ if download_nearest_epw:
1258
+ if rectangle_vertices is None:
1259
+ print("rectangle_vertices is required to download nearest EPW file")
1260
+ return None
1261
+ else:
1262
+ # Calculate center point of rectangle
1263
+ lons = [coord[0] for coord in rectangle_vertices]
1264
+ lats = [coord[1] for coord in rectangle_vertices]
1265
+ center_lon = (min(lons) + max(lons)) / 2
1266
+ center_lat = (min(lats) + max(lats)) / 2
1267
+
1268
+ # Optional: specify maximum distance in kilometers
1269
+ max_distance = kwargs.get("max_distance", 100) # None for no limit
1270
+ output_dir = kwargs.get("output_dir", "output")
1271
+
1272
+ epw_file_path, weather_data, metadata = get_nearest_epw_from_climate_onebuilding(
1273
+ longitude=center_lon,
1274
+ latitude=center_lat,
1275
+ output_dir=output_dir,
1276
+ max_distance=max_distance,
1277
+ extract_zip=True,
1278
+ load_data=True
1279
+ )
1280
+
1281
+ # Read EPW data
1282
+ df, lon, lat, tz, elevation_m = read_epw_for_solar_simulation(epw_file_path)
1283
+ if df.empty:
1284
+ raise ValueError("No data in EPW file.")
1285
+
1286
+ # Step 1: Calculate Sky View Factor for building surfaces
1287
+ print(f"Processing Sky View Factor for building surfaces...")
1288
+ building_svf_mesh = get_building_surface_svf(
1289
+ voxel_data, # Your 3D voxel grid
1290
+ meshsize, # Size of each voxel in meters
1291
+ building_id_grid=building_id_grid,
1292
+ )
1293
+
1294
+ print(f"Processing Solar Irradiance for building surfaces...")
1295
+ if calc_type == 'instantaneous':
1296
+ calc_time = kwargs.get("calc_time", "01-01 12:00:00")
1297
+
1298
+ # Parse calculation time without year
1299
+ try:
1300
+ calc_dt = datetime.strptime(calc_time, "%m-%d %H:%M:%S")
1301
+ except ValueError as ve:
1302
+ raise ValueError("calc_time must be in format 'MM-DD HH:MM:SS'") from ve
1303
+
1304
+ df_period = df[
1305
+ (df.index.month == calc_dt.month) & (df.index.day == calc_dt.day) & (df.index.hour == calc_dt.hour)
1306
+ ]
1307
+
1308
+ if df_period.empty:
1309
+ raise ValueError("No EPW data at the specified time.")
1310
+
1311
+ # Prepare timezone conversion
1312
+ offset_minutes = int(tz * 60)
1313
+ local_tz = pytz.FixedOffset(offset_minutes)
1314
+ df_period_local = df_period.copy()
1315
+ df_period_local.index = df_period_local.index.tz_localize(local_tz)
1316
+ df_period_utc = df_period_local.tz_convert(pytz.UTC)
1317
+
1318
+ # Compute solar positions
1319
+ solar_positions = get_solar_positions_astral(df_period_utc.index, lon, lat)
1320
+
1321
+ # Scale irradiance values
1322
+ direct_normal_irradiance = df_period_utc.iloc[0]['DNI'] * direct_normal_irradiance_scaling
1323
+ diffuse_irradiance = df_period_utc.iloc[0]['DHI'] * diffuse_irradiance_scaling
1324
+
1325
+ # Get solar position
1326
+ azimuth_degrees = solar_positions.iloc[0]['azimuth']
1327
+ elevation_degrees = solar_positions.iloc[0]['elevation']
1328
+
1329
+ print(f"Time: {df_period_local.index[0].strftime('%Y-%m-%d %H:%M:%S')}")
1330
+ print(f"Sun position: Azimuth {azimuth_degrees:.1f}°, Elevation {elevation_degrees:.1f}°")
1331
+ print(f"DNI: {direct_normal_irradiance:.1f} W/m², DHI: {diffuse_irradiance:.1f} W/m²")
1332
+
1333
+ # Skip if sun is below horizon
1334
+ if elevation_degrees <= 0:
1335
+ print("Sun is below horizon, skipping calculation.")
1336
+ return building_svf_mesh.copy()
1337
+
1338
+ # Compute irradiance
1339
+ irradiance_mesh = get_building_solar_irradiance(
1340
+ voxel_data,
1341
+ meshsize,
1342
+ building_svf_mesh,
1343
+ azimuth_degrees,
1344
+ elevation_degrees,
1345
+ direct_normal_irradiance,
1346
+ diffuse_irradiance,
1347
+ **kwargs
1348
+ )
1349
+
1350
+ return irradiance_mesh
1351
+
1352
+ elif calc_type == 'cumulative':
1353
+ # Set default parameters
1354
+ period_start = kwargs.get("period_start", "01-01 00:00:00")
1355
+ period_end = kwargs.get("period_end", "12-31 23:59:59")
1356
+ time_step_hours = kwargs.get("time_step_hours", 1.0)
1357
+
1358
+ # Parse start and end times without year
1359
+ try:
1360
+ start_dt = datetime.strptime(period_start, "%m-%d %H:%M:%S")
1361
+ end_dt = datetime.strptime(period_end, "%m-%d %H:%M:%S")
1362
+ except ValueError as ve:
1363
+ raise ValueError("Time must be in format 'MM-DD HH:MM:SS'") from ve
1364
+
1365
+ # Create local timezone
1366
+ offset_minutes = int(tz * 60)
1367
+ local_tz = pytz.FixedOffset(offset_minutes)
1368
+
1369
+ # Filter weather data by month, day, hour
1370
+ df_period = df[
1371
+ ((df.index.month > start_dt.month) |
1372
+ ((df.index.month == start_dt.month) & (df.index.day >= start_dt.day) &
1373
+ (df.index.hour >= start_dt.hour))) &
1374
+ ((df.index.month < end_dt.month) |
1375
+ ((df.index.month == end_dt.month) & (df.index.day <= end_dt.day) &
1376
+ (df.index.hour <= end_dt.hour)))
1377
+ ]
1378
+
1379
+ if df_period.empty:
1380
+ raise ValueError("No weather data available for the specified period.")
1381
+
1382
+ # Convert to local timezone and then to UTC for solar position calculation
1383
+ df_period_local = df_period.copy()
1384
+ df_period_local.index = df_period_local.index.tz_localize(local_tz)
1385
+ df_period_utc = df_period_local.tz_convert(pytz.UTC)
1386
+
1387
+ # Get solar positions for all times
1388
+ solar_positions = get_solar_positions_astral(df_period_utc.index, lon, lat)
1389
+
1390
+ # Create a copy of kwargs without time_step_hours to avoid duplicate argument
1391
+ kwargs_copy = kwargs.copy()
1392
+ if 'time_step_hours' in kwargs_copy:
1393
+ del kwargs_copy['time_step_hours']
1394
+
1395
+ # Get cumulative irradiance - adapt to match expected function signature
1396
+ cumulative_mesh = get_cumulative_building_solar_irradiance(
1397
+ voxel_data,
1398
+ meshsize,
1399
+ building_svf_mesh,
1400
+ df, lon, lat, tz, # Pass only the required 7 positional arguments
1401
+ period_start=period_start,
1402
+ period_end=period_end,
1403
+ time_step_hours=time_step_hours,
1404
+ direct_normal_irradiance_scaling=direct_normal_irradiance_scaling,
1405
+ diffuse_irradiance_scaling=diffuse_irradiance_scaling,
1406
+ colormap=kwargs.get('colormap', 'jet'),
1407
+ show_each_timestep=kwargs.get('show_each_timestep', False),
1408
+ obj_export=kwargs.get('obj_export', False),
1409
+ output_directory=kwargs.get('output_directory', 'output'),
1410
+ output_file_name=kwargs.get('output_file_name', 'cumulative_solar')
1411
+ )
1412
+
1413
+ return cumulative_mesh
1414
+
1415
+ else:
1416
+ raise ValueError("calc_type must be either 'instantaneous' or 'cumulative'")