zero2geospatial3d-sdk 0.1.0__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.
- zero2geospatial3d/__init__.py +105 -0
- zero2geospatial3d/acoustics.py +284 -0
- zero2geospatial3d/classify.py +379 -0
- zero2geospatial3d/cli.py +367 -0
- zero2geospatial3d/comfort.py +687 -0
- zero2geospatial3d/data/epw_stations.json.gz +0 -0
- zero2geospatial3d/daylight.py +337 -0
- zero2geospatial3d/dispersion.py +249 -0
- zero2geospatial3d/epw.py +162 -0
- zero2geospatial3d/geo.py +138 -0
- zero2geospatial3d/grid.py +119 -0
- zero2geospatial3d/microclimate.py +388 -0
- zero2geospatial3d/morphology.py +283 -0
- zero2geospatial3d/nsga2.py +315 -0
- zero2geospatial3d/osm.py +450 -0
- zero2geospatial3d/osm_tags.py +180 -0
- zero2geospatial3d/py.typed +0 -0
- zero2geospatial3d/results.py +231 -0
- zero2geospatial3d/road_sources.py +141 -0
- zero2geospatial3d/scene.py +555 -0
- zero2geospatial3d/server.py +332 -0
- zero2geospatial3d/shading.py +443 -0
- zero2geospatial3d/solar.py +207 -0
- zero2geospatial3d/terrain.py +610 -0
- zero2geospatial3d/urban_form.py +326 -0
- zero2geospatial3d/walkability.py +380 -0
- zero2geospatial3d/weather.py +373 -0
- zero2geospatial3d/web/index.html +50 -0
- zero2geospatial3d/web/src/analysis/climate.js +278 -0
- zero2geospatial3d/web/src/analysis/compliance.js +345 -0
- zero2geospatial3d/web/src/analysis/gpu.js +431 -0
- zero2geospatial3d/web/src/analysis/optimize.js +208 -0
- zero2geospatial3d/web/src/analysis/runners.js +1644 -0
- zero2geospatial3d/web/src/analysis/terrain_results.js +145 -0
- zero2geospatial3d/web/src/app.js +630 -0
- zero2geospatial3d/web/src/core/acoustics.js +341 -0
- zero2geospatial3d/web/src/core/classify.js +158 -0
- zero2geospatial3d/web/src/core/comfort.js +686 -0
- zero2geospatial3d/web/src/core/daylight_rule.js +199 -0
- zero2geospatial3d/web/src/core/dispersion.js +264 -0
- zero2geospatial3d/web/src/core/microclimate.js +372 -0
- zero2geospatial3d/web/src/core/morphology.js +153 -0
- zero2geospatial3d/web/src/core/nsga2.js +181 -0
- zero2geospatial3d/web/src/core/party_walls.js +60 -0
- zero2geospatial3d/web/src/core/road_sources.js +127 -0
- zero2geospatial3d/web/src/core/solar.js +112 -0
- zero2geospatial3d/web/src/core/terrain_analysis.js +267 -0
- zero2geospatial3d/web/src/core/terrain_math.js +279 -0
- zero2geospatial3d/web/src/core/urban_form.js +405 -0
- zero2geospatial3d/web/src/core/walkability.js +316 -0
- zero2geospatial3d/web/src/core/weather.js +225 -0
- zero2geospatial3d/web/src/core/wind.js +131 -0
- zero2geospatial3d/web/src/export/check_pdf.js +74 -0
- zero2geospatial3d/web/src/export/exporters.js +556 -0
- zero2geospatial3d/web/src/export/pdf.js +149 -0
- zero2geospatial3d/web/src/export/snapshot.js +234 -0
- zero2geospatial3d/web/src/export/snapshot_scene.js +150 -0
- zero2geospatial3d/web/src/main.js +75 -0
- zero2geospatial3d/web/src/scene/objects.js +645 -0
- zero2geospatial3d/web/src/scene/overlay.js +145 -0
- zero2geospatial3d/web/src/scene/terrain.js +546 -0
- zero2geospatial3d/web/src/scene/viewer.js +275 -0
- zero2geospatial3d/web/src/scene/wind_streamlines.js +336 -0
- zero2geospatial3d/web/src/style.css +225 -0
- zero2geospatial3d/web/src/ui/building_report.js +404 -0
- zero2geospatial3d/web/src/ui/charts.js +441 -0
- zero2geospatial3d/web/src/ui/colorize.js +101 -0
- zero2geospatial3d/web/src/ui/compare.js +362 -0
- zero2geospatial3d/web/src/ui/dom.js +104 -0
- zero2geospatial3d/web/src/ui/panel_analyze.js +742 -0
- zero2geospatial3d/web/src/ui/panel_compare.js +294 -0
- zero2geospatial3d/web/src/ui/panel_layers.js +172 -0
- zero2geospatial3d/web/src/ui/panel_terrain.js +277 -0
- zero2geospatial3d/web/src/ui/qgis_link.js +127 -0
- zero2geospatial3d/web/src/ui/sunclock.js +45 -0
- zero2geospatial3d/web/src/ui/tools.js +197 -0
- zero2geospatial3d/web/src/ui/viewtools.js +173 -0
- zero2geospatial3d/web/vendor/three/addons/controls/OrbitControls.js +1417 -0
- zero2geospatial3d/web/vendor/three/addons/exporters/GLTFExporter.js +3309 -0
- zero2geospatial3d/web/vendor/three/addons/utils/TextureUtils.js +98 -0
- zero2geospatial3d/web/vendor/three/three.module.js +53044 -0
- zero2geospatial3d/wind.py +178 -0
- zero2geospatial3d_sdk-0.1.0.dist-info/METADATA +219 -0
- zero2geospatial3d_sdk-0.1.0.dist-info/RECORD +89 -0
- zero2geospatial3d_sdk-0.1.0.dist-info/WHEEL +5 -0
- zero2geospatial3d_sdk-0.1.0.dist-info/entry_points.txt +2 -0
- zero2geospatial3d_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
- zero2geospatial3d_sdk-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +52 -0
- zero2geospatial3d_sdk-0.1.0.dist-info/top_level.txt +1 -0
zero2geospatial3d/epw.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""EnergyPlus weather (EPW) files: header check, nearest-station lookup and
|
|
2
|
+
download from climate.onebuilding.org (TMYx files).
|
|
3
|
+
|
|
4
|
+
The bundled station index ``data/epw_stations.json.gz`` lists about 17,000
|
|
5
|
+
TMYx stations (names, coordinates, time zones, elevations and download paths)
|
|
6
|
+
from the location indexes published by Climate.OneBuilding.Org. Weather files
|
|
7
|
+
themselves are not bundled; :func:`download_station` fetches one on request.
|
|
8
|
+
Parse a downloaded file with :func:`zero2geospatial3d.weather.parse_epw`.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import gzip
|
|
14
|
+
import io
|
|
15
|
+
import json
|
|
16
|
+
import math
|
|
17
|
+
import os
|
|
18
|
+
import urllib.request
|
|
19
|
+
import zipfile
|
|
20
|
+
from functools import lru_cache
|
|
21
|
+
from typing import Any, Optional
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"EpwError",
|
|
25
|
+
"INDEX_PATH",
|
|
26
|
+
"read_header",
|
|
27
|
+
"haversine_km",
|
|
28
|
+
"load_index",
|
|
29
|
+
"nearest_stations",
|
|
30
|
+
"extract_epw",
|
|
31
|
+
"download_station",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
INDEX_PATH = os.path.join(os.path.dirname(__file__), "data", "epw_stations.json.gz")
|
|
35
|
+
MAX_ZIP_BYTES = 40 * 1024 * 1024
|
|
36
|
+
ALLOWED_PREFIX = "https://climate.onebuilding.org/"
|
|
37
|
+
DEFAULT_TIMEOUT_S = 120.0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class EpwError(RuntimeError):
|
|
41
|
+
"""Raised for unreadable, malformed or oversized weather files."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def read_header(path: str) -> dict[str, Any]:
|
|
45
|
+
"""Parse the LOCATION line of an EPW file: city, country, lat, lon, tz, elevation."""
|
|
46
|
+
try:
|
|
47
|
+
with open(path, encoding="latin-1") as handle:
|
|
48
|
+
first = handle.readline()
|
|
49
|
+
except OSError as exc:
|
|
50
|
+
raise EpwError(f"Cannot read {path}: {exc}") from exc
|
|
51
|
+
return parse_location_line(first)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def parse_location_line(first: str) -> dict[str, Any]:
|
|
55
|
+
"""Parse an EPW ``LOCATION,...`` record."""
|
|
56
|
+
parts = [p.strip() for p in first.split(",")]
|
|
57
|
+
if len(parts) < 10 or parts[0].upper() != "LOCATION":
|
|
58
|
+
raise EpwError("Not an EPW file (the first line is not a LOCATION record).")
|
|
59
|
+
try:
|
|
60
|
+
return {
|
|
61
|
+
"city": parts[1],
|
|
62
|
+
"region": parts[2],
|
|
63
|
+
"country": parts[3],
|
|
64
|
+
"source": parts[4],
|
|
65
|
+
"wmo": parts[5],
|
|
66
|
+
"lat": float(parts[6]),
|
|
67
|
+
"lon": float(parts[7]),
|
|
68
|
+
"utc": float(parts[8]),
|
|
69
|
+
"elevation": float(parts[9]),
|
|
70
|
+
}
|
|
71
|
+
except ValueError as exc:
|
|
72
|
+
raise EpwError(f"Malformed EPW LOCATION line: {exc}") from exc
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
|
76
|
+
"""Great-circle distance in kilometres."""
|
|
77
|
+
p1, p2 = math.radians(lat1), math.radians(lat2)
|
|
78
|
+
dp, dl = p2 - p1, math.radians(lon2 - lon1)
|
|
79
|
+
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
|
|
80
|
+
return 6371.0088 * 2 * math.asin(min(1.0, math.sqrt(a)))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@lru_cache(maxsize=2)
|
|
84
|
+
def _load_cached(path: str) -> dict[str, Any]:
|
|
85
|
+
with gzip.open(path, "rt", encoding="utf-8") as handle:
|
|
86
|
+
return json.load(handle)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def load_index(path: str = INDEX_PATH) -> dict[str, Any]:
|
|
90
|
+
"""The station index: ``{"base": url, "columns": [...], "rows": [[lat, lon, name, path, utc, elev], ...]}``."""
|
|
91
|
+
if not os.path.isfile(path):
|
|
92
|
+
raise EpwError(f"The weather-station index is missing: {path}")
|
|
93
|
+
return _load_cached(path)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def nearest_stations(
|
|
97
|
+
lat: float, lon: float, count: int = 5, index: Optional[dict[str, Any]] = None
|
|
98
|
+
) -> list[dict[str, Any]]:
|
|
99
|
+
"""The ``count`` nearest stations as dicts sorted by distance."""
|
|
100
|
+
index = index or load_index()
|
|
101
|
+
base = index.get("base", "")
|
|
102
|
+
scored = []
|
|
103
|
+
for lat_s, lon_s, station, path, utc, elev in index["rows"]:
|
|
104
|
+
scored.append(
|
|
105
|
+
(haversine_km(lat, lon, lat_s, lon_s), lat_s, lon_s, station, path, utc, elev)
|
|
106
|
+
)
|
|
107
|
+
scored.sort(key=lambda row: row[0])
|
|
108
|
+
return [
|
|
109
|
+
{
|
|
110
|
+
"distance_km": round(d, 1),
|
|
111
|
+
"lat": la,
|
|
112
|
+
"lon": lo,
|
|
113
|
+
"station": st,
|
|
114
|
+
"url": base + p,
|
|
115
|
+
"utc": utc,
|
|
116
|
+
"elevation": el,
|
|
117
|
+
}
|
|
118
|
+
for d, la, lo, st, p, utc, el in scored[:count]
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def extract_epw(zip_bytes: bytes, target_path: str) -> str:
|
|
123
|
+
"""Write the single .epw member of a OneBuilding zip to ``target_path``."""
|
|
124
|
+
if len(zip_bytes) > MAX_ZIP_BYTES:
|
|
125
|
+
raise EpwError("The weather archive is unexpectedly large.")
|
|
126
|
+
try:
|
|
127
|
+
archive = zipfile.ZipFile(io.BytesIO(zip_bytes))
|
|
128
|
+
except zipfile.BadZipFile as exc:
|
|
129
|
+
raise EpwError("The download is not a zip archive.") from exc
|
|
130
|
+
members = [m for m in archive.infolist() if m.filename.lower().endswith(".epw")]
|
|
131
|
+
if not members:
|
|
132
|
+
raise EpwError("The archive contains no .epw file.")
|
|
133
|
+
member = members[0]
|
|
134
|
+
if member.file_size > MAX_ZIP_BYTES:
|
|
135
|
+
raise EpwError("The weather file is unexpectedly large.")
|
|
136
|
+
data = archive.read(member)
|
|
137
|
+
os.makedirs(os.path.dirname(target_path) or ".", exist_ok=True)
|
|
138
|
+
with open(target_path, "wb") as handle:
|
|
139
|
+
handle.write(data)
|
|
140
|
+
read_header(target_path)
|
|
141
|
+
return target_path
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def download_station(url: str, target_path: str, timeout: float = DEFAULT_TIMEOUT_S) -> str:
|
|
145
|
+
"""Download a OneBuilding TMYx zip and extract its EPW to ``target_path``.
|
|
146
|
+
|
|
147
|
+
Only ``https://climate.onebuilding.org/`` URLs are accepted (as returned by
|
|
148
|
+
:func:`nearest_stations`).
|
|
149
|
+
"""
|
|
150
|
+
if not url.startswith(ALLOWED_PREFIX):
|
|
151
|
+
raise EpwError("Only climate.onebuilding.org downloads are allowed.")
|
|
152
|
+
from . import __version__
|
|
153
|
+
|
|
154
|
+
request = urllib.request.Request(
|
|
155
|
+
url, headers={"User-Agent": f"zero2geospatial3d-sdk/{__version__}"}
|
|
156
|
+
)
|
|
157
|
+
try:
|
|
158
|
+
with urllib.request.urlopen(request, timeout=timeout) as reply: # noqa: S310
|
|
159
|
+
content = reply.read(MAX_ZIP_BYTES + 1)
|
|
160
|
+
except OSError as exc:
|
|
161
|
+
raise EpwError(f"Download failed: {exc}") from exc
|
|
162
|
+
return extract_epw(content, target_path)
|
zero2geospatial3d/geo.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Pure-Python geometry helpers shared by the exporter and the tests.
|
|
2
|
+
|
|
3
|
+
Scene convention: coordinates are metres in a projected CRS, made local by
|
|
4
|
+
subtracting ``origin`` (the centre of the scene extent). The viewer maps local
|
|
5
|
+
(x, y) to Three.js as (x, height, -y), so +x is east and +y is north.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
|
|
12
|
+
MAX_GRID_CELLS = 1024 # longest side of an exported raster grid
|
|
13
|
+
MAX_SCENE_SIDE_M = 250000.0 # sanity limit only: local float32 coordinates stay sub-centimetre
|
|
14
|
+
LARGE_SCENE_SIDE_M = 20000.0 # above this the export warns about coarse cells
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def utm_epsg_for(lon: float, lat: float) -> int:
|
|
18
|
+
"""EPSG code of the WGS84 / UTM zone containing (lon, lat)."""
|
|
19
|
+
zone = int(math.floor((lon + 180.0) / 6.0) + 1)
|
|
20
|
+
zone = max(1, min(60, zone))
|
|
21
|
+
return (32600 if lat >= 0 else 32700) + zone
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def grid_shape(
|
|
25
|
+
width: float, height: float, cell: float | None, max_cells: int = MAX_GRID_CELLS
|
|
26
|
+
) -> tuple[int, int, float]:
|
|
27
|
+
"""(cols, rows, cell) for an extent, with the cell grown if needed so that
|
|
28
|
+
neither side exceeds ``max_cells``. ``cell=None`` means automatic."""
|
|
29
|
+
if width <= 0 or height <= 0:
|
|
30
|
+
raise ValueError("extent must have a positive width and height")
|
|
31
|
+
longest = max(width, height)
|
|
32
|
+
auto = longest / max_cells
|
|
33
|
+
cell = auto if not cell or cell <= 0 else max(float(cell), auto)
|
|
34
|
+
cols = max(2, int(math.ceil(width / cell)))
|
|
35
|
+
rows = max(2, int(math.ceil(height / cell)))
|
|
36
|
+
return cols, rows, cell
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def round_coord(value: float, digits: int = 2) -> float:
|
|
40
|
+
return round(float(value), digits)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def ring_area(ring) -> float:
|
|
44
|
+
"""Signed shoelace area of a closed or open ring of (x, y)."""
|
|
45
|
+
n = len(ring)
|
|
46
|
+
if n < 3:
|
|
47
|
+
return 0.0
|
|
48
|
+
total = 0.0
|
|
49
|
+
for i in range(n):
|
|
50
|
+
x1, y1 = ring[i][0], ring[i][1]
|
|
51
|
+
x2, y2 = ring[(i + 1) % n][0], ring[(i + 1) % n][1]
|
|
52
|
+
total += x1 * y2 - x2 * y1
|
|
53
|
+
return total / 2.0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def utc_offset_guess(lon: float) -> float:
|
|
57
|
+
"""Nautical time-zone guess from longitude. Only a starting value: civil
|
|
58
|
+
time zones differ (Turkey is UTC+3 at about 30 degrees east), so the
|
|
59
|
+
viewer lets the user change it and an EPW file overrides it."""
|
|
60
|
+
return float(round(lon / 15.0))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def choose_utc_offset(
|
|
64
|
+
lon: float, machine_offset: float | None, station_offset: float | None = None
|
|
65
|
+
) -> float:
|
|
66
|
+
"""Clock time zone for the sun: the standard offset of the nearest
|
|
67
|
+
weather station when one is close (the civil zone where it differs from
|
|
68
|
+
the longitude, as in Turkey), else the computer's standard offset when it
|
|
69
|
+
is within 1.5 h of the longitude's nautical zone (people analyse places
|
|
70
|
+
in their own country), else the nautical zone."""
|
|
71
|
+
if station_offset is not None:
|
|
72
|
+
return float(station_offset)
|
|
73
|
+
nautical = utc_offset_guess(lon)
|
|
74
|
+
if machine_offset is not None and abs(machine_offset - lon / 15.0) <= 1.5:
|
|
75
|
+
return float(machine_offset)
|
|
76
|
+
return nautical
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def safe_name(text: str, fallback: str = "layer") -> str:
|
|
80
|
+
out = []
|
|
81
|
+
for ch in str(text or ""):
|
|
82
|
+
if ch.isalnum() or ch in "-_":
|
|
83
|
+
out.append(ch)
|
|
84
|
+
elif ch in " .":
|
|
85
|
+
out.append("_")
|
|
86
|
+
name = "".join(out).strip("_")
|
|
87
|
+
return name[:60] or fallback
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def field_name(text: str, used: set, limit: int = 30) -> str:
|
|
91
|
+
"""A GeoPackage-safe, unique, lower-case field name."""
|
|
92
|
+
base = "".join(ch if ch.isalnum() else "_" for ch in str(text or "").lower()).strip("_")
|
|
93
|
+
base = (base or "value")[:limit]
|
|
94
|
+
if base[0].isdigit():
|
|
95
|
+
base = ("v_" + base)[:limit]
|
|
96
|
+
name = base
|
|
97
|
+
k = 2
|
|
98
|
+
while name in used:
|
|
99
|
+
suffix = f"_{k}"
|
|
100
|
+
name = base[: limit - len(suffix)] + suffix
|
|
101
|
+
k += 1
|
|
102
|
+
used.add(name)
|
|
103
|
+
return name
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
EARTH_RADIUS_M = 6378137.0
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def lonlat_to_local(
|
|
110
|
+
lon: float, lat: float, origin_lon: float, origin_lat: float
|
|
111
|
+
) -> tuple[float, float]:
|
|
112
|
+
"""Local metres (x east, y north) of a WGS84 point around an origin.
|
|
113
|
+
|
|
114
|
+
Equirectangular approximation, accurate to a few centimetres per kilometre
|
|
115
|
+
for scenes up to a few kilometres across (the plugin's typical extent). For
|
|
116
|
+
larger areas project with pyproj (``pip install zero2geospatial3d-sdk[geo]``).
|
|
117
|
+
"""
|
|
118
|
+
k = math.cos(math.radians(origin_lat))
|
|
119
|
+
x = math.radians(lon - origin_lon) * EARTH_RADIUS_M * k
|
|
120
|
+
y = math.radians(lat - origin_lat) * EARTH_RADIUS_M
|
|
121
|
+
return x, y
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def local_to_lonlat(
|
|
125
|
+
x: float, y: float, origin_lon: float, origin_lat: float
|
|
126
|
+
) -> tuple[float, float]:
|
|
127
|
+
"""Inverse of :func:`lonlat_to_local`."""
|
|
128
|
+
k = math.cos(math.radians(origin_lat))
|
|
129
|
+
lon = origin_lon + math.degrees(x / (EARTH_RADIUS_M * k))
|
|
130
|
+
lat = origin_lat + math.degrees(y / EARTH_RADIUS_M)
|
|
131
|
+
return lon, lat
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def bbox_around(lon: float, lat: float, half_size_m: float) -> tuple[float, float, float, float]:
|
|
135
|
+
"""(south, west, north, east) of a square of ``2 * half_size_m`` around a point."""
|
|
136
|
+
w, s = local_to_lonlat(-half_size_m, -half_size_m, lon, lat)
|
|
137
|
+
e, n = local_to_lonlat(half_size_m, half_size_m, lon, lat)
|
|
138
|
+
return s, w, n, e
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Regular raster grid shared by every analysis module.
|
|
2
|
+
|
|
3
|
+
A :class:`Grid` holds a 2-D ``values`` array of shape ``(rows, cols)`` stored
|
|
4
|
+
row-major from the north-west cell. ``(min_x, max_y)`` is the *centre* of that
|
|
5
|
+
cell in local metres (x east, y north), exactly as in the plugin's web viewer.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
__all__ = ["Grid", "make_grid"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Grid:
|
|
20
|
+
"""A north-up raster in local metres.
|
|
21
|
+
|
|
22
|
+
Attributes:
|
|
23
|
+
values: ``(rows, cols)`` float array, row 0 is the northern edge.
|
|
24
|
+
cell: cell size in metres.
|
|
25
|
+
min_x: x of the centre of the north-west cell.
|
|
26
|
+
max_y: y of the centre of the north-west cell.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
values: np.ndarray
|
|
30
|
+
cell: float = 1.0
|
|
31
|
+
min_x: float = 0.0
|
|
32
|
+
max_y: float = 0.0
|
|
33
|
+
nodata: Optional[float] = field(default=None)
|
|
34
|
+
|
|
35
|
+
def __post_init__(self) -> None:
|
|
36
|
+
arr = np.asarray(self.values, dtype=float)
|
|
37
|
+
if arr.ndim != 2:
|
|
38
|
+
raise ValueError("Grid values must be a 2-D (rows, cols) array")
|
|
39
|
+
if self.cell <= 0:
|
|
40
|
+
raise ValueError("cell size must be positive")
|
|
41
|
+
self.values = arr
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def rows(self) -> int:
|
|
45
|
+
return int(self.values.shape[0])
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def cols(self) -> int:
|
|
49
|
+
return int(self.values.shape[1])
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def shape(self) -> tuple[int, int]:
|
|
53
|
+
return (self.rows, self.cols)
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def min_y(self) -> float:
|
|
57
|
+
return self.max_y - (self.rows - 1) * self.cell
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def max_x(self) -> float:
|
|
61
|
+
return self.min_x + (self.cols - 1) * self.cell
|
|
62
|
+
|
|
63
|
+
def cell_centre(self, c: int, r: int) -> tuple[float, float]:
|
|
64
|
+
"""Local ``(x, y)`` of the centre of column ``c``, row ``r``."""
|
|
65
|
+
return (self.min_x + c * self.cell, self.max_y - r * self.cell)
|
|
66
|
+
|
|
67
|
+
def coords(self) -> tuple[np.ndarray, np.ndarray]:
|
|
68
|
+
"""Arrays ``(x, y)`` of shape ``(rows, cols)`` with every cell centre."""
|
|
69
|
+
xs = self.min_x + np.arange(self.cols) * self.cell
|
|
70
|
+
ys = self.max_y - np.arange(self.rows) * self.cell
|
|
71
|
+
return np.meshgrid(xs, ys)
|
|
72
|
+
|
|
73
|
+
def index_of(self, x: float, y: float) -> tuple[int, int]:
|
|
74
|
+
"""Nearest ``(col, row)`` for local ``(x, y)``, clamped to the grid."""
|
|
75
|
+
c = int(round((x - self.min_x) / self.cell))
|
|
76
|
+
r = int(round((self.max_y - y) / self.cell))
|
|
77
|
+
return (min(max(c, 0), self.cols - 1), min(max(r, 0), self.rows - 1))
|
|
78
|
+
|
|
79
|
+
def sample(self, x: float, y: float) -> float:
|
|
80
|
+
"""Bilinear value at local ``(x, y)``; clamped at the edges."""
|
|
81
|
+
return float(self.sample_many(np.array([x]), np.array([y]))[0])
|
|
82
|
+
|
|
83
|
+
def sample_many(self, x: np.ndarray, y: np.ndarray) -> np.ndarray:
|
|
84
|
+
"""Vectorised bilinear sampling (clamped at the edges)."""
|
|
85
|
+
x = np.asarray(x, dtype=float)
|
|
86
|
+
y = np.asarray(y, dtype=float)
|
|
87
|
+
v = self.values
|
|
88
|
+
if self.cols < 2 or self.rows < 2:
|
|
89
|
+
return np.full(x.shape, float(v.flat[0]) if v.size else 0.0)
|
|
90
|
+
fc = np.clip((x - self.min_x) / self.cell, 0, self.cols - 1)
|
|
91
|
+
fr = np.clip((self.max_y - y) / self.cell, 0, self.rows - 1)
|
|
92
|
+
c0 = np.minimum(self.cols - 2, np.floor(fc)).astype(int)
|
|
93
|
+
r0 = np.minimum(self.rows - 2, np.floor(fr)).astype(int)
|
|
94
|
+
tx = fc - c0
|
|
95
|
+
ty = fr - r0
|
|
96
|
+
a = v[r0, c0] * (1 - tx) + v[r0, c0 + 1] * tx
|
|
97
|
+
b = v[r0 + 1, c0] * (1 - tx) + v[r0 + 1, c0 + 1] * tx
|
|
98
|
+
return a * (1 - ty) + b * ty
|
|
99
|
+
|
|
100
|
+
def copy(self, values: Optional[np.ndarray] = None) -> Grid:
|
|
101
|
+
"""A grid with the same georeference and new (or copied) values."""
|
|
102
|
+
vals = self.values.copy() if values is None else np.asarray(values, dtype=float)
|
|
103
|
+
return Grid(vals, self.cell, self.min_x, self.max_y, self.nodata)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def make_grid(
|
|
107
|
+
cols: int,
|
|
108
|
+
rows: int,
|
|
109
|
+
cell: float,
|
|
110
|
+
min_x: float = 0.0,
|
|
111
|
+
max_y: float = 0.0,
|
|
112
|
+
values: Optional[np.ndarray] = None,
|
|
113
|
+
) -> Grid:
|
|
114
|
+
"""Build a :class:`Grid`; ``values`` may be flat (row-major) or 2-D."""
|
|
115
|
+
if values is None:
|
|
116
|
+
arr = np.zeros((rows, cols))
|
|
117
|
+
else:
|
|
118
|
+
arr = np.asarray(values, dtype=float).reshape(rows, cols)
|
|
119
|
+
return Grid(arr, cell, min_x, max_y)
|