Skip to content

Commit 2f26046

Browse files
committed
Update version to 1.0.22 and enhance sunlight hours functions with location and timezone parameters
1 parent e3bce1c commit 2f26046

4 files changed

Lines changed: 151 additions & 27 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "voxcity"
3-
version = "1.0.21"
3+
version = "1.0.22"
44
description = "voxcity is an easy and one-stop tool to output 3d city models for microclimate simulation by integrating multiple geospatial open-data"
55
readme = "README.md"
66
license = "MIT"

src/voxcity/simulator_gpu/solar/integration/building.py

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
get_solar_positions_astral,
2525
compute_boundary_vertical_mask,
2626
apply_computation_mask_to_faces,
27+
get_timezone_offset_from_location,
28+
generate_annual_hourly_dataframe,
2729
)
2830

2931
from .caching import (
@@ -513,20 +515,29 @@ def get_building_sunlight_hours(
513515
epw_file_path: str = None,
514516
download_nearest_epw: bool = False,
515517
dni_threshold: float = 120.0,
518+
lon: float = None,
519+
lat: float = None,
520+
tz: float = None,
516521
**kwargs
517522
):
518523
"""
519524
GPU-accelerated sunlight hours computation for building surfaces.
520525
521526
Supports PSH (Probable Sunlight Hours) and DSH (Direct Sun Hours) modes.
522527
528+
**DSH mode** does NOT require an EPW file. Location is automatically
529+
extracted from the VoxCity object and timezone is inferred.
530+
523531
Args:
524532
voxcity: VoxCity object
525533
building_svf_mesh: Trimesh object with building surfaces (optional)
526534
mode: 'PSH' or 'DSH'
527-
epw_file_path: Path to EPW file
535+
epw_file_path: Path to EPW file (required for PSH, optional for DSH)
528536
download_nearest_epw: If True, download nearest EPW
529537
dni_threshold: DNI threshold for PSH mode (default: 120.0 W/m²)
538+
lon: Longitude in degrees (optional, extracted from voxcity if not provided)
539+
lat: Latitude in degrees (optional, extracted from voxcity if not provided)
540+
tz: Timezone offset in hours (optional, inferred from location if not provided)
530541
**kwargs: Additional parameters
531542
532543
Returns:
@@ -549,16 +560,37 @@ def get_building_sunlight_hours(
549560
use_sky_patches = kwargs.pop('use_sky_patches', True)
550561
sky_discretization = kwargs.pop('sky_discretization', 'tregenza')
551562

552-
# Load EPW data
553-
weather_df, lon, lat, tz = load_epw_data(
554-
epw_file_path=epw_file_path,
555-
download_nearest_epw=download_nearest_epw,
556-
voxcity=voxcity,
557-
**kwargs
558-
)
559-
560-
if mode == 'PSH' and 'DNI' not in weather_df.columns:
561-
raise ValueError("Weather dataframe must have 'DNI' column for PSH mode.")
563+
# Load data depending on mode
564+
if mode == 'DSH' and epw_file_path is None and not download_nearest_epw:
565+
# DSH mode without EPW: derive location and timezone from voxcity
566+
if lat is None or lon is None:
567+
_lat, _lon = get_location_from_voxcity(voxcity)
568+
if lat is None:
569+
lat = _lat
570+
if lon is None:
571+
lon = _lon
572+
if tz is None:
573+
tz = get_timezone_offset_from_location(lon, lat)
574+
575+
# Generate synthetic annual hourly timestamps
576+
weather_df = generate_annual_hourly_dataframe()
577+
else:
578+
# PSH mode or DSH with EPW provided
579+
weather_df, lon_epw, lat_epw, tz_epw = load_epw_data(
580+
epw_file_path=epw_file_path,
581+
download_nearest_epw=download_nearest_epw,
582+
voxcity=voxcity,
583+
**kwargs
584+
)
585+
if lon is None:
586+
lon = lon_epw
587+
if lat is None:
588+
lat = lat_epw
589+
if tz is None:
590+
tz = tz_epw
591+
592+
if mode == 'PSH' and 'DNI' not in weather_df.columns:
593+
raise ValueError("Weather dataframe must have 'DNI' column for PSH mode.")
562594

563595
# Filter dataframe
564596
df_period_utc = filter_df_to_period(weather_df, period_start, period_end, tz)

src/voxcity/simulator_gpu/solar/integration/ground.py

Lines changed: 54 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@
2626
load_epw_data,
2727
get_solar_positions_astral,
2828
add_metadata_to_array,
29+
get_location_from_voxcity,
30+
get_timezone_offset_from_location,
31+
generate_annual_hourly_dataframe,
2932
)
3033

3134
from .caching import (
@@ -751,6 +754,9 @@ def get_sunlight_hours(
751754
download_nearest_epw: bool = False,
752755
dni_threshold: float = 120.0,
753756
show_plot: bool = False,
757+
lon: float = None,
758+
lat: float = None,
759+
tz: float = None,
754760
**kwargs
755761
) -> np.ndarray:
756762
"""
@@ -759,16 +765,23 @@ def get_sunlight_hours(
759765
Supports two modes:
760766
761767
**PSH (Probable Sunlight Hours)**: Uses EPW weather data to account for cloud cover.
768+
Requires an EPW file (via epw_file_path or download_nearest_epw).
762769
763770
**DSH (Direct Sun Hours)**: Assumes clear sky for all hours.
771+
Does NOT require an EPW file. Location (lon/lat) is automatically
772+
extracted from the VoxCity object, and timezone is inferred from
773+
the location. These can be overridden via the lon, lat, tz parameters.
764774
765775
Args:
766776
voxcity: VoxCity object
767777
mode: 'PSH' (Probable Sunlight Hours) or 'DSH' (Direct Sun Hours)
768-
epw_file_path: Path to EPW file
778+
epw_file_path: Path to EPW file (required for PSH, optional for DSH)
769779
download_nearest_epw: If True, download nearest EPW based on location
770780
dni_threshold: DNI threshold for PSH mode (default: 120.0 W/m², WMO standard)
771781
show_plot: Whether to display a matplotlib plot
782+
lon: Longitude in degrees (optional, extracted from voxcity if not provided)
783+
lat: Latitude in degrees (optional, extracted from voxcity if not provided)
784+
tz: Timezone offset in hours (optional, inferred from location if not provided)
772785
**kwargs: Additional parameters
773786
774787
Returns:
@@ -793,20 +806,46 @@ def get_sunlight_hours(
793806
use_sky_patches = kwargs.pop('use_sky_patches', True)
794807
sky_discretization = kwargs.pop('sky_discretization', 'tregenza')
795808

796-
# Load EPW data
797-
weather_df, lon, lat, tz = load_epw_data(
798-
epw_file_path=epw_file_path,
799-
download_nearest_epw=download_nearest_epw,
800-
voxcity=voxcity,
801-
**kwargs
802-
)
803-
804-
if progress_report:
805-
print(f" Mode: {mode}")
806-
print(f" Location: lon={lon:.4f}, lat={lat:.4f}, tz={tz}")
807-
808-
if mode == 'PSH' and 'DNI' not in weather_df.columns:
809-
raise ValueError("Weather dataframe must have 'DNI' column for PSH mode.")
809+
# Load data depending on mode
810+
if mode == 'DSH' and epw_file_path is None and not download_nearest_epw:
811+
# DSH mode without EPW: derive location and timezone from voxcity
812+
if lat is None or lon is None:
813+
_lat, _lon = get_location_from_voxcity(voxcity)
814+
if lat is None:
815+
lat = _lat
816+
if lon is None:
817+
lon = _lon
818+
if tz is None:
819+
tz = get_timezone_offset_from_location(lon, lat)
820+
821+
# Generate synthetic annual hourly timestamps
822+
weather_df = generate_annual_hourly_dataframe()
823+
824+
if progress_report:
825+
print(f" Mode: {mode} (no EPW file needed)")
826+
print(f" Location: lon={lon:.4f}, lat={lat:.4f}, tz={tz}")
827+
else:
828+
# PSH mode or DSH with EPW provided
829+
weather_df, lon_epw, lat_epw, tz_epw = load_epw_data(
830+
epw_file_path=epw_file_path,
831+
download_nearest_epw=download_nearest_epw,
832+
voxcity=voxcity,
833+
**kwargs
834+
)
835+
# Use EPW values as defaults, but allow user overrides
836+
if lon is None:
837+
lon = lon_epw
838+
if lat is None:
839+
lat = lat_epw
840+
if tz is None:
841+
tz = tz_epw
842+
843+
if progress_report:
844+
print(f" Mode: {mode}")
845+
print(f" Location: lon={lon:.4f}, lat={lat:.4f}, tz={tz}")
846+
847+
if mode == 'PSH' and 'DNI' not in weather_df.columns:
848+
raise ValueError("Weather dataframe must have 'DNI' column for PSH mode.")
810849

811850
# Filter dataframe to period
812851
df_period_utc = filter_df_to_period(weather_df, period_start, period_end, tz)

src/voxcity/simulator_gpu/solar/integration/utils.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,59 @@ def get_hour_range_from_period(start_time: str, end_time: str) -> Tuple[int, int
302302
# EPW Data Loading
303303
# =============================================================================
304304

305+
def get_timezone_offset_from_location(lon: float, lat: float) -> float:
306+
"""
307+
Get UTC timezone offset (in hours) from longitude/latitude using timezonefinder.
308+
309+
Falls back to a simple longitude-based estimate if timezonefinder is not available.
310+
311+
Args:
312+
lon: Longitude in degrees
313+
lat: Latitude in degrees
314+
315+
Returns:
316+
Timezone offset in hours (e.g. 9.0 for JST, -5.0 for EST)
317+
"""
318+
try:
319+
from timezonefinder import TimezoneFinder
320+
import pytz
321+
tf = TimezoneFinder()
322+
tz_str = tf.timezone_at(lng=lon, lat=lat)
323+
if tz_str:
324+
timezone = pytz.timezone(tz_str)
325+
offset_seconds = timezone.utcoffset(datetime(2020, 6, 15)).total_seconds()
326+
return offset_seconds / 3600.0
327+
except ImportError:
328+
pass
329+
# Fallback: estimate from longitude (each 15° ≈ 1 hour)
330+
return round(lon / 15.0)
331+
332+
333+
def generate_annual_hourly_dataframe(year: int = 2020):
334+
"""
335+
Generate a pandas DataFrame with hourly timestamps for a full year.
336+
337+
The DataFrame has a datetime index (timezone-naive) and no weather columns,
338+
suitable for DSH (Direct Sun Hours) calculations that only need solar
339+
position data and do not require weather/EPW data.
340+
341+
Args:
342+
year: The year to generate timestamps for (default: 2020, a non-leap year
343+
is fine since solar geometry varies negligibly between years)
344+
345+
Returns:
346+
pandas DataFrame with hourly datetime index spanning the full year
347+
"""
348+
import pandas as pd
349+
times = pd.date_range(
350+
start=f'{year}-01-01 00:00:00',
351+
end=f'{year}-12-31 23:00:00',
352+
freq='h'
353+
)
354+
df = pd.DataFrame(index=times)
355+
return df
356+
357+
305358
def load_epw_data(
306359
epw_file_path: Optional[str] = None,
307360
download_nearest_epw: bool = False,

0 commit comments

Comments
 (0)