Skip to content

Commit 81a0cb9

Browse files
committed
Merge feat/custom-solar-daily-hour: daily hour-of-day window for cumulative solar
2 parents a7528cb + 683b240 commit 81a0cb9

5 files changed

Lines changed: 160 additions & 4 deletions

File tree

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,8 @@ def get_cumulative_building_solar_irradiance(
405405
kwargs = dict(kwargs)
406406
period_start = kwargs.pop('period_start', '01-01 00:00:00')
407407
period_end = kwargs.pop('period_end', '12-31 23:59:59')
408+
daily_start_hour = kwargs.pop('daily_start_hour', None)
409+
daily_end_hour = kwargs.pop('daily_end_hour', None)
408410
time_step_hours = float(kwargs.pop('time_step_hours', 1.0))
409411
progress_report = kwargs.pop('progress_report', False)
410412
use_sky_patches = kwargs.pop('use_sky_patches', False)
@@ -415,7 +417,9 @@ def get_cumulative_building_solar_irradiance(
415417
raise ValueError("No data in weather dataframe.")
416418

417419
# Filter dataframe
418-
df_period_utc = filter_df_to_period(weather_df, period_start, period_end, tz)
420+
df_period_utc = filter_df_to_period(weather_df, period_start, period_end, tz,
421+
daily_start_hour=daily_start_hour,
422+
daily_end_hour=daily_end_hour)
419423

420424
# Get solar positions
421425
solar_positions = get_solar_positions_astral(df_period_utc.index, lon, lat)
@@ -660,6 +664,8 @@ def get_building_sunlight_hours(
660664
kwargs = dict(kwargs)
661665
period_start = kwargs.pop('period_start', '01-01 00:00:00')
662666
period_end = kwargs.pop('period_end', '12-31 23:59:59')
667+
daily_start_hour = kwargs.pop('daily_start_hour', None)
668+
daily_end_hour = kwargs.pop('daily_end_hour', None)
663669
time_step_hours = float(kwargs.pop('time_step_hours', 1.0))
664670
progress_report = kwargs.pop('progress_report', False)
665671
computation_mask = kwargs.pop('computation_mask', None)
@@ -700,7 +706,9 @@ def get_building_sunlight_hours(
700706
raise ValueError("Weather dataframe must have 'DNI' column for PSH mode.")
701707

702708
# Filter dataframe
703-
df_period_utc = filter_df_to_period(weather_df, period_start, period_end, tz)
709+
df_period_utc = filter_df_to_period(weather_df, period_start, period_end, tz,
710+
daily_start_hour=daily_start_hour,
711+
daily_end_hour=daily_end_hour)
704712

705713
# Get solar positions
706714
solar_positions = get_solar_positions_astral(df_period_utc.index, lon, lat)

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,8 @@ def get_cumulative_global_solar_irradiance(
493493
- computation_mask (np.ndarray): Optional 2D boolean mask
494494
- start_time (str): Start time 'MM-DD HH:MM:SS'
495495
- end_time (str): End time 'MM-DD HH:MM:SS'
496+
- daily_start_hour (int): Optional inclusive hour-of-day lower bound (0-23)
497+
- daily_end_hour (int): Optional inclusive hour-of-day upper bound (0-23)
496498
- view_point_height (float): Observer height
497499
- use_sky_patches (bool): Use sky patch optimization (default: True)
498500
- sky_discretization (str): 'tregenza', 'reinhart', etc.
@@ -511,6 +513,8 @@ def get_cumulative_global_solar_irradiance(
511513
colormap = kwargs.pop('colormap', 'magma')
512514
start_time = kwargs.pop('start_time', '01-01 05:00:00')
513515
end_time = kwargs.pop('end_time', '01-01 20:00:00')
516+
daily_start_hour = kwargs.pop('daily_start_hour', None)
517+
daily_end_hour = kwargs.pop('daily_end_hour', None)
514518
progress_report = kwargs.pop('progress_report', False)
515519
use_sky_patches = kwargs.pop('use_sky_patches', True)
516520
sky_discretization = kwargs.pop('sky_discretization', 'tregenza')
@@ -519,7 +523,9 @@ def get_cumulative_global_solar_irradiance(
519523
raise ValueError("No data in EPW dataframe.")
520524

521525
# Filter dataframe to period
522-
df_period_utc = filter_df_to_period(df, start_time, end_time, tz)
526+
df_period_utc = filter_df_to_period(df, start_time, end_time, tz,
527+
daily_start_hour=daily_start_hour,
528+
daily_end_hour=daily_end_hour)
523529

524530
# Get solar positions
525531
solar_positions = get_solar_positions_astral(df_period_utc.index, lon, lat)

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ def parse_time_period(
233233
return start_dt, end_dt
234234

235235

236-
def filter_df_to_period(df, start_time: str, end_time: str, tz: float):
236+
def filter_df_to_period(df, start_time: str, end_time: str, tz: float, daily_start_hour: Optional[int] = None, daily_end_hour: Optional[int] = None):
237237
"""
238238
Filter weather DataFrame to specified time period and convert to UTC.
239239
@@ -242,6 +242,11 @@ def filter_df_to_period(df, start_time: str, end_time: str, tz: float):
242242
start_time: Start time in format 'MM-DD HH:MM:SS'
243243
end_time: End time in format 'MM-DD HH:MM:SS'
244244
tz: Timezone offset in hours
245+
daily_start_hour: Optional inclusive hour-of-day lower bound (0-23) for a
246+
daily window crossed with the date range. None disables the daily filter.
247+
daily_end_hour: Optional inclusive hour-of-day upper bound (0-23). None
248+
disables the daily filter. Supports wraparound when start > end
249+
(e.g. 22-3 spans overnight).
245250
246251
Returns:
247252
Tuple of (df_period_utc, df with hour_of_year column)
@@ -269,6 +274,14 @@ def filter_df_to_period(df, start_time: str, end_time: str, tz: float):
269274
else:
270275
df_period = df[(df['hour_of_year'] >= start_hour) | (df['hour_of_year'] <= end_hour)]
271276

277+
# Optional daily hour-of-day window (crossed with the date range above).
278+
if daily_start_hour is not None and daily_end_hour is not None:
279+
hod = df_period.index.hour
280+
if daily_start_hour <= daily_end_hour:
281+
df_period = df_period[(hod >= daily_start_hour) & (hod <= daily_end_hour)]
282+
else: # wraparound, mirrors the continuous-span branch above
283+
df_period = df_period[(hod >= daily_start_hour) | (hod <= daily_end_hour)]
284+
272285
if df_period.empty:
273286
raise ValueError("No weather data in the specified period.")
274287

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,8 @@ def get_cumulative_volumetric_solar_irradiance(
367367
**kwargs: Additional parameters:
368368
- start_time (str): Start time 'MM-DD HH:MM:SS' (default: '01-01 05:00:00')
369369
- end_time (str): End time 'MM-DD HH:MM:SS' (default: '01-01 20:00:00')
370+
- daily_start_hour (int): Optional inclusive hour-of-day lower bound (0-23)
371+
- daily_end_hour (int): Optional inclusive hour-of-day upper bound (0-23)
370372
- use_sky_patches (bool): Use sky patch optimization (default: True)
371373
- sky_discretization (str): 'tregenza', 'reinhart', 'uniform', 'fibonacci'
372374
- computation_mask (np.ndarray): Optional 2D boolean mask of shape (nx, ny).
@@ -382,6 +384,8 @@ def get_cumulative_volumetric_solar_irradiance(
382384
progress_report = kwargs.pop('progress_report', False)
383385
start_time = kwargs.pop('start_time', '01-01 05:00:00')
384386
end_time = kwargs.pop('end_time', '01-01 20:00:00')
387+
daily_start_hour = kwargs.pop('daily_start_hour', None)
388+
daily_end_hour = kwargs.pop('daily_end_hour', None)
385389
use_sky_patches = kwargs.pop('use_sky_patches', True)
386390
sky_discretization = kwargs.pop('sky_discretization', 'tregenza')
387391
n_azimuth = kwargs.pop('n_azimuth', 36)
@@ -412,6 +416,16 @@ def get_cumulative_volumetric_solar_irradiance(
412416
else:
413417
df_period = df[(df['hour_of_year'] >= start_hour) | (df['hour_of_year'] <= end_hour)]
414418

419+
# Optional daily hour-of-day window (crossed with the date range above).
420+
# Mirrors filter_df_to_period in integration/utils.py; applied on the local
421+
# naive index before UTC conversion.
422+
if daily_start_hour is not None and daily_end_hour is not None:
423+
hod = df_period.index.hour
424+
if daily_start_hour <= daily_end_hour:
425+
df_period = df_period[(hod >= daily_start_hour) & (hod <= daily_end_hour)]
426+
else: # wraparound, mirrors the continuous-span branch above
427+
df_period = df_period[(hod >= daily_start_hour) | (hod <= daily_end_hour)]
428+
415429
if df_period.empty:
416430
raise ValueError("No EPW data in the specified period.")
417431

tests/test_simulator_solar_temporal.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,3 +320,118 @@ def test_active_patches_count(self):
320320

321321
# Only one patch should be active
322322
assert result['n_active_patches'] == 1
323+
324+
325+
import pandas as pd
326+
from voxcity.simulator_gpu.solar.integration.utils import filter_df_to_period
327+
328+
329+
def _year_df():
330+
idx = pd.date_range("2000-01-01 00:00:00", "2000-12-31 23:00:00", freq="h")
331+
return pd.DataFrame({"DNI": 1.0, "DHI": 1.0}, index=idx)
332+
333+
334+
def test_filter_df_daily_hour_window_crosses_date_range():
335+
df = _year_df()
336+
out = filter_df_to_period(
337+
df, "07-01 00:00:00", "08-31 23:00:00", tz=0.0,
338+
daily_start_hour=6, daily_end_hour=11,
339+
)
340+
hours = out.index.hour.unique().tolist()
341+
assert min(hours) >= 6 and max(hours) <= 11
342+
months = out.index.month.unique().tolist()
343+
assert set(months) == {7, 8}
344+
345+
346+
def test_filter_df_daily_hour_wraparound():
347+
df = _year_df()
348+
out = filter_df_to_period(
349+
df, "07-01 00:00:00", "07-02 23:00:00", tz=0.0,
350+
daily_start_hour=22, daily_end_hour=3,
351+
)
352+
assert set(out.index.hour.unique().tolist()) <= {22, 23, 0, 1, 2, 3}
353+
354+
355+
def test_filter_df_none_daily_hours_unchanged():
356+
df = _year_df()
357+
out = filter_df_to_period(df, "07-01 06:00:00", "07-01 11:00:00", tz=0.0)
358+
# Continuous span: all 6 hours present, no daily narrowing.
359+
assert len(out) == 6
360+
361+
362+
from unittest.mock import patch
363+
364+
365+
def test_cumulative_global_forwards_daily_hours():
366+
"""Ground cumulative entry point must forward daily hour kwargs to filter_df_to_period."""
367+
import voxcity.simulator_gpu.solar.integration as integ
368+
# Patch filter_df_to_period as resolved inside the caller's own module.
369+
target_module = integ.get_cumulative_global_solar_irradiance.__module__
370+
with patch(f"{target_module}.filter_df_to_period") as mock_filter:
371+
# Stop execution right after the filter call so no heavy work runs.
372+
mock_filter.side_effect = RuntimeError("stop after filter")
373+
try:
374+
integ.get_cumulative_global_solar_irradiance(
375+
voxcity=None, df=_year_df(), lon=0.0, lat=0.0, tz=0.0,
376+
start_time="07-01 00:00:00", end_time="08-31 23:00:00",
377+
daily_start_hour=6, daily_end_hour=11,
378+
)
379+
except Exception:
380+
pass
381+
assert mock_filter.called, "filter_df_to_period was not reached"
382+
_, called_kwargs = mock_filter.call_args
383+
assert called_kwargs.get("daily_start_hour") == 6
384+
assert called_kwargs.get("daily_end_hour") == 11
385+
386+
387+
def test_cumulative_volumetric_applies_daily_hours():
388+
"""Volumetric cumulative entry point filters inline; verify the daily window is applied.
389+
390+
volumetric.get_cumulative_volumetric_solar_irradiance does NOT call
391+
filter_df_to_period (it filters inline), so we prove the daily-hour bounds
392+
reach the filtering by inspecting the timestamps passed to the first
393+
downstream consumer (get_solar_positions_astral).
394+
"""
395+
import voxcity.simulator_gpu.solar.integration as integ
396+
mod = integ.get_cumulative_volumetric_solar_irradiance.__module__
397+
captured = {}
398+
399+
def fake_solar_positions(index, lon, lat):
400+
captured["hours"] = sorted(set(index.hour.tolist()))
401+
raise RuntimeError("stop after filter")
402+
403+
with patch(f"{mod}.get_solar_positions_astral", side_effect=fake_solar_positions):
404+
try:
405+
integ.get_cumulative_volumetric_solar_irradiance(
406+
voxcity=None, df=_year_df(), lon=0.0, lat=0.0, tz=0.0,
407+
start_time="07-01 00:00:00", end_time="08-31 23:00:00",
408+
daily_start_hour=6, daily_end_hour=11,
409+
)
410+
except Exception:
411+
pass
412+
assert captured.get("hours"), "get_solar_positions_astral was not reached"
413+
assert set(captured["hours"]) == {6, 7, 8, 9, 10, 11}
414+
415+
416+
def test_building_cumulative_forwards_daily_hours():
417+
"""Building-surface cumulative entry point must forward daily hour kwargs
418+
to filter_df_to_period."""
419+
import voxcity.simulator_gpu.solar.integration.building as bld
420+
with patch.object(bld, "filter_df_to_period") as mock_filter:
421+
# Stop execution right after the filter call so no heavy work runs.
422+
mock_filter.side_effect = RuntimeError("stop after filter")
423+
try:
424+
bld.get_cumulative_building_solar_irradiance(
425+
voxcity=None,
426+
building_svf_mesh=None,
427+
weather_df=_year_df(),
428+
lon=0.0, lat=0.0, tz=0.0,
429+
period_start="07-01 00:00:00", period_end="08-31 23:00:00",
430+
daily_start_hour=6, daily_end_hour=11,
431+
)
432+
except Exception:
433+
pass
434+
assert mock_filter.called, "filter_df_to_period was not reached"
435+
_, called_kwargs = mock_filter.call_args
436+
assert called_kwargs.get("daily_start_hour") == 6
437+
assert called_kwargs.get("daily_end_hour") == 11

0 commit comments

Comments
 (0)