epwtools 0.3.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.
- epwtools/__init__.py +15 -0
- epwtools/_namespaces.py +458 -0
- epwtools/aio.py +332 -0
- epwtools/cli.py +481 -0
- epwtools/client.py +772 -0
- epwtools-0.3.0.dist-info/METADATA +313 -0
- epwtools-0.3.0.dist-info/RECORD +9 -0
- epwtools-0.3.0.dist-info/WHEEL +4 -0
- epwtools-0.3.0.dist-info/entry_points.txt +3 -0
epwtools/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""epw.tools Python SDK — generate and analyse EPW weather files."""
|
|
2
|
+
__version__ = "0.3.0"
|
|
3
|
+
|
|
4
|
+
from .client import EPWClient, EPWToolsError # noqa: F401
|
|
5
|
+
|
|
6
|
+
# AsyncEPWClient is an optional re-export gated on httpx availability.
|
|
7
|
+
# Importing the symbol unconditionally would force everyone to install
|
|
8
|
+
# httpx; instead we provide a lazy attribute that raises an informative
|
|
9
|
+
# error only when the user actually reaches for it.
|
|
10
|
+
|
|
11
|
+
def __getattr__(name): # noqa: D401
|
|
12
|
+
if name == "AsyncEPWClient":
|
|
13
|
+
from .aio import AsyncEPWClient as _A
|
|
14
|
+
return _A
|
|
15
|
+
raise AttributeError(f"module 'epwtools' has no attribute {name!r}")
|
epwtools/_namespaces.py
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
"""
|
|
2
|
+
epwtools._namespaces
|
|
3
|
+
====================
|
|
4
|
+
Endpoint groupings attached to ``EPWClient``.
|
|
5
|
+
|
|
6
|
+
This file holds the implementation of ``client.tools``, ``client.epw``,
|
|
7
|
+
``client.reports``, ``client.jobs``, and ``client.data`` so that
|
|
8
|
+
``client.py`` stays focused on the high-level "I want a weather file"
|
|
9
|
+
API surface (``client.generate()``, ``client.tmy()``, ``client.future()``).
|
|
10
|
+
|
|
11
|
+
Each namespace holds a back-reference to the parent client and uses its
|
|
12
|
+
``_get`` / ``_post`` helpers. Keep them thin: one method per endpoint,
|
|
13
|
+
docstrings short, no business logic. Anything more elaborate belongs in
|
|
14
|
+
the parent client where it can be discovered via tab-completion.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import TYPE_CHECKING, Any, Iterator
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
22
|
+
from .client import EPWClient
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ── /api/jobs/* ───────────────────────────────────────────────────────────────
|
|
26
|
+
class _JobsAPI:
|
|
27
|
+
"""Job lifecycle endpoints. Most users won't need this — the main
|
|
28
|
+
``client.generate()`` / ``client.tmy()`` etc. methods poll on your
|
|
29
|
+
behalf. Use this when you want manual control (long-running jobs,
|
|
30
|
+
callbacks, batch dashboards)."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, client: "EPWClient"):
|
|
33
|
+
self._c = client
|
|
34
|
+
|
|
35
|
+
def get(self, job_id: str) -> dict:
|
|
36
|
+
"""Return the full job dict (status, logs, result)."""
|
|
37
|
+
return self._c._get(f"/jobs/{job_id}")
|
|
38
|
+
|
|
39
|
+
def cancel(self, job_id: str) -> dict:
|
|
40
|
+
"""Cancel a queued or in-flight job. No-op if already complete."""
|
|
41
|
+
# The backend route is ``DELETE /api/jobs/{id}``.
|
|
42
|
+
r = self._c._session.delete(
|
|
43
|
+
f"{self._c.base}/jobs/{job_id}", timeout=self._c.timeout
|
|
44
|
+
)
|
|
45
|
+
self._c._raise(r)
|
|
46
|
+
return r.json() if r.content else {"job_id": job_id, "cancelled": True}
|
|
47
|
+
|
|
48
|
+
def expires_at(self, job_id: str) -> dict:
|
|
49
|
+
"""Server-side TTL for the job's output files. Useful for archival
|
|
50
|
+
before files are GC'd (default retention varies by tier)."""
|
|
51
|
+
return self._c._get(f"/jobs/{job_id}/expires_at")
|
|
52
|
+
|
|
53
|
+
def parsed_epw(self, job_id: str, epw_filename: str) -> dict:
|
|
54
|
+
"""Return the parsed-EPW JSON payload (header + 8760 rows)."""
|
|
55
|
+
return self._c._get(f"/jobs/{job_id}/parsed-epw/{epw_filename}")
|
|
56
|
+
|
|
57
|
+
def csv(self, job_id: str, epw_filename: str, out_dir: Path | str = ".") -> Path:
|
|
58
|
+
"""Download the EPW as CSV (8760 hourly rows + header)."""
|
|
59
|
+
url = f"{self._c.base}/jobs/{job_id}/csv/{epw_filename}"
|
|
60
|
+
resp = self._c._session.get(url, timeout=120)
|
|
61
|
+
self._c._raise(resp)
|
|
62
|
+
out = Path(out_dir)
|
|
63
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
# Re-use the parent client's path-traversal guard (CWE-22).
|
|
65
|
+
path = out / self._c._safe_filename(
|
|
66
|
+
epw_filename.replace(".epw", ".csv"), "output.csv"
|
|
67
|
+
)
|
|
68
|
+
path.write_bytes(resp.content)
|
|
69
|
+
return path
|
|
70
|
+
|
|
71
|
+
def report_pdf(self, job_id: str, out_dir: Path | str = ".") -> Path:
|
|
72
|
+
"""Download the branded PDF report for a completed job."""
|
|
73
|
+
url = f"{self._c.base}/jobs/{job_id}/report.pdf"
|
|
74
|
+
resp = self._c._session.get(url, timeout=120)
|
|
75
|
+
self._c._raise(resp)
|
|
76
|
+
out = Path(out_dir)
|
|
77
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
path = out / f"{job_id[:8]}_report.pdf"
|
|
79
|
+
path.write_bytes(resp.content)
|
|
80
|
+
return path
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ── /api/tools/* ──────────────────────────────────────────────────────────────
|
|
84
|
+
class _ToolsAPI:
|
|
85
|
+
"""Post-processing tools that operate on EPW files (or coordinates).
|
|
86
|
+
|
|
87
|
+
Most methods here take a *path to a local EPW file* and return either
|
|
88
|
+
a JSON payload (``epw_diff``, ``koppen``) or a path to a transformed
|
|
89
|
+
output (``uhi_adjust``, ``inject_event``, ``altitude_correct``).
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(self, client: "EPWClient"):
|
|
93
|
+
self._c = client
|
|
94
|
+
|
|
95
|
+
# --- EPW-file mutations (return modified EPW path) -----------------------
|
|
96
|
+
|
|
97
|
+
def _upload_and_return_epw(
|
|
98
|
+
self,
|
|
99
|
+
endpoint: str,
|
|
100
|
+
epw_path: Path | str,
|
|
101
|
+
out_dir: Path | str,
|
|
102
|
+
extra_data: dict | None = None,
|
|
103
|
+
) -> Path:
|
|
104
|
+
"""Common upload-EPW-and-download-modified-EPW helper."""
|
|
105
|
+
epw_path = Path(epw_path)
|
|
106
|
+
if not epw_path.exists():
|
|
107
|
+
raise FileNotFoundError(f"EPW file not found: {epw_path}")
|
|
108
|
+
with epw_path.open("rb") as fh:
|
|
109
|
+
resp = self._c._session.post(
|
|
110
|
+
f"{self._c.base}{endpoint}",
|
|
111
|
+
files={"file": (epw_path.name, fh, "application/octet-stream")},
|
|
112
|
+
data=extra_data or {},
|
|
113
|
+
timeout=120,
|
|
114
|
+
)
|
|
115
|
+
self._c._raise(resp)
|
|
116
|
+
out = Path(out_dir)
|
|
117
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
out_path = out / self._c._safe_filename(
|
|
119
|
+
f"{epw_path.stem}_modified.epw", "modified.epw"
|
|
120
|
+
)
|
|
121
|
+
out_path.write_bytes(resp.content)
|
|
122
|
+
return out_path
|
|
123
|
+
|
|
124
|
+
def uhi_adjust(self, epw_path: Path | str, ucz: str = "UCZ-2",
|
|
125
|
+
out_dir: Path | str = ".") -> Path:
|
|
126
|
+
"""Apply Urban Heat Island morphing for a given Urban Climate Zone
|
|
127
|
+
(``UCZ-1`` dense urban → ``UCZ-7`` rural)."""
|
|
128
|
+
return self._upload_and_return_epw(
|
|
129
|
+
"/tools/uhi-adjust", epw_path, out_dir, {"ucz": ucz}
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def inject_event(self, epw_path: Path | str, event_type: str = "heatwave",
|
|
133
|
+
start_doy: int = 200, duration_days: int = 5,
|
|
134
|
+
intensity_c: float = 5.0,
|
|
135
|
+
out_dir: Path | str = ".") -> Path:
|
|
136
|
+
"""Overlay a synthetic extreme event on an existing EPW.
|
|
137
|
+
|
|
138
|
+
Parameters
|
|
139
|
+
----------
|
|
140
|
+
event_type: ``heatwave`` | ``coldsnap`` | ``windstorm`` | ``humidity``.
|
|
141
|
+
"""
|
|
142
|
+
return self._upload_and_return_epw(
|
|
143
|
+
"/tools/inject-event", epw_path, out_dir,
|
|
144
|
+
{"event_type": event_type, "start_doy": start_doy,
|
|
145
|
+
"duration_days": duration_days, "intensity_c": intensity_c},
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def wildfire_smoke(self, epw_path: Path | str,
|
|
149
|
+
start_doy: int = 200, duration_days: int = 7,
|
|
150
|
+
aod: float = 1.5,
|
|
151
|
+
out_dir: Path | str = ".") -> Path:
|
|
152
|
+
"""Attenuate solar radiation for wildfire smoke / haze conditions.
|
|
153
|
+
|
|
154
|
+
``aod`` is aerosol optical depth at 550 nm (typical: 0.1 clear,
|
|
155
|
+
1.0 hazy, 3.0 dense smoke)."""
|
|
156
|
+
return self._upload_and_return_epw(
|
|
157
|
+
"/tools/wildfire-smoke", epw_path, out_dir,
|
|
158
|
+
{"start_doy": start_doy, "duration_days": duration_days, "aod": aod},
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def altitude_correct(self, epw_path: Path | str, target_elev_m: float,
|
|
162
|
+
out_dir: Path | str = ".") -> Path:
|
|
163
|
+
"""Re-pressure / lapse-rate-correct an EPW for a different elevation."""
|
|
164
|
+
return self._upload_and_return_epw(
|
|
165
|
+
"/tools/altitude-correct", epw_path, out_dir,
|
|
166
|
+
{"target_elev_m": target_elev_m},
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
def solar_calibrate(self, epw_path: Path | str,
|
|
170
|
+
out_dir: Path | str = ".") -> Path:
|
|
171
|
+
"""Replace MERRA-2 irradiance with NREL PSM v3 (US locations only)."""
|
|
172
|
+
return self._upload_and_return_epw(
|
|
173
|
+
"/tools/solar-calibrate", epw_path, out_dir,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
def gap_fill(self, epw_path: Path | str,
|
|
177
|
+
out_dir: Path | str = ".") -> Path:
|
|
178
|
+
"""Fill missing values in a partial EPW using reanalysis backfill."""
|
|
179
|
+
return self._upload_and_return_epw(
|
|
180
|
+
"/tools/gap-fill", epw_path, out_dir,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
def merge_epw(self, files: list[Path | str], out_dir: Path | str = ".",
|
|
184
|
+
out_name: str = "merged.epw") -> Path:
|
|
185
|
+
"""Merge multiple partial EPWs into one. Files must cover disjoint
|
|
186
|
+
time windows or share the same WMO ID."""
|
|
187
|
+
upload_files = []
|
|
188
|
+
opened = []
|
|
189
|
+
try:
|
|
190
|
+
for p in files:
|
|
191
|
+
pp = Path(p)
|
|
192
|
+
fh = pp.open("rb")
|
|
193
|
+
opened.append(fh)
|
|
194
|
+
upload_files.append(("files", (pp.name, fh, "application/octet-stream")))
|
|
195
|
+
resp = self._c._session.post(
|
|
196
|
+
f"{self._c.base}/tools/merge-epw",
|
|
197
|
+
files=upload_files, timeout=120,
|
|
198
|
+
)
|
|
199
|
+
finally:
|
|
200
|
+
for fh in opened:
|
|
201
|
+
fh.close()
|
|
202
|
+
self._c._raise(resp)
|
|
203
|
+
out = Path(out_dir)
|
|
204
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
205
|
+
path = out / self._c._safe_filename(out_name, "merged.epw")
|
|
206
|
+
path.write_bytes(resp.content)
|
|
207
|
+
return path
|
|
208
|
+
|
|
209
|
+
# --- EPW-file analysis (return JSON) -------------------------------------
|
|
210
|
+
|
|
211
|
+
def epw_diff(self, baseline: Path | str, modified: Path | str) -> dict:
|
|
212
|
+
"""Hourly delta between two EPWs (used by reviewers to verify
|
|
213
|
+
morphing was sensible)."""
|
|
214
|
+
bp, mp = Path(baseline), Path(modified)
|
|
215
|
+
with bp.open("rb") as fh1, mp.open("rb") as fh2:
|
|
216
|
+
resp = self._c._session.post(
|
|
217
|
+
f"{self._c.base}/tools/epw-diff",
|
|
218
|
+
files={
|
|
219
|
+
"baseline": (bp.name, fh1, "application/octet-stream"),
|
|
220
|
+
"modified": (mp.name, fh2, "application/octet-stream"),
|
|
221
|
+
},
|
|
222
|
+
timeout=120,
|
|
223
|
+
)
|
|
224
|
+
self._c._raise(resp)
|
|
225
|
+
return resp.json()
|
|
226
|
+
|
|
227
|
+
def koppen(self, epw_path: Path | str) -> dict:
|
|
228
|
+
"""Return the Köppen–Geiger and ASHRAE/IECC climate-zone for an EPW."""
|
|
229
|
+
epw_path = Path(epw_path)
|
|
230
|
+
with epw_path.open("rb") as fh:
|
|
231
|
+
resp = self._c._session.post(
|
|
232
|
+
f"{self._c.base}/tools/koppen",
|
|
233
|
+
files={"file": (epw_path.name, fh, "application/octet-stream")},
|
|
234
|
+
timeout=60,
|
|
235
|
+
)
|
|
236
|
+
self._c._raise(resp)
|
|
237
|
+
return resp.json()
|
|
238
|
+
|
|
239
|
+
def solar_check(self, epw_path: Path | str) -> dict:
|
|
240
|
+
"""Independent solar-data sanity check (Zhang–Huang 2002)."""
|
|
241
|
+
epw_path = Path(epw_path)
|
|
242
|
+
with epw_path.open("rb") as fh:
|
|
243
|
+
resp = self._c._session.post(
|
|
244
|
+
f"{self._c.base}/tools/solar-check",
|
|
245
|
+
files={"file": (epw_path.name, fh, "application/octet-stream")},
|
|
246
|
+
timeout=60,
|
|
247
|
+
)
|
|
248
|
+
self._c._raise(resp)
|
|
249
|
+
return resp.json()
|
|
250
|
+
|
|
251
|
+
def ground_temps(self, epw_path: Path | str) -> dict:
|
|
252
|
+
"""Compute monthly ground-temperature profile (Kusuda 1965)."""
|
|
253
|
+
epw_path = Path(epw_path)
|
|
254
|
+
with epw_path.open("rb") as fh:
|
|
255
|
+
resp = self._c._session.post(
|
|
256
|
+
f"{self._c.base}/tools/ground-temps",
|
|
257
|
+
files={"file": (epw_path.name, fh, "application/octet-stream")},
|
|
258
|
+
timeout=60,
|
|
259
|
+
)
|
|
260
|
+
self._c._raise(resp)
|
|
261
|
+
return resp.json()
|
|
262
|
+
|
|
263
|
+
def billing_dd(self, epw_path: Path | str,
|
|
264
|
+
heating_balance: float = 18.3,
|
|
265
|
+
cooling_balance: float = 18.3) -> dict:
|
|
266
|
+
"""Heating / cooling / billing degree-days at given balance points."""
|
|
267
|
+
epw_path = Path(epw_path)
|
|
268
|
+
with epw_path.open("rb") as fh:
|
|
269
|
+
resp = self._c._session.post(
|
|
270
|
+
f"{self._c.base}/tools/billing-dd",
|
|
271
|
+
files={"file": (epw_path.name, fh, "application/octet-stream")},
|
|
272
|
+
data={"heating_balance": heating_balance,
|
|
273
|
+
"cooling_balance": cooling_balance},
|
|
274
|
+
timeout=60,
|
|
275
|
+
)
|
|
276
|
+
self._c._raise(resp)
|
|
277
|
+
return resp.json()
|
|
278
|
+
|
|
279
|
+
def monthly_dd(self, epw_path: Path | str,
|
|
280
|
+
heating_balance: float = 18.3,
|
|
281
|
+
cooling_balance: float = 18.3) -> dict:
|
|
282
|
+
"""Monthly HDD / CDD breakdown."""
|
|
283
|
+
epw_path = Path(epw_path)
|
|
284
|
+
with epw_path.open("rb") as fh:
|
|
285
|
+
resp = self._c._session.post(
|
|
286
|
+
f"{self._c.base}/tools/monthly-dd",
|
|
287
|
+
files={"file": (epw_path.name, fh, "application/octet-stream")},
|
|
288
|
+
data={"heating_balance": heating_balance,
|
|
289
|
+
"cooling_balance": cooling_balance},
|
|
290
|
+
timeout=60,
|
|
291
|
+
)
|
|
292
|
+
self._c._raise(resp)
|
|
293
|
+
return resp.json()
|
|
294
|
+
|
|
295
|
+
def uhi_predict(self, lat: float, lon: float) -> dict:
|
|
296
|
+
"""Predicted UHI ΔT for a coordinate from the trained model."""
|
|
297
|
+
return self._c._get(f"/tools/uhi/predict?lat={lat}&lon={lon}")
|
|
298
|
+
|
|
299
|
+
def ucz_types(self) -> list[dict]:
|
|
300
|
+
"""List of supported Urban Climate Zone classes."""
|
|
301
|
+
return self._c._get("/tools/uhi/ucz-types")
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# ── /api/epw/* (visualisation + analysis on a job's EPW) ──────────────────────
|
|
305
|
+
class _EPWAnalysisAPI:
|
|
306
|
+
"""EPW visualisation endpoints (heatmap, wind-rose, psychrometric, etc.).
|
|
307
|
+
These operate on a *job_id + filename* pair rather than uploading raw
|
|
308
|
+
EPWs — call them after :meth:`EPWClient.generate` returns."""
|
|
309
|
+
|
|
310
|
+
def __init__(self, client: "EPWClient"):
|
|
311
|
+
self._c = client
|
|
312
|
+
|
|
313
|
+
def _q(self, endpoint: str, job_id: str, filename: str, **extra) -> dict:
|
|
314
|
+
params = {"job_id": job_id, "filename": filename, **extra}
|
|
315
|
+
qs = "&".join(f"{k}={v}" for k, v in params.items() if v is not None)
|
|
316
|
+
return self._c._get(f"{endpoint}?{qs}")
|
|
317
|
+
|
|
318
|
+
def wind_rose(self, job_id: str, filename: str) -> dict:
|
|
319
|
+
"""16 directions × 6 speed bins."""
|
|
320
|
+
return self._q("/epw/wind-rose", job_id, filename)
|
|
321
|
+
|
|
322
|
+
def heatmap(self, job_id: str, filename: str, variable: str = "dry_bulb") -> dict:
|
|
323
|
+
"""Hourly heatmap (8760 cells = 365 days × 24 hours) for a variable."""
|
|
324
|
+
return self._q("/epw/heatmap", job_id, filename, variable=variable)
|
|
325
|
+
|
|
326
|
+
def psychro(self, job_id: str, filename: str) -> dict:
|
|
327
|
+
"""Psychrometric-chart points (8760 dry-bulb / humidity-ratio pairs)."""
|
|
328
|
+
return self._q("/epw/psychro", job_id, filename)
|
|
329
|
+
|
|
330
|
+
def sun_path(self, job_id: str, filename: str) -> dict:
|
|
331
|
+
"""Sun-path geometry (azimuth / altitude) per hour."""
|
|
332
|
+
return self._q("/epw/sun-path", job_id, filename)
|
|
333
|
+
|
|
334
|
+
def koppen(self, job_id: str, filename: str) -> dict:
|
|
335
|
+
"""Köppen–Geiger classification for the EPW."""
|
|
336
|
+
return self._q("/epw/koppen", job_id, filename)
|
|
337
|
+
|
|
338
|
+
def pv_yield(self, job_id: str, filename: str,
|
|
339
|
+
dc_capacity_kw: float = 1.0, tilt: float = 30,
|
|
340
|
+
azimuth: float = 180) -> dict:
|
|
341
|
+
"""PV simulation (annual & monthly kWh per kW DC)."""
|
|
342
|
+
return self._q(
|
|
343
|
+
"/epw/pv-yield", job_id, filename,
|
|
344
|
+
dc_capacity_kw=dc_capacity_kw, tilt=tilt, azimuth=azimuth,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
def demand(self, job_id: str, filename: str) -> dict:
|
|
348
|
+
"""Heating / cooling demand profile (HDD / CDD / monthly)."""
|
|
349
|
+
return self._q("/epw/demand", job_id, filename)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
# ── /api/reports/* ────────────────────────────────────────────────────────────
|
|
353
|
+
class _ReportsAPI:
|
|
354
|
+
"""Domain-specific PDF/JSON reports."""
|
|
355
|
+
|
|
356
|
+
def __init__(self, client: "EPWClient"):
|
|
357
|
+
self._c = client
|
|
358
|
+
|
|
359
|
+
def _post_epw(self, endpoint: str, epw_path: Path | str,
|
|
360
|
+
extra: dict | None = None) -> dict:
|
|
361
|
+
epw_path = Path(epw_path)
|
|
362
|
+
with epw_path.open("rb") as fh:
|
|
363
|
+
resp = self._c._session.post(
|
|
364
|
+
f"{self._c.base}{endpoint}",
|
|
365
|
+
files={"file": (epw_path.name, fh, "application/octet-stream")},
|
|
366
|
+
data=extra or {},
|
|
367
|
+
timeout=120,
|
|
368
|
+
)
|
|
369
|
+
self._c._raise(resp)
|
|
370
|
+
return resp.json()
|
|
371
|
+
|
|
372
|
+
def solar(self, epw_path: Path | str) -> dict:
|
|
373
|
+
"""Solar-design report (POA, PR, Bird clear-sky, monthly + annual)."""
|
|
374
|
+
return self._post_epw("/reports/solar", epw_path)
|
|
375
|
+
|
|
376
|
+
def viticulture(self, epw_path: Path | str) -> dict:
|
|
377
|
+
"""Viticulture report (GDD, Huglin, Winkler region, frost risk)."""
|
|
378
|
+
return self._post_epw("/reports/viticulture", epw_path)
|
|
379
|
+
|
|
380
|
+
def datacenter(self, epw_path: Path | str) -> dict:
|
|
381
|
+
"""Datacenter report (free-cooling hours, mechanical cooling, PUE est.)."""
|
|
382
|
+
return self._post_epw("/reports/datacenter", epw_path)
|
|
383
|
+
|
|
384
|
+
def stormwater(self, epw_path: Path | str) -> dict:
|
|
385
|
+
"""Stormwater IDF curves (intensity-duration-frequency)."""
|
|
386
|
+
return self._post_epw("/reports/stormwater", epw_path)
|
|
387
|
+
|
|
388
|
+
def insurance(self, epw_path: Path | str,
|
|
389
|
+
heat_threshold: float = 38, freeze_threshold: float = -10,
|
|
390
|
+
wind_threshold: float = 20) -> dict:
|
|
391
|
+
"""Parametric-insurance triggers (heat days / freeze days / wind events)."""
|
|
392
|
+
return self._post_epw(
|
|
393
|
+
"/reports/insurance", epw_path,
|
|
394
|
+
{"heat_threshold": heat_threshold,
|
|
395
|
+
"freeze_threshold": freeze_threshold,
|
|
396
|
+
"wind_threshold": wind_threshold},
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
# ── /api/cities, /api/stations, /api/cmip6, /api/qc-report ────────────────────
|
|
401
|
+
class _DataAPI:
|
|
402
|
+
"""Read-only catalog endpoints — useful for building UIs on top of
|
|
403
|
+
the API or pre-flight-checking that data is available for a location."""
|
|
404
|
+
|
|
405
|
+
def __init__(self, client: "EPWClient"):
|
|
406
|
+
self._c = client
|
|
407
|
+
|
|
408
|
+
def cities(self) -> list[dict]:
|
|
409
|
+
"""All ~3,000 curated cities the platform knows about."""
|
|
410
|
+
return self._c._get("/cities")
|
|
411
|
+
|
|
412
|
+
def city_instant(self, slug: str) -> dict:
|
|
413
|
+
"""Pre-cached instant-preview EPW for a city (no credits)."""
|
|
414
|
+
return self._c._get(f"/cities/{slug}/instant")
|
|
415
|
+
|
|
416
|
+
def stations_isd(self, lat: float, lon: float, radius_km: float = 50) -> list[dict]:
|
|
417
|
+
"""NOAA ISD stations within ``radius_km`` of a point."""
|
|
418
|
+
return self._c._get(f"/stations/isd?lat={lat}&lon={lon}&radius_km={radius_km}")
|
|
419
|
+
|
|
420
|
+
def stations_nearby(self, lat: float, lon: float,
|
|
421
|
+
radius_km: float = 50, limit: int = 10) -> list[dict]:
|
|
422
|
+
"""All known stations (ISD + Meteostat + airport ICAOs) within radius."""
|
|
423
|
+
return self._c._get(
|
|
424
|
+
f"/stations/nearby?lat={lat}&lon={lon}"
|
|
425
|
+
f"&radius_km={radius_km}&limit={limit}"
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
def stations_nearest(self, lat: float, lon: float) -> dict:
|
|
429
|
+
"""Single nearest station (for "preview station name" UIs)."""
|
|
430
|
+
return self._c._get(f"/stations/nearest?lat={lat}&lon={lon}")
|
|
431
|
+
|
|
432
|
+
def design_conditions(self, lat: float, lon: float, edition: str = "2025") -> dict:
|
|
433
|
+
"""ASHRAE design conditions for the nearest reference station."""
|
|
434
|
+
return self._c._get(
|
|
435
|
+
f"/design-conditions?lat={lat}&lon={lon}&edition={edition}"
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
def qc_report(self, job_id: str) -> dict:
|
|
439
|
+
"""WMO No.8 / NWS QC report for a completed job."""
|
|
440
|
+
return self._c._get(f"/qc-report?job_id={job_id}")
|
|
441
|
+
|
|
442
|
+
def cmip6_models(self) -> list[dict]:
|
|
443
|
+
"""Available CMIP6 GCMs for future-climate morphing."""
|
|
444
|
+
return self._c._get("/cmip6/models")
|
|
445
|
+
|
|
446
|
+
def future_model_range(self, lat: float, lon: float,
|
|
447
|
+
scenario: str = "ssp245",
|
|
448
|
+
horizon: int = 2050) -> dict:
|
|
449
|
+
"""Multi-model range (min / median / max ΔT) at a location."""
|
|
450
|
+
return self._c._get(
|
|
451
|
+
f"/future/model-range?lat={lat}&lon={lon}"
|
|
452
|
+
f"&scenario={scenario}&horizon={horizon}"
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
__all__ = [
|
|
457
|
+
"_JobsAPI", "_ToolsAPI", "_EPWAnalysisAPI", "_ReportsAPI", "_DataAPI",
|
|
458
|
+
]
|