voxcity 0.6.26__py3-none-any.whl → 1.0.2__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.
- voxcity/__init__.py +10 -4
- voxcity/downloader/__init__.py +2 -1
- voxcity/downloader/gba.py +210 -0
- voxcity/downloader/gee.py +5 -1
- voxcity/downloader/mbfp.py +1 -1
- voxcity/downloader/oemj.py +80 -8
- voxcity/downloader/utils.py +73 -73
- voxcity/errors.py +30 -0
- voxcity/exporter/__init__.py +9 -1
- voxcity/exporter/cityles.py +129 -34
- voxcity/exporter/envimet.py +51 -26
- voxcity/exporter/magicavoxel.py +42 -5
- voxcity/exporter/netcdf.py +27 -0
- voxcity/exporter/obj.py +103 -28
- voxcity/generator/__init__.py +47 -0
- voxcity/generator/api.py +721 -0
- voxcity/generator/grids.py +381 -0
- voxcity/generator/io.py +94 -0
- voxcity/generator/pipeline.py +282 -0
- voxcity/generator/update.py +429 -0
- voxcity/generator/voxelizer.py +392 -0
- voxcity/geoprocessor/__init__.py +75 -6
- voxcity/geoprocessor/conversion.py +153 -0
- voxcity/geoprocessor/draw.py +1488 -1169
- voxcity/geoprocessor/heights.py +199 -0
- voxcity/geoprocessor/io.py +101 -0
- voxcity/geoprocessor/merge_utils.py +91 -0
- voxcity/geoprocessor/mesh.py +26 -10
- voxcity/geoprocessor/network.py +35 -6
- voxcity/geoprocessor/overlap.py +84 -0
- voxcity/geoprocessor/raster/__init__.py +82 -0
- voxcity/geoprocessor/raster/buildings.py +435 -0
- voxcity/geoprocessor/raster/canopy.py +258 -0
- voxcity/geoprocessor/raster/core.py +150 -0
- voxcity/geoprocessor/raster/export.py +93 -0
- voxcity/geoprocessor/raster/landcover.py +159 -0
- voxcity/geoprocessor/raster/raster.py +110 -0
- voxcity/geoprocessor/selection.py +85 -0
- voxcity/geoprocessor/utils.py +824 -820
- voxcity/models.py +113 -0
- voxcity/simulator/common/__init__.py +22 -0
- voxcity/simulator/common/geometry.py +98 -0
- voxcity/simulator/common/raytracing.py +450 -0
- voxcity/simulator/solar/__init__.py +66 -0
- voxcity/simulator/solar/integration.py +336 -0
- voxcity/simulator/solar/kernels.py +62 -0
- voxcity/simulator/solar/radiation.py +648 -0
- voxcity/simulator/solar/sky.py +668 -0
- voxcity/simulator/solar/temporal.py +792 -0
- voxcity/simulator/view.py +36 -2286
- voxcity/simulator/visibility/__init__.py +29 -0
- voxcity/simulator/visibility/landmark.py +392 -0
- voxcity/simulator/visibility/view.py +508 -0
- voxcity/utils/__init__.py +11 -0
- voxcity/utils/classes.py +194 -0
- voxcity/utils/lc.py +80 -39
- voxcity/utils/logging.py +61 -0
- voxcity/utils/orientation.py +51 -0
- voxcity/utils/shape.py +230 -0
- voxcity/utils/weather/__init__.py +26 -0
- voxcity/utils/weather/epw.py +146 -0
- voxcity/utils/weather/files.py +36 -0
- voxcity/utils/weather/onebuilding.py +486 -0
- voxcity/visualizer/__init__.py +24 -0
- voxcity/visualizer/builder.py +43 -0
- voxcity/visualizer/grids.py +141 -0
- voxcity/visualizer/maps.py +187 -0
- voxcity/visualizer/palette.py +228 -0
- voxcity/visualizer/renderer.py +1145 -0
- {voxcity-0.6.26.dist-info → voxcity-1.0.2.dist-info}/METADATA +162 -48
- voxcity-1.0.2.dist-info/RECORD +81 -0
- voxcity/generator.py +0 -1302
- voxcity/geoprocessor/grid.py +0 -1739
- voxcity/geoprocessor/polygon.py +0 -1344
- voxcity/simulator/solar.py +0 -2339
- voxcity/utils/visualization.py +0 -2849
- voxcity/utils/weather.py +0 -1038
- voxcity-0.6.26.dist-info/RECORD +0 -38
- {voxcity-0.6.26.dist-info → voxcity-1.0.2.dist-info}/WHEEL +0 -0
- {voxcity-0.6.26.dist-info → voxcity-1.0.2.dist-info}/licenses/AUTHORS.rst +0 -0
- {voxcity-0.6.26.dist-info → voxcity-1.0.2.dist-info}/licenses/LICENSE +0 -0
voxcity/utils/shape.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Tuple, Optional, Dict, Any, Callable
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from ..models import VoxCity, VoxelGrid, BuildingGrid, LandCoverGrid, DemGrid, CanopyGrid
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _compute_center_crop_indices(size: int, target: int) -> Tuple[int, int]:
|
|
11
|
+
if size <= target:
|
|
12
|
+
return 0, size
|
|
13
|
+
start = max(0, (size - target) // 2)
|
|
14
|
+
end = start + target
|
|
15
|
+
return start, end
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _pad_split(total_pad: int) -> Tuple[int, int]:
|
|
19
|
+
# Split padding for centering; put extra on the bottom/right side
|
|
20
|
+
a = total_pad // 2
|
|
21
|
+
b = total_pad - a
|
|
22
|
+
return a, b
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _pad_crop_2d(
|
|
26
|
+
arr: np.ndarray,
|
|
27
|
+
target_xy: Tuple[int, int],
|
|
28
|
+
pad_value: Any,
|
|
29
|
+
align_xy: str = "center",
|
|
30
|
+
allow_crop_xy: bool = True,
|
|
31
|
+
) -> np.ndarray:
|
|
32
|
+
x, y = arr.shape[:2]
|
|
33
|
+
tx, ty = int(target_xy[0]), int(target_xy[1])
|
|
34
|
+
|
|
35
|
+
# Crop (center) if needed and allowed
|
|
36
|
+
if allow_crop_xy and (x > tx or y > ty):
|
|
37
|
+
if align_xy == "center":
|
|
38
|
+
xs, xe = _compute_center_crop_indices(x, tx) if x > tx else (0, x)
|
|
39
|
+
ys, ye = _compute_center_crop_indices(y, ty) if y > ty else (0, y)
|
|
40
|
+
else:
|
|
41
|
+
# top-left alignment: crop from bottom/right only
|
|
42
|
+
xs, xe = (0, tx) if x > tx else (0, x)
|
|
43
|
+
ys, ye = (0, ty) if y > ty else (0, y)
|
|
44
|
+
arr = arr[xs:xe, ys:ye]
|
|
45
|
+
x, y = arr.shape[:2]
|
|
46
|
+
|
|
47
|
+
# Pad to target
|
|
48
|
+
px = max(0, tx - x)
|
|
49
|
+
py = max(0, ty - y)
|
|
50
|
+
|
|
51
|
+
if px == 0 and py == 0:
|
|
52
|
+
return arr
|
|
53
|
+
|
|
54
|
+
if align_xy == "center":
|
|
55
|
+
px0, px1 = _pad_split(px)
|
|
56
|
+
py0, py1 = _pad_split(py)
|
|
57
|
+
else:
|
|
58
|
+
# top-left: pad only on bottom/right
|
|
59
|
+
px0, px1 = 0, px
|
|
60
|
+
py0, py1 = 0, py
|
|
61
|
+
|
|
62
|
+
if arr.ndim == 2:
|
|
63
|
+
pad_width = ((px0, px1), (py0, py1))
|
|
64
|
+
else:
|
|
65
|
+
# Preserve trailing dims (e.g., channels)
|
|
66
|
+
pad_width = ((px0, px1), (py0, py1)) + tuple((0, 0) for _ in range(arr.ndim - 2))
|
|
67
|
+
|
|
68
|
+
return np.pad(arr, pad_width, mode="constant", constant_values=pad_value)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _pad_crop_3d_zbottom(
|
|
72
|
+
arr: np.ndarray,
|
|
73
|
+
target_shape: Tuple[int, int, int],
|
|
74
|
+
pad_value: Any,
|
|
75
|
+
align_xy: str = "center",
|
|
76
|
+
allow_crop_xy: bool = True,
|
|
77
|
+
allow_crop_z: bool = False,
|
|
78
|
+
) -> np.ndarray:
|
|
79
|
+
nx, ny, nz = arr.shape
|
|
80
|
+
tx, ty, tz = int(target_shape[0]), int(target_shape[1]), int(target_shape[2])
|
|
81
|
+
|
|
82
|
+
# XY crop/pad
|
|
83
|
+
arr_xy = _pad_crop_2d(arr, (tx, ty), pad_value, align_xy=align_xy, allow_crop_xy=allow_crop_xy)
|
|
84
|
+
nx, ny, nz = arr_xy.shape
|
|
85
|
+
|
|
86
|
+
# Z handling: keep ground at z=0; pad only at the top by default
|
|
87
|
+
if nz > tz:
|
|
88
|
+
if allow_crop_z:
|
|
89
|
+
arr_xy = arr_xy[:, :, :tz]
|
|
90
|
+
else:
|
|
91
|
+
tz = nz # expand target to avoid cropping
|
|
92
|
+
elif nz < tz:
|
|
93
|
+
pad_top = tz - nz # add empty air above
|
|
94
|
+
arr_xy = np.pad(arr_xy, ((0, 0), (0, 0), (0, pad_top)), mode="constant", constant_values=pad_value)
|
|
95
|
+
|
|
96
|
+
return arr_xy
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def normalize_voxcity_shape(
|
|
100
|
+
city: VoxCity,
|
|
101
|
+
target_shape: Tuple[int, int, int],
|
|
102
|
+
*,
|
|
103
|
+
align_xy: str = "center",
|
|
104
|
+
pad_values: Optional[Dict[str, Any]] = None,
|
|
105
|
+
allow_crop_xy: bool = True,
|
|
106
|
+
allow_crop_z: bool = False,
|
|
107
|
+
) -> VoxCity:
|
|
108
|
+
"""
|
|
109
|
+
Return a new VoxCity with arrays padded/cropped to target (x, y, z).
|
|
110
|
+
|
|
111
|
+
- XY alignment can be 'center' (default) or 'top-left'.
|
|
112
|
+
- Z padding is added at the TOP to preserve ground level at z=0.
|
|
113
|
+
- By default, Z is never cropped (allow_crop_z=False). If target z is smaller than current,
|
|
114
|
+
target z is expanded to current to avoid losing data.
|
|
115
|
+
"""
|
|
116
|
+
if pad_values is None:
|
|
117
|
+
pad_values = {}
|
|
118
|
+
|
|
119
|
+
# Resolve pad values for each layer
|
|
120
|
+
pv_vox = pad_values.get("voxels", 0)
|
|
121
|
+
pv_lc = pad_values.get("land_cover", 0)
|
|
122
|
+
pv_dem = pad_values.get("dem", 0.0)
|
|
123
|
+
pv_bh = pad_values.get("building_heights", 0.0)
|
|
124
|
+
pv_bid = pad_values.get("building_ids", 0)
|
|
125
|
+
pv_canopy = pad_values.get("canopy", 0.0)
|
|
126
|
+
pv_bmin = pad_values.get("building_min_heights_factory", None) # callable creating empty cell, default []
|
|
127
|
+
if pv_bmin is None:
|
|
128
|
+
def _empty_list() -> list:
|
|
129
|
+
return []
|
|
130
|
+
pv_bmin = _empty_list
|
|
131
|
+
elif not callable(pv_bmin):
|
|
132
|
+
const_val = pv_bmin
|
|
133
|
+
pv_bmin = (lambda v=const_val: v)
|
|
134
|
+
|
|
135
|
+
# Source arrays
|
|
136
|
+
vox = city.voxels.classes
|
|
137
|
+
bh = city.buildings.heights
|
|
138
|
+
bmin = city.buildings.min_heights
|
|
139
|
+
bid = city.buildings.ids
|
|
140
|
+
lc = city.land_cover.classes
|
|
141
|
+
dem = city.dem.elevation
|
|
142
|
+
can_top = city.tree_canopy.top
|
|
143
|
+
can_bot = city.tree_canopy.bottom if city.tree_canopy.bottom is not None else None
|
|
144
|
+
|
|
145
|
+
# Normalize shapes
|
|
146
|
+
vox_n = _pad_crop_3d_zbottom(
|
|
147
|
+
vox.astype(vox.dtype, copy=False),
|
|
148
|
+
target_shape,
|
|
149
|
+
pad_value=np.array(pv_vox, dtype=vox.dtype),
|
|
150
|
+
align_xy=align_xy,
|
|
151
|
+
allow_crop_xy=allow_crop_xy,
|
|
152
|
+
allow_crop_z=allow_crop_z,
|
|
153
|
+
)
|
|
154
|
+
tx, ty, tz = vox_n.shape
|
|
155
|
+
|
|
156
|
+
def _pad2d(a: np.ndarray, pad_val: Any) -> np.ndarray:
|
|
157
|
+
return _pad_crop_2d(
|
|
158
|
+
a.astype(a.dtype, copy=False),
|
|
159
|
+
(tx, ty),
|
|
160
|
+
pad_value=np.array(pad_val, dtype=a.dtype) if not isinstance(pad_val, (list, tuple, dict)) else pad_val,
|
|
161
|
+
align_xy=align_xy,
|
|
162
|
+
allow_crop_xy=allow_crop_xy,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
bh_n = _pad2d(bh, pv_bh)
|
|
166
|
+
bid_n = _pad2d(bid, pv_bid)
|
|
167
|
+
lc_n = _pad2d(lc, pv_lc)
|
|
168
|
+
dem_n = _pad2d(dem, pv_dem)
|
|
169
|
+
can_top_n = _pad2d(can_top, pv_canopy)
|
|
170
|
+
can_bot_n = _pad2d(can_bot, pv_canopy) if can_bot is not None else None # type: ignore
|
|
171
|
+
|
|
172
|
+
# Object-dtype 2D padding/cropping for min_heights
|
|
173
|
+
# Center-crop if needed, then pad with empty lists
|
|
174
|
+
bx, by = bmin.shape
|
|
175
|
+
if bx > tx or by > ty:
|
|
176
|
+
if align_xy == "center":
|
|
177
|
+
xs, xe = _compute_center_crop_indices(bx, tx) if bx > tx else (0, bx)
|
|
178
|
+
ys, ye = _compute_center_crop_indices(by, ty) if by > ty else (0, by)
|
|
179
|
+
else:
|
|
180
|
+
xs, xe = (0, tx) if bx > tx else (0, bx)
|
|
181
|
+
ys, ye = (0, ty) if by > ty else (0, by)
|
|
182
|
+
bmin_c = bmin[xs:xe, ys:ye]
|
|
183
|
+
else:
|
|
184
|
+
bmin_c = bmin
|
|
185
|
+
bx, by = bmin_c.shape
|
|
186
|
+
px = max(0, tx - bx)
|
|
187
|
+
py = max(0, ty - by)
|
|
188
|
+
if px or py:
|
|
189
|
+
out = np.empty((tx, ty), dtype=object)
|
|
190
|
+
# Fill with empty factory values
|
|
191
|
+
for i in range(tx):
|
|
192
|
+
for j in range(ty):
|
|
193
|
+
out[i, j] = pv_bmin()
|
|
194
|
+
if align_xy == "center":
|
|
195
|
+
px0, px1 = _pad_split(px)
|
|
196
|
+
py0, py1 = _pad_split(py)
|
|
197
|
+
else:
|
|
198
|
+
px0, py0 = 0, 0
|
|
199
|
+
px1, py1 = px, py
|
|
200
|
+
out[px0:px0 + bx, py0:py0 + by] = bmin_c
|
|
201
|
+
bmin_n = out
|
|
202
|
+
else:
|
|
203
|
+
bmin_n = bmin_c
|
|
204
|
+
|
|
205
|
+
# Rebuild VoxCity with normalized arrays and same metadata (meshsize/bounds)
|
|
206
|
+
meta = city.voxels.meta
|
|
207
|
+
voxels_new = VoxelGrid(classes=vox_n, meta=meta)
|
|
208
|
+
buildings_new = BuildingGrid(heights=bh_n, min_heights=bmin_n, ids=bid_n, meta=meta)
|
|
209
|
+
land_new = LandCoverGrid(classes=lc_n, meta=meta)
|
|
210
|
+
dem_new = DemGrid(elevation=dem_n, meta=meta)
|
|
211
|
+
canopy_new = CanopyGrid(top=can_top_n, bottom=can_bot_n, meta=meta)
|
|
212
|
+
|
|
213
|
+
city_new = VoxCity(
|
|
214
|
+
voxels=voxels_new,
|
|
215
|
+
buildings=buildings_new,
|
|
216
|
+
land_cover=land_new,
|
|
217
|
+
dem=dem_new,
|
|
218
|
+
tree_canopy=canopy_new,
|
|
219
|
+
extras=dict(city.extras) if city.extras is not None else {},
|
|
220
|
+
)
|
|
221
|
+
# Keep extras canopy mirrors in sync if present
|
|
222
|
+
try:
|
|
223
|
+
city_new.extras["canopy_top"] = can_top_n
|
|
224
|
+
if can_bot_n is not None:
|
|
225
|
+
city_new.extras["canopy_bottom"] = can_bot_n
|
|
226
|
+
except Exception:
|
|
227
|
+
pass
|
|
228
|
+
return city_new
|
|
229
|
+
|
|
230
|
+
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Weather utilities subpackage.
|
|
3
|
+
|
|
4
|
+
Public API:
|
|
5
|
+
- safe_rename, safe_extract
|
|
6
|
+
- process_epw, read_epw_for_solar_simulation
|
|
7
|
+
- get_nearest_epw_from_climate_onebuilding
|
|
8
|
+
|
|
9
|
+
This package was introduced to split a previously monolithic module into
|
|
10
|
+
cohesive submodules. Backwards-compatible imports are preserved: importing
|
|
11
|
+
from `voxcity.utils.weather` continues to work.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from .files import safe_rename, safe_extract
|
|
15
|
+
from .epw import process_epw, read_epw_for_solar_simulation
|
|
16
|
+
from .onebuilding import get_nearest_epw_from_climate_onebuilding
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"safe_rename",
|
|
20
|
+
"safe_extract",
|
|
21
|
+
"process_epw",
|
|
22
|
+
"read_epw_for_solar_simulation",
|
|
23
|
+
"get_nearest_epw_from_climate_onebuilding",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Tuple, Union
|
|
3
|
+
import pandas as pd
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def process_epw(epw_path: Union[str, Path]) -> Tuple[pd.DataFrame, dict]:
|
|
7
|
+
"""
|
|
8
|
+
Process an EPW file into a pandas DataFrame and header metadata.
|
|
9
|
+
"""
|
|
10
|
+
columns = [
|
|
11
|
+
'Year', 'Month', 'Day', 'Hour', 'Minute',
|
|
12
|
+
'Data Source and Uncertainty Flags',
|
|
13
|
+
'Dry Bulb Temperature', 'Dew Point Temperature',
|
|
14
|
+
'Relative Humidity', 'Atmospheric Station Pressure',
|
|
15
|
+
'Extraterrestrial Horizontal Radiation',
|
|
16
|
+
'Extraterrestrial Direct Normal Radiation',
|
|
17
|
+
'Horizontal Infrared Radiation Intensity',
|
|
18
|
+
'Global Horizontal Radiation',
|
|
19
|
+
'Direct Normal Radiation', 'Diffuse Horizontal Radiation',
|
|
20
|
+
'Global Horizontal Illuminance',
|
|
21
|
+
'Direct Normal Illuminance', 'Diffuse Horizontal Illuminance',
|
|
22
|
+
'Zenith Luminance', 'Wind Direction', 'Wind Speed',
|
|
23
|
+
'Total Sky Cover', 'Opaque Sky Cover', 'Visibility',
|
|
24
|
+
'Ceiling Height', 'Present Weather Observation',
|
|
25
|
+
'Present Weather Codes', 'Precipitable Water',
|
|
26
|
+
'Aerosol Optical Depth', 'Snow Depth',
|
|
27
|
+
'Days Since Last Snowfall', 'Albedo',
|
|
28
|
+
'Liquid Precipitation Depth', 'Liquid Precipitation Quantity'
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
with open(epw_path, 'r') as f:
|
|
32
|
+
lines = f.readlines()
|
|
33
|
+
|
|
34
|
+
headers = {
|
|
35
|
+
'LOCATION': lines[0].strip(),
|
|
36
|
+
'DESIGN_CONDITIONS': lines[1].strip(),
|
|
37
|
+
'TYPICAL_EXTREME_PERIODS': lines[2].strip(),
|
|
38
|
+
'GROUND_TEMPERATURES': lines[3].strip(),
|
|
39
|
+
'HOLIDAYS_DAYLIGHT_SAVINGS': lines[4].strip(),
|
|
40
|
+
'COMMENTS_1': lines[5].strip(),
|
|
41
|
+
'COMMENTS_2': lines[6].strip(),
|
|
42
|
+
'DATA_PERIODS': lines[7].strip()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
location = headers['LOCATION'].split(',')
|
|
46
|
+
if len(location) >= 10:
|
|
47
|
+
headers['LOCATION'] = {
|
|
48
|
+
'City': location[1].strip(),
|
|
49
|
+
'State': location[2].strip(),
|
|
50
|
+
'Country': location[3].strip(),
|
|
51
|
+
'Data Source': location[4].strip(),
|
|
52
|
+
'WMO': location[5].strip(),
|
|
53
|
+
'Latitude': float(location[6]),
|
|
54
|
+
'Longitude': float(location[7]),
|
|
55
|
+
'Time Zone': float(location[8]),
|
|
56
|
+
'Elevation': float(location[9])
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
data = [line.strip().split(',') for line in lines[8:]]
|
|
60
|
+
df = pd.DataFrame(data, columns=columns)
|
|
61
|
+
|
|
62
|
+
numeric_columns = [
|
|
63
|
+
'Year', 'Month', 'Day', 'Hour', 'Minute',
|
|
64
|
+
'Dry Bulb Temperature', 'Dew Point Temperature',
|
|
65
|
+
'Relative Humidity', 'Atmospheric Station Pressure',
|
|
66
|
+
'Extraterrestrial Horizontal Radiation',
|
|
67
|
+
'Extraterrestrial Direct Normal Radiation',
|
|
68
|
+
'Horizontal Infrared Radiation Intensity',
|
|
69
|
+
'Global Horizontal Radiation',
|
|
70
|
+
'Direct Normal Radiation', 'Diffuse Horizontal Radiation',
|
|
71
|
+
'Global Horizontal Illuminance',
|
|
72
|
+
'Direct Normal Illuminance', 'Diffuse Horizontal Illuminance',
|
|
73
|
+
'Zenith Luminance', 'Wind Direction', 'Wind Speed',
|
|
74
|
+
'Total Sky Cover', 'Opaque Sky Cover', 'Visibility',
|
|
75
|
+
'Ceiling Height', 'Precipitable Water',
|
|
76
|
+
'Aerosol Optical Depth', 'Snow Depth',
|
|
77
|
+
'Days Since Last Snowfall', 'Albedo',
|
|
78
|
+
'Liquid Precipitation Depth', 'Liquid Precipitation Quantity'
|
|
79
|
+
]
|
|
80
|
+
for col in numeric_columns:
|
|
81
|
+
df[col] = pd.to_numeric(df[col], errors='coerce')
|
|
82
|
+
|
|
83
|
+
df['datetime'] = pd.to_datetime({
|
|
84
|
+
'year': df['Year'],
|
|
85
|
+
'month': df['Month'],
|
|
86
|
+
'day': df['Day'],
|
|
87
|
+
'hour': df['Hour'] - 1,
|
|
88
|
+
'minute': df['Minute']
|
|
89
|
+
})
|
|
90
|
+
df.set_index('datetime', inplace=True)
|
|
91
|
+
return df, headers
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def read_epw_for_solar_simulation(epw_file_path):
|
|
95
|
+
"""
|
|
96
|
+
Read EPW file specifically for solar simulation purposes.
|
|
97
|
+
Returns (df[DNI,DHI], lon, lat, tz, elevation_m).
|
|
98
|
+
"""
|
|
99
|
+
epw_path_obj = Path(epw_file_path)
|
|
100
|
+
if not epw_path_obj.exists() or not epw_path_obj.is_file():
|
|
101
|
+
raise FileNotFoundError(f"EPW file not found: {epw_file_path}")
|
|
102
|
+
|
|
103
|
+
with open(epw_path_obj, 'r', encoding='utf-8') as f:
|
|
104
|
+
lines = f.readlines()
|
|
105
|
+
|
|
106
|
+
location_line = None
|
|
107
|
+
for line in lines:
|
|
108
|
+
if line.startswith("LOCATION"):
|
|
109
|
+
location_line = line.strip().split(',')
|
|
110
|
+
break
|
|
111
|
+
if location_line is None:
|
|
112
|
+
raise ValueError("Could not find LOCATION line in EPW file.")
|
|
113
|
+
|
|
114
|
+
lat = float(location_line[6])
|
|
115
|
+
lon = float(location_line[7])
|
|
116
|
+
tz = float(location_line[8])
|
|
117
|
+
elevation_m = float(location_line[9])
|
|
118
|
+
|
|
119
|
+
data_start_index = None
|
|
120
|
+
for i, line in enumerate(lines):
|
|
121
|
+
vals = line.strip().split(',')
|
|
122
|
+
if i >= 8 and len(vals) > 30:
|
|
123
|
+
data_start_index = i
|
|
124
|
+
break
|
|
125
|
+
if data_start_index is None:
|
|
126
|
+
raise ValueError("Could not find start of weather data lines in EPW file.")
|
|
127
|
+
|
|
128
|
+
data = []
|
|
129
|
+
for l in lines[data_start_index:]:
|
|
130
|
+
vals = l.strip().split(',')
|
|
131
|
+
if len(vals) < 15:
|
|
132
|
+
continue
|
|
133
|
+
year = int(vals[0])
|
|
134
|
+
month = int(vals[1])
|
|
135
|
+
day = int(vals[2])
|
|
136
|
+
hour = int(vals[3]) - 1
|
|
137
|
+
dni = float(vals[14])
|
|
138
|
+
dhi = float(vals[15])
|
|
139
|
+
timestamp = pd.Timestamp(year, month, day, hour)
|
|
140
|
+
data.append([timestamp, dni, dhi])
|
|
141
|
+
|
|
142
|
+
df = pd.DataFrame(data, columns=['time', 'DNI', 'DHI']).set_index('time')
|
|
143
|
+
df = df.sort_index()
|
|
144
|
+
return df, lon, lat, tz, elevation_m
|
|
145
|
+
|
|
146
|
+
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import os
|
|
3
|
+
import zipfile
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def safe_rename(src: Path, dst: Path) -> Path:
|
|
7
|
+
"""
|
|
8
|
+
Safely rename a file, handling existing files by adding a number suffix.
|
|
9
|
+
"""
|
|
10
|
+
if not dst.exists():
|
|
11
|
+
src.rename(dst)
|
|
12
|
+
return dst
|
|
13
|
+
base = dst.stem
|
|
14
|
+
ext = dst.suffix
|
|
15
|
+
counter = 1
|
|
16
|
+
while True:
|
|
17
|
+
new_dst = dst.with_name(f"{base}_{counter}{ext}")
|
|
18
|
+
if not new_dst.exists():
|
|
19
|
+
src.rename(new_dst)
|
|
20
|
+
return new_dst
|
|
21
|
+
counter += 1
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def safe_extract(zip_ref: zipfile.ZipFile, filename: str, extract_dir: Path) -> Path:
|
|
25
|
+
"""
|
|
26
|
+
Safely extract a file from zip, handling existing files.
|
|
27
|
+
"""
|
|
28
|
+
try:
|
|
29
|
+
zip_ref.extract(filename, extract_dir)
|
|
30
|
+
return extract_dir / filename
|
|
31
|
+
except FileExistsError:
|
|
32
|
+
temp_name = f"temp_{os.urandom(4).hex()}_{filename}"
|
|
33
|
+
zip_ref.extract(filename, extract_dir, temp_name)
|
|
34
|
+
return extract_dir / temp_name
|
|
35
|
+
|
|
36
|
+
|