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.
- voxcity/exporter/obj.py +2 -2
- voxcity/generator.py +17 -4
- voxcity/geoprocessor/grid.py +5 -1
- voxcity/geoprocessor/mesh.py +21 -1
- voxcity/geoprocessor/polygon.py +95 -1
- voxcity/simulator/solar.py +656 -7
- voxcity/simulator/view.py +635 -2
- voxcity/utils/visualization.py +767 -168
- {voxcity-0.3.25.dist-info → voxcity-0.4.1.dist-info}/METADATA +13 -13
- {voxcity-0.3.25.dist-info → voxcity-0.4.1.dist-info}/RECORD +14 -14
- {voxcity-0.3.25.dist-info → voxcity-0.4.1.dist-info}/WHEEL +1 -1
- {voxcity-0.3.25.dist-info → voxcity-0.4.1.dist-info}/AUTHORS.rst +0 -0
- {voxcity-0.3.25.dist-info → voxcity-0.4.1.dist-info}/LICENSE +0 -0
- {voxcity-0.3.25.dist-info → voxcity-0.4.1.dist-info}/top_level.txt +0 -0
voxcity/simulator/solar.py
CHANGED
|
@@ -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,
|
|
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,
|
|
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,
|
|
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'")
|