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.
Files changed (25) hide show
  1. {pyepwmorph-3.1.0 → pyepwmorph-3.2.0}/PKG-INFO +2 -2
  2. {pyepwmorph-3.1.0 → pyepwmorph-3.2.0}/README.md +1 -1
  3. {pyepwmorph-3.1.0 → pyepwmorph-3.2.0}/pyepwmorph/models/ch2025.py +2 -4
  4. {pyepwmorph-3.1.0 → pyepwmorph-3.2.0}/pyepwmorph/morph/procedures.py +11 -2
  5. {pyepwmorph-3.1.0 → pyepwmorph-3.2.0}/pyepwmorph/tools/configuration.py +53 -20
  6. {pyepwmorph-3.1.0 → pyepwmorph-3.2.0}/pyepwmorph/tools/io.py +75 -5
  7. {pyepwmorph-3.1.0 → pyepwmorph-3.2.0}/pyproject.toml +1 -1
  8. {pyepwmorph-3.1.0 → pyepwmorph-3.2.0}/.gitignore +0 -0
  9. {pyepwmorph-3.1.0 → pyepwmorph-3.2.0}/LICENSE +0 -0
  10. {pyepwmorph-3.1.0 → pyepwmorph-3.2.0}/pyepwmorph/__init__.py +0 -0
  11. {pyepwmorph-3.1.0 → pyepwmorph-3.2.0}/pyepwmorph/data/__init__.py +0 -0
  12. {pyepwmorph-3.1.0 → pyepwmorph-3.2.0}/pyepwmorph/data/ch2025_monthly.parquet +0 -0
  13. {pyepwmorph-3.1.0 → pyepwmorph-3.2.0}/pyepwmorph/data/ch2025_stations.parquet +0 -0
  14. {pyepwmorph-3.1.0 → pyepwmorph-3.2.0}/pyepwmorph/models/__init__.py +0 -0
  15. {pyepwmorph-3.1.0 → pyepwmorph-3.2.0}/pyepwmorph/models/access.py +0 -0
  16. {pyepwmorph-3.1.0 → pyepwmorph-3.2.0}/pyepwmorph/models/assemble.py +0 -0
  17. {pyepwmorph-3.1.0 → pyepwmorph-3.2.0}/pyepwmorph/models/coordinate.py +0 -0
  18. {pyepwmorph-3.1.0 → pyepwmorph-3.2.0}/pyepwmorph/models/custom.py +0 -0
  19. {pyepwmorph-3.1.0 → pyepwmorph-3.2.0}/pyepwmorph/morph/__init__.py +0 -0
  20. {pyepwmorph-3.1.0 → pyepwmorph-3.2.0}/pyepwmorph/tools/__init__.py +0 -0
  21. {pyepwmorph-3.1.0 → pyepwmorph-3.2.0}/pyepwmorph/tools/cache.py +0 -0
  22. {pyepwmorph-3.1.0 → pyepwmorph-3.2.0}/pyepwmorph/tools/psychrometrics.py +0 -0
  23. {pyepwmorph-3.1.0 → pyepwmorph-3.2.0}/pyepwmorph/tools/solar.py +0 -0
  24. {pyepwmorph-3.1.0 → pyepwmorph-3.2.0}/pyepwmorph/tools/utilities.py +0 -0
  25. {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.1.0
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. If the EPW's years fall outside 1991-2020, a `UserWarning` recommends a TMY built from 1991-2020 data and the signal is still applied to the file as it stands. The same caveats are listed in `MorphConfig.ch2025_notes` and the matched station in `MorphConfig.ch2025_station`.
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. If the EPW's years fall outside 1991-2020, a `UserWarning` recommends a TMY built from 1991-2020 data and the signal is still applied to the file as it stands. The same caveats are listed in `MorphConfig.ch2025_notes` and the matched station in `MorphConfig.ch2025_station`.
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 lies inside the CH2025 reference period."""
97
- start, end = (int(year) for year in baseline_range)
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 the year of a DatetimeIndex, or the default solar year."""
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
- return int(index[0].year)
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 to users.
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.detect_baseline_range()
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
- if not baseline_matches(self.baseline_range):
295
- start, end = (int(year) for year in self.baseline_range)
296
- ref_start, ref_end = CH2025_BASELINE_RANGE
297
- if end > ref_end:
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 detect_baseline_range(self) -> tuple[int, int]:
251
- """Detect the baseline year range from EPW comments or data."""
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.dataframe['year'].to_numpy()
256
- return (int(years.min()), int(years.max()))
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]
@@ -17,7 +17,7 @@ core-metadata-version = "2.4"
17
17
 
18
18
  [project]
19
19
  name = "pyepwmorph"
20
- version = "3.1.0"
20
+ version = "3.2.0"
21
21
  authors = [
22
22
  { name="Justin McCarty", email="mccarty.justin.f@gmail.com" },
23
23
  ]
File without changes
File without changes