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 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}")
@@ -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
+ ]