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.
Files changed (89) hide show
  1. zero2geospatial3d/__init__.py +105 -0
  2. zero2geospatial3d/acoustics.py +284 -0
  3. zero2geospatial3d/classify.py +379 -0
  4. zero2geospatial3d/cli.py +367 -0
  5. zero2geospatial3d/comfort.py +687 -0
  6. zero2geospatial3d/data/epw_stations.json.gz +0 -0
  7. zero2geospatial3d/daylight.py +337 -0
  8. zero2geospatial3d/dispersion.py +249 -0
  9. zero2geospatial3d/epw.py +162 -0
  10. zero2geospatial3d/geo.py +138 -0
  11. zero2geospatial3d/grid.py +119 -0
  12. zero2geospatial3d/microclimate.py +388 -0
  13. zero2geospatial3d/morphology.py +283 -0
  14. zero2geospatial3d/nsga2.py +315 -0
  15. zero2geospatial3d/osm.py +450 -0
  16. zero2geospatial3d/osm_tags.py +180 -0
  17. zero2geospatial3d/py.typed +0 -0
  18. zero2geospatial3d/results.py +231 -0
  19. zero2geospatial3d/road_sources.py +141 -0
  20. zero2geospatial3d/scene.py +555 -0
  21. zero2geospatial3d/server.py +332 -0
  22. zero2geospatial3d/shading.py +443 -0
  23. zero2geospatial3d/solar.py +207 -0
  24. zero2geospatial3d/terrain.py +610 -0
  25. zero2geospatial3d/urban_form.py +326 -0
  26. zero2geospatial3d/walkability.py +380 -0
  27. zero2geospatial3d/weather.py +373 -0
  28. zero2geospatial3d/web/index.html +50 -0
  29. zero2geospatial3d/web/src/analysis/climate.js +278 -0
  30. zero2geospatial3d/web/src/analysis/compliance.js +345 -0
  31. zero2geospatial3d/web/src/analysis/gpu.js +431 -0
  32. zero2geospatial3d/web/src/analysis/optimize.js +208 -0
  33. zero2geospatial3d/web/src/analysis/runners.js +1644 -0
  34. zero2geospatial3d/web/src/analysis/terrain_results.js +145 -0
  35. zero2geospatial3d/web/src/app.js +630 -0
  36. zero2geospatial3d/web/src/core/acoustics.js +341 -0
  37. zero2geospatial3d/web/src/core/classify.js +158 -0
  38. zero2geospatial3d/web/src/core/comfort.js +686 -0
  39. zero2geospatial3d/web/src/core/daylight_rule.js +199 -0
  40. zero2geospatial3d/web/src/core/dispersion.js +264 -0
  41. zero2geospatial3d/web/src/core/microclimate.js +372 -0
  42. zero2geospatial3d/web/src/core/morphology.js +153 -0
  43. zero2geospatial3d/web/src/core/nsga2.js +181 -0
  44. zero2geospatial3d/web/src/core/party_walls.js +60 -0
  45. zero2geospatial3d/web/src/core/road_sources.js +127 -0
  46. zero2geospatial3d/web/src/core/solar.js +112 -0
  47. zero2geospatial3d/web/src/core/terrain_analysis.js +267 -0
  48. zero2geospatial3d/web/src/core/terrain_math.js +279 -0
  49. zero2geospatial3d/web/src/core/urban_form.js +405 -0
  50. zero2geospatial3d/web/src/core/walkability.js +316 -0
  51. zero2geospatial3d/web/src/core/weather.js +225 -0
  52. zero2geospatial3d/web/src/core/wind.js +131 -0
  53. zero2geospatial3d/web/src/export/check_pdf.js +74 -0
  54. zero2geospatial3d/web/src/export/exporters.js +556 -0
  55. zero2geospatial3d/web/src/export/pdf.js +149 -0
  56. zero2geospatial3d/web/src/export/snapshot.js +234 -0
  57. zero2geospatial3d/web/src/export/snapshot_scene.js +150 -0
  58. zero2geospatial3d/web/src/main.js +75 -0
  59. zero2geospatial3d/web/src/scene/objects.js +645 -0
  60. zero2geospatial3d/web/src/scene/overlay.js +145 -0
  61. zero2geospatial3d/web/src/scene/terrain.js +546 -0
  62. zero2geospatial3d/web/src/scene/viewer.js +275 -0
  63. zero2geospatial3d/web/src/scene/wind_streamlines.js +336 -0
  64. zero2geospatial3d/web/src/style.css +225 -0
  65. zero2geospatial3d/web/src/ui/building_report.js +404 -0
  66. zero2geospatial3d/web/src/ui/charts.js +441 -0
  67. zero2geospatial3d/web/src/ui/colorize.js +101 -0
  68. zero2geospatial3d/web/src/ui/compare.js +362 -0
  69. zero2geospatial3d/web/src/ui/dom.js +104 -0
  70. zero2geospatial3d/web/src/ui/panel_analyze.js +742 -0
  71. zero2geospatial3d/web/src/ui/panel_compare.js +294 -0
  72. zero2geospatial3d/web/src/ui/panel_layers.js +172 -0
  73. zero2geospatial3d/web/src/ui/panel_terrain.js +277 -0
  74. zero2geospatial3d/web/src/ui/qgis_link.js +127 -0
  75. zero2geospatial3d/web/src/ui/sunclock.js +45 -0
  76. zero2geospatial3d/web/src/ui/tools.js +197 -0
  77. zero2geospatial3d/web/src/ui/viewtools.js +173 -0
  78. zero2geospatial3d/web/vendor/three/addons/controls/OrbitControls.js +1417 -0
  79. zero2geospatial3d/web/vendor/three/addons/exporters/GLTFExporter.js +3309 -0
  80. zero2geospatial3d/web/vendor/three/addons/utils/TextureUtils.js +98 -0
  81. zero2geospatial3d/web/vendor/three/three.module.js +53044 -0
  82. zero2geospatial3d/wind.py +178 -0
  83. zero2geospatial3d_sdk-0.1.0.dist-info/METADATA +219 -0
  84. zero2geospatial3d_sdk-0.1.0.dist-info/RECORD +89 -0
  85. zero2geospatial3d_sdk-0.1.0.dist-info/WHEEL +5 -0
  86. zero2geospatial3d_sdk-0.1.0.dist-info/entry_points.txt +2 -0
  87. zero2geospatial3d_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
  88. zero2geospatial3d_sdk-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +52 -0
  89. zero2geospatial3d_sdk-0.1.0.dist-info/top_level.txt +1 -0
@@ -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)
@@ -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)