pyepwmorph 3.1.0__tar.gz → 3.2.0__tar.gz
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.
- {pyepwmorph-3.1.0 → pyepwmorph-3.2.0}/PKG-INFO +2 -2
- {pyepwmorph-3.1.0 → pyepwmorph-3.2.0}/README.md +1 -1
- {pyepwmorph-3.1.0 → pyepwmorph-3.2.0}/pyepwmorph/models/ch2025.py +2 -4
- {pyepwmorph-3.1.0 → pyepwmorph-3.2.0}/pyepwmorph/morph/procedures.py +11 -2
- {pyepwmorph-3.1.0 → pyepwmorph-3.2.0}/pyepwmorph/tools/configuration.py +53 -20
- {pyepwmorph-3.1.0 → pyepwmorph-3.2.0}/pyepwmorph/tools/io.py +75 -5
- {pyepwmorph-3.1.0 → pyepwmorph-3.2.0}/pyproject.toml +1 -1
- {pyepwmorph-3.1.0 → pyepwmorph-3.2.0}/.gitignore +0 -0
- {pyepwmorph-3.1.0 → pyepwmorph-3.2.0}/LICENSE +0 -0
- {pyepwmorph-3.1.0 → pyepwmorph-3.2.0}/pyepwmorph/__init__.py +0 -0
- {pyepwmorph-3.1.0 → pyepwmorph-3.2.0}/pyepwmorph/data/__init__.py +0 -0
- {pyepwmorph-3.1.0 → pyepwmorph-3.2.0}/pyepwmorph/data/ch2025_monthly.parquet +0 -0
- {pyepwmorph-3.1.0 → pyepwmorph-3.2.0}/pyepwmorph/data/ch2025_stations.parquet +0 -0
- {pyepwmorph-3.1.0 → pyepwmorph-3.2.0}/pyepwmorph/models/__init__.py +0 -0
- {pyepwmorph-3.1.0 → pyepwmorph-3.2.0}/pyepwmorph/models/access.py +0 -0
- {pyepwmorph-3.1.0 → pyepwmorph-3.2.0}/pyepwmorph/models/assemble.py +0 -0
- {pyepwmorph-3.1.0 → pyepwmorph-3.2.0}/pyepwmorph/models/coordinate.py +0 -0
- {pyepwmorph-3.1.0 → pyepwmorph-3.2.0}/pyepwmorph/models/custom.py +0 -0
- {pyepwmorph-3.1.0 → pyepwmorph-3.2.0}/pyepwmorph/morph/__init__.py +0 -0
- {pyepwmorph-3.1.0 → pyepwmorph-3.2.0}/pyepwmorph/tools/__init__.py +0 -0
- {pyepwmorph-3.1.0 → pyepwmorph-3.2.0}/pyepwmorph/tools/cache.py +0 -0
- {pyepwmorph-3.1.0 → pyepwmorph-3.2.0}/pyepwmorph/tools/psychrometrics.py +0 -0
- {pyepwmorph-3.1.0 → pyepwmorph-3.2.0}/pyepwmorph/tools/solar.py +0 -0
- {pyepwmorph-3.1.0 → pyepwmorph-3.2.0}/pyepwmorph/tools/utilities.py +0 -0
- {pyepwmorph-3.1.0 → pyepwmorph-3.2.0}/pyepwmorph/tools/workflow.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pyepwmorph
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.2.0
|
|
4
4
|
Summary: A python package to enable simple and easy gathering of climate model data and morphing of EPW files
|
|
5
5
|
Project-URL: Homepage, https://github.com/justinfmccarty/pyepwmorph
|
|
6
6
|
Project-URL: Issues, https://github.com/justinfmccarty/pyepwmorph/issues
|
|
@@ -148,7 +148,7 @@ morphed = results["gwl2.0"]["50"]
|
|
|
148
148
|
|
|
149
149
|
The site is matched to the nearest station that has the requested variables (or, with `ch2025_full_coverage=True`, the nearest station that has every CH2025 variable), with an elevation penalty so a much higher station is not chosen just because it is close on the map. Supported variables are temperature, humidity, dew point, wind, and radiation (global, diffuse, and direct). Pressure and cloud cover are not in CH2025; requesting them raises an error. Sky cover is left at the EPW's baseline values when radiation is morphed, so longwave sky temperature in EnergyPlus does not follow the shortwave change.
|
|
150
150
|
|
|
151
|
-
The signal is the change from the 1991-2020 reference climate to the chosen warming level. Each model chain's change is computed first, over the chains that reach that warming level, and the percentiles are taken of those changes.
|
|
151
|
+
The signal is the change from the 1991-2020 reference climate to the chosen warming level. Each model chain's change is computed first, over the chains that reach that warming level, and the percentiles are taken of those changes. The EPW's period is read from the `Period of Record` in its header. If that is not exactly 1991-2020, a `UserWarning` recommends a TMY built from 1991-2020 data. If the header states no period, a second warning says the period could not be detected, and the mismatch warning still fires if the data rows' years fall outside 1991-2020. The signal is applied to the file as it stands either way. `tools.io.write_period_of_record` adds the statement to files built without one. The same caveats are listed in `MorphConfig.ch2025_notes` and the matched station in `MorphConfig.ch2025_station`.
|
|
152
152
|
|
|
153
153
|
CH2025 data: MeteoSwiss & ETH Zurich (2025), Climate CH2025 - Daily Datasets, CC-BY 4.0, https://doi.org/10.18751/climate/scenarios/ch2025/data/1.0/
|
|
154
154
|
|
|
@@ -109,7 +109,7 @@ morphed = results["gwl2.0"]["50"]
|
|
|
109
109
|
|
|
110
110
|
The site is matched to the nearest station that has the requested variables (or, with `ch2025_full_coverage=True`, the nearest station that has every CH2025 variable), with an elevation penalty so a much higher station is not chosen just because it is close on the map. Supported variables are temperature, humidity, dew point, wind, and radiation (global, diffuse, and direct). Pressure and cloud cover are not in CH2025; requesting them raises an error. Sky cover is left at the EPW's baseline values when radiation is morphed, so longwave sky temperature in EnergyPlus does not follow the shortwave change.
|
|
111
111
|
|
|
112
|
-
The signal is the change from the 1991-2020 reference climate to the chosen warming level. Each model chain's change is computed first, over the chains that reach that warming level, and the percentiles are taken of those changes.
|
|
112
|
+
The signal is the change from the 1991-2020 reference climate to the chosen warming level. Each model chain's change is computed first, over the chains that reach that warming level, and the percentiles are taken of those changes. The EPW's period is read from the `Period of Record` in its header. If that is not exactly 1991-2020, a `UserWarning` recommends a TMY built from 1991-2020 data. If the header states no period, a second warning says the period could not be detected, and the mismatch warning still fires if the data rows' years fall outside 1991-2020. The signal is applied to the file as it stands either way. `tools.io.write_period_of_record` adds the statement to files built without one. The same caveats are listed in `MorphConfig.ch2025_notes` and the matched station in `MorphConfig.ch2025_station`.
|
|
113
113
|
|
|
114
114
|
CH2025 data: MeteoSwiss & ETH Zurich (2025), Climate CH2025 - Daily Datasets, CC-BY 4.0, https://doi.org/10.18751/climate/scenarios/ch2025/data/1.0/
|
|
115
115
|
|
|
@@ -93,10 +93,8 @@ def reference_key(state: str) -> str:
|
|
|
93
93
|
|
|
94
94
|
|
|
95
95
|
def baseline_matches(baseline_range) -> bool:
|
|
96
|
-
"""Return whether an EPW baseline
|
|
97
|
-
|
|
98
|
-
ref_start, ref_end = CH2025_BASELINE_RANGE
|
|
99
|
-
return ref_start <= start and end <= ref_end
|
|
96
|
+
"""Return whether an EPW baseline is exactly the CH2025 reference period."""
|
|
97
|
+
return tuple(int(year) for year in baseline_range) == CH2025_BASELINE_RANGE
|
|
100
98
|
|
|
101
99
|
|
|
102
100
|
def in_switzerland(latitude: float, longitude: float) -> bool:
|
|
@@ -7,6 +7,8 @@ Container for all of the individual morphing calculations which can be traced ba
|
|
|
7
7
|
55 514–524 ISSN 0960-1481 URL https://www.sciencedirect.com/science/article/pii/S0960148113000232
|
|
8
8
|
|
|
9
9
|
"""
|
|
10
|
+
import calendar
|
|
11
|
+
|
|
10
12
|
import numpy as np
|
|
11
13
|
import pandas as pd
|
|
12
14
|
|
|
@@ -33,10 +35,17 @@ def _index_of(*candidates):
|
|
|
33
35
|
|
|
34
36
|
|
|
35
37
|
def _year_of(index):
|
|
36
|
-
"""Return
|
|
38
|
+
"""Return a non-leap year to build solar geometry for the data in *index*.
|
|
39
|
+
|
|
40
|
+
EPW files always hold 8760 hours with no 29 February, so their calendar
|
|
41
|
+
is a non-leap year's even when the first data row says 2016. Building
|
|
42
|
+
daily series for a leap year would add an empty 29 February and shift
|
|
43
|
+
every later day by one, so a leap year is replaced by the year before.
|
|
44
|
+
"""
|
|
37
45
|
if index is None or len(index) == 0:
|
|
38
46
|
return morph_solar_utils.DEFAULT_SOLAR_YEAR
|
|
39
|
-
|
|
47
|
+
year = int(index[0].year)
|
|
48
|
+
return year - 1 if calendar.isleap(year) else year
|
|
40
49
|
|
|
41
50
|
|
|
42
51
|
def _as_series(values, index, name, decimals=2):
|
|
@@ -70,6 +70,46 @@ MORPH_ORDER = [
|
|
|
70
70
|
]
|
|
71
71
|
|
|
72
72
|
|
|
73
|
+
def _ch2025_baseline_notes(baseline_range, source, reference_range, matches):
|
|
74
|
+
"""Plain-language caveats about how the EPW's period compares with 1991-2020.
|
|
75
|
+
|
|
76
|
+
A stated period (header or caller) must equal the reference exactly. When
|
|
77
|
+
only the data rows are available, the period cannot be confirmed, which
|
|
78
|
+
is its own caveat; years outside the reference are also a mismatch.
|
|
79
|
+
"""
|
|
80
|
+
start, end = (int(year) for year in baseline_range)
|
|
81
|
+
ref_start, ref_end = reference_range
|
|
82
|
+
notes = []
|
|
83
|
+
|
|
84
|
+
if source == "data":
|
|
85
|
+
notes.append(
|
|
86
|
+
f"The period this weather file was built from is not stated in its header, "
|
|
87
|
+
f"so it cannot be checked against the CH2025 reference period "
|
|
88
|
+
f"({ref_start}-{ref_end}). Its months come from {start}-{end}. Check the "
|
|
89
|
+
f"source of the file to confirm the period it represents."
|
|
90
|
+
)
|
|
91
|
+
mismatched = not (ref_start <= start and end <= ref_end)
|
|
92
|
+
described = f"The weather file's months come from {start}-{end}"
|
|
93
|
+
else:
|
|
94
|
+
mismatched = not matches((start, end))
|
|
95
|
+
described = f"The weather file was built from {start}-{end}"
|
|
96
|
+
|
|
97
|
+
if mismatched:
|
|
98
|
+
midpoint = (start + end) / 2.0
|
|
99
|
+
ref_midpoint = (ref_start + ref_end) / 2.0
|
|
100
|
+
if midpoint > ref_midpoint:
|
|
101
|
+
effect = "It is centred later and already holds part of that warming, so the morphed file may overstate it."
|
|
102
|
+
elif midpoint < ref_midpoint:
|
|
103
|
+
effect = "It is centred earlier, so the morphed file may understate the warming."
|
|
104
|
+
else:
|
|
105
|
+
effect = "It spans different years, so the change may not fit it exactly."
|
|
106
|
+
notes.append(
|
|
107
|
+
f"{described}, but CH2025 changes are measured from {ref_start}-{ref_end}. "
|
|
108
|
+
f"{effect} For CH2025, use a TMY built from {ref_start}-{ref_end} data."
|
|
109
|
+
)
|
|
110
|
+
return notes
|
|
111
|
+
|
|
112
|
+
|
|
73
113
|
def _dependencies_for(data_source):
|
|
74
114
|
if data_source == "ch2025":
|
|
75
115
|
return CH2025_VARIABLE_DEPENDENCIES
|
|
@@ -170,7 +210,12 @@ class MorphConfig:
|
|
|
170
210
|
``elevation_difference_m`` and ``weak_match``.
|
|
171
211
|
ch2025_notes : list[str]
|
|
172
212
|
CH2025 only. Plain-language caveats about this morph (baseline
|
|
173
|
-
mismatch, weak station match), for display
|
|
213
|
+
mismatch, undetectable baseline, weak station match), for display
|
|
214
|
+
to users.
|
|
215
|
+
baseline_source : str
|
|
216
|
+
Where ``baseline_range`` came from: ``"user"`` (passed in),
|
|
217
|
+
``"comments"`` (the EPW's Period of Record) or ``"data"`` (the span
|
|
218
|
+
of years in the data rows).
|
|
174
219
|
|
|
175
220
|
Backward Compatibility
|
|
176
221
|
----------------------
|
|
@@ -272,7 +317,9 @@ class MorphConfig:
|
|
|
272
317
|
self.location['elevation'] = self.epw.location['elevation']
|
|
273
318
|
self.location['utc_offset'] = self.epw.location['utc_offset']
|
|
274
319
|
if self.baseline_range is None:
|
|
275
|
-
self.baseline_range = self.epw.
|
|
320
|
+
self.baseline_range, self.baseline_source = self.epw.detect_baseline_period()
|
|
321
|
+
else:
|
|
322
|
+
self.baseline_source = "user"
|
|
276
323
|
|
|
277
324
|
if self.data_source == "ch2025":
|
|
278
325
|
from pyepwmorph.models.ch2025 import (
|
|
@@ -291,24 +338,10 @@ class MorphConfig:
|
|
|
291
338
|
f"CH2025 covers longitude {west} to {east} and latitude {south} to {north}. "
|
|
292
339
|
f"Use data_source='cmip6' for locations outside that domain."
|
|
293
340
|
)
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
effect = (
|
|
299
|
-
f"Years after {ref_end} already contain part of that warming, "
|
|
300
|
-
f"so the morphed file may overstate it."
|
|
301
|
-
)
|
|
302
|
-
else:
|
|
303
|
-
effect = (
|
|
304
|
-
f"Years before {ref_start} were cooler, so the morphed file may "
|
|
305
|
-
f"understate the warming."
|
|
306
|
-
)
|
|
307
|
-
note = (
|
|
308
|
-
f"The EPW covers {start}-{end} but CH2025 changes are measured from "
|
|
309
|
-
f"{ref_start}-{ref_end}. {effect} For CH2025, use a TMY built from "
|
|
310
|
-
f"{ref_start}-{ref_end} data."
|
|
311
|
-
)
|
|
341
|
+
for note in _ch2025_baseline_notes(
|
|
342
|
+
self.baseline_range, self.baseline_source,
|
|
343
|
+
CH2025_BASELINE_RANGE, baseline_matches,
|
|
344
|
+
):
|
|
312
345
|
self.ch2025_notes.append(note)
|
|
313
346
|
warnings.warn(note, UserWarning, stacklevel=2)
|
|
314
347
|
|
|
@@ -109,6 +109,62 @@ def epw_baseline_range(file_content: list[str]) -> tuple[int, int]:
|
|
|
109
109
|
return (int(years.min()), int(years.max()))
|
|
110
110
|
|
|
111
111
|
|
|
112
|
+
#: Matches a "Period of Record" statement, as ClimateOneBuilding writes it.
|
|
113
|
+
_PERIOD_OF_RECORD_RE = re.compile(r"\s*;?\s*Period of Record\s*=?\s*\d{4}\s*-\s*\d{4}")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def epw_data_years(file_content: list[str]) -> list[int]:
|
|
117
|
+
"""Return the distinct years in an EPW's data rows, sorted.
|
|
118
|
+
|
|
119
|
+
A typical year stitches months from different years, so these are the
|
|
120
|
+
years the months were taken from, not the period the file was built
|
|
121
|
+
from. Use ``epw_baseline_range`` when the header states that period.
|
|
122
|
+
"""
|
|
123
|
+
header_len = _find_header_length(file_content)
|
|
124
|
+
years = set()
|
|
125
|
+
for line in file_content[header_len:]:
|
|
126
|
+
field = line.split(",", 1)[0].strip()
|
|
127
|
+
if field.isdigit():
|
|
128
|
+
years.add(int(field))
|
|
129
|
+
if not years:
|
|
130
|
+
raise ValueError("No data rows found in EPW file")
|
|
131
|
+
return sorted(years)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def write_period_of_record(filepath: str, start_year: int, end_year: int) -> None:
|
|
135
|
+
"""Record the years an EPW was built from in its COMMENTS 1 header.
|
|
136
|
+
|
|
137
|
+
Writes ``Period of Record=start-end`` in the form ClimateOneBuilding uses,
|
|
138
|
+
which ``epw_baseline_range`` reads back. Any existing statement is
|
|
139
|
+
replaced. The data rows are not touched. If the file has no COMMENTS 1
|
|
140
|
+
line, one is inserted before COMMENTS 2 or DATA PERIODS.
|
|
141
|
+
"""
|
|
142
|
+
lines = read_epw_string(filepath)
|
|
143
|
+
header_len = _find_header_length(lines)
|
|
144
|
+
statement = f"Period of Record={int(start_year)}-{int(end_year)}"
|
|
145
|
+
|
|
146
|
+
for i, line in enumerate(lines[:header_len]):
|
|
147
|
+
if not line.startswith("COMMENTS 1"):
|
|
148
|
+
continue
|
|
149
|
+
value = line.rstrip("\r\n")[len("COMMENTS 1"):].lstrip(",")
|
|
150
|
+
quoted = len(value) >= 2 and value.startswith('"') and value.endswith('"')
|
|
151
|
+
inner = value[1:-1] if quoted else value
|
|
152
|
+
inner = _PERIOD_OF_RECORD_RE.sub("", inner).strip().rstrip(";").strip()
|
|
153
|
+
inner = f"{inner}; {statement}" if inner else statement
|
|
154
|
+
lines[i] = f'COMMENTS 1,"{inner}"\n' if quoted else f"COMMENTS 1,{inner}\n"
|
|
155
|
+
break
|
|
156
|
+
else:
|
|
157
|
+
position = next(
|
|
158
|
+
(i for i, line in enumerate(lines[:header_len])
|
|
159
|
+
if line.startswith(("COMMENTS 2", "DATA PERIODS"))),
|
|
160
|
+
header_len,
|
|
161
|
+
)
|
|
162
|
+
lines.insert(position, f"COMMENTS 1,{statement}\n")
|
|
163
|
+
|
|
164
|
+
with open(filepath, "w", encoding="utf-8") as fh:
|
|
165
|
+
fh.writelines(lines)
|
|
166
|
+
|
|
167
|
+
|
|
112
168
|
def read_epw_dataframe(filepath: str, normalize_hours: bool = True) -> pd.DataFrame:
|
|
113
169
|
"""Read an EPW file into a pandas DataFrame with an 8760-hour index.
|
|
114
170
|
|
|
@@ -247,10 +303,24 @@ class Epw:
|
|
|
247
303
|
with open(filepath, "w", encoding="utf-8") as fh:
|
|
248
304
|
fh.write(self.make_epw_string())
|
|
249
305
|
|
|
250
|
-
def
|
|
251
|
-
"""Detect the baseline
|
|
306
|
+
def detect_baseline_period(self) -> tuple[tuple[int, int], str]:
|
|
307
|
+
"""Detect the baseline years and say where they came from.
|
|
308
|
+
|
|
309
|
+
Returns
|
|
310
|
+
-------
|
|
311
|
+
tuple
|
|
312
|
+
``((start, end), source)``. *source* is ``"comments"`` when the
|
|
313
|
+
header states a Period of Record, which is the period the file
|
|
314
|
+
was built from. Otherwise it is ``"data"`` and the range spans
|
|
315
|
+
the years in the data rows, which for a typical year are only
|
|
316
|
+
the years its months were taken from.
|
|
317
|
+
"""
|
|
252
318
|
try:
|
|
253
|
-
return epw_baseline_range(self.string)
|
|
319
|
+
return epw_baseline_range(self.string), "comments"
|
|
254
320
|
except (ValueError, IndexError):
|
|
255
|
-
years = self.
|
|
256
|
-
return (
|
|
321
|
+
years = epw_data_years(self.string)
|
|
322
|
+
return (years[0], years[-1]), "data"
|
|
323
|
+
|
|
324
|
+
def detect_baseline_range(self) -> tuple[int, int]:
|
|
325
|
+
"""Detect the baseline year range from EPW comments or data rows."""
|
|
326
|
+
return self.detect_baseline_period()[0]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|