rtc-tools 2.7.3__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.
- rtc_tools-2.7.3.dist-info/METADATA +53 -0
- rtc_tools-2.7.3.dist-info/RECORD +50 -0
- rtc_tools-2.7.3.dist-info/WHEEL +5 -0
- rtc_tools-2.7.3.dist-info/entry_points.txt +3 -0
- rtc_tools-2.7.3.dist-info/licenses/COPYING.LESSER +165 -0
- rtc_tools-2.7.3.dist-info/top_level.txt +1 -0
- rtctools/__init__.py +5 -0
- rtctools/_internal/__init__.py +0 -0
- rtctools/_internal/alias_tools.py +188 -0
- rtctools/_internal/caching.py +25 -0
- rtctools/_internal/casadi_helpers.py +99 -0
- rtctools/_internal/debug_check_helpers.py +41 -0
- rtctools/_version.py +21 -0
- rtctools/data/__init__.py +4 -0
- rtctools/data/csv.py +150 -0
- rtctools/data/interpolation/__init__.py +3 -0
- rtctools/data/interpolation/bspline.py +31 -0
- rtctools/data/interpolation/bspline1d.py +169 -0
- rtctools/data/interpolation/bspline2d.py +54 -0
- rtctools/data/netcdf.py +467 -0
- rtctools/data/pi.py +1236 -0
- rtctools/data/rtc.py +228 -0
- rtctools/data/storage.py +343 -0
- rtctools/optimization/__init__.py +0 -0
- rtctools/optimization/collocated_integrated_optimization_problem.py +3208 -0
- rtctools/optimization/control_tree_mixin.py +221 -0
- rtctools/optimization/csv_lookup_table_mixin.py +462 -0
- rtctools/optimization/csv_mixin.py +300 -0
- rtctools/optimization/goal_programming_mixin.py +769 -0
- rtctools/optimization/goal_programming_mixin_base.py +1094 -0
- rtctools/optimization/homotopy_mixin.py +165 -0
- rtctools/optimization/initial_state_estimation_mixin.py +89 -0
- rtctools/optimization/io_mixin.py +320 -0
- rtctools/optimization/linearization_mixin.py +33 -0
- rtctools/optimization/linearized_order_goal_programming_mixin.py +235 -0
- rtctools/optimization/min_abs_goal_programming_mixin.py +385 -0
- rtctools/optimization/modelica_mixin.py +482 -0
- rtctools/optimization/netcdf_mixin.py +177 -0
- rtctools/optimization/optimization_problem.py +1302 -0
- rtctools/optimization/pi_mixin.py +292 -0
- rtctools/optimization/planning_mixin.py +19 -0
- rtctools/optimization/single_pass_goal_programming_mixin.py +676 -0
- rtctools/optimization/timeseries.py +56 -0
- rtctools/rtctoolsapp.py +131 -0
- rtctools/simulation/__init__.py +0 -0
- rtctools/simulation/csv_mixin.py +171 -0
- rtctools/simulation/io_mixin.py +195 -0
- rtctools/simulation/pi_mixin.py +255 -0
- rtctools/simulation/simulation_problem.py +1293 -0
- rtctools/util.py +241 -0
rtctools/data/pi.py
ADDED
|
@@ -0,0 +1,1236 @@
|
|
|
1
|
+
import bisect
|
|
2
|
+
import datetime
|
|
3
|
+
import io
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import xml.etree.ElementTree as ET
|
|
7
|
+
|
|
8
|
+
import defusedxml.ElementTree as DefusedElementTree
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
ns = {"fews": "http://www.wldelft.nl/fews", "pi": "http://www.wldelft.nl/fews/PI"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Diag:
|
|
15
|
+
"""
|
|
16
|
+
diag wrapper.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
ERROR_FATAL = 1 << 0
|
|
20
|
+
ERROR = 1 << 1
|
|
21
|
+
WARN = 1 << 2
|
|
22
|
+
INFO = 1 << 3
|
|
23
|
+
DEBUG = 1 << 4
|
|
24
|
+
|
|
25
|
+
def __init__(self, folder, basename="diag"):
|
|
26
|
+
"""
|
|
27
|
+
Parse diag file.
|
|
28
|
+
|
|
29
|
+
:param folder: Folder in which diag.xml is found or to be created.
|
|
30
|
+
:param basename: Alternative basename for the diagnostics XML file.
|
|
31
|
+
"""
|
|
32
|
+
self.__path_xml = os.path.join(folder, basename + ".xml")
|
|
33
|
+
|
|
34
|
+
self.__tree = DefusedElementTree.parse(self.__path_xml)
|
|
35
|
+
self.__xml_root = self.__tree.getroot()
|
|
36
|
+
|
|
37
|
+
def get(self, level=ERROR_FATAL):
|
|
38
|
+
"""
|
|
39
|
+
Return only the wanted levels (debug, info, etc.)
|
|
40
|
+
|
|
41
|
+
:param level: Log level.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
diag_lines = self.__xml_root.findall("*", ns)
|
|
45
|
+
diag_lines_out = []
|
|
46
|
+
USED_LEVELS = []
|
|
47
|
+
|
|
48
|
+
if level & self.ERROR_FATAL:
|
|
49
|
+
USED_LEVELS.append("0")
|
|
50
|
+
if level & self.ERROR:
|
|
51
|
+
USED_LEVELS.append("1")
|
|
52
|
+
if level & self.WARN:
|
|
53
|
+
USED_LEVELS.append("2")
|
|
54
|
+
if level & self.INFO:
|
|
55
|
+
USED_LEVELS.append("3")
|
|
56
|
+
if level & self.DEBUG:
|
|
57
|
+
USED_LEVELS.append("4")
|
|
58
|
+
|
|
59
|
+
for child in diag_lines:
|
|
60
|
+
for used_level in USED_LEVELS:
|
|
61
|
+
if child.get("level") == used_level:
|
|
62
|
+
diag_lines_out.append(child)
|
|
63
|
+
|
|
64
|
+
return diag_lines_out
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def has_errors(self):
|
|
68
|
+
"""
|
|
69
|
+
True if the log contains errors.
|
|
70
|
+
"""
|
|
71
|
+
error_levels = self.ERROR_FATAL | self.ERROR
|
|
72
|
+
diag_lines = self.get(error_levels)
|
|
73
|
+
|
|
74
|
+
if len(diag_lines) > 0:
|
|
75
|
+
return True
|
|
76
|
+
else:
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class DiagHandler(logging.Handler):
|
|
81
|
+
"""
|
|
82
|
+
PI diag file logging handler.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(self, folder, basename="diag", level=logging.NOTSET):
|
|
86
|
+
super(DiagHandler, self).__init__(level=level)
|
|
87
|
+
|
|
88
|
+
self.__path_xml = os.path.join(folder, basename + ".xml")
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
self.__tree = DefusedElementTree.parse(self.__path_xml)
|
|
92
|
+
self.__xml_root = self.__tree.getroot()
|
|
93
|
+
except Exception:
|
|
94
|
+
self.__xml_root = ET.Element("{%s}Diag" % (ns["pi"],))
|
|
95
|
+
self.__tree = ET.ElementTree(element=self.__xml_root)
|
|
96
|
+
|
|
97
|
+
self.__map_level = {50: 0, 40: 1, 30: 2, 20: 3, 10: 4, 0: 4}
|
|
98
|
+
|
|
99
|
+
def emit(self, record):
|
|
100
|
+
self.format(record)
|
|
101
|
+
|
|
102
|
+
self.acquire()
|
|
103
|
+
el = ET.SubElement(self.__xml_root, "{%s}line" % (ns["pi"],))
|
|
104
|
+
# Work around cElementTree issue 21403
|
|
105
|
+
el.set("description", record.message)
|
|
106
|
+
el.set("eventCode", record.module + "." + record.funcName)
|
|
107
|
+
el.set("level", str(self.__map_level[record.levelno]))
|
|
108
|
+
self.release()
|
|
109
|
+
|
|
110
|
+
def append_element(self, el):
|
|
111
|
+
self.acquire()
|
|
112
|
+
self.__xml_root.append(el)
|
|
113
|
+
self.release()
|
|
114
|
+
|
|
115
|
+
def flush(self):
|
|
116
|
+
self.__tree.write(self.__path_xml)
|
|
117
|
+
|
|
118
|
+
def close(self):
|
|
119
|
+
self.flush()
|
|
120
|
+
super().close()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class ParameterConfig:
|
|
124
|
+
"""
|
|
125
|
+
rtcParameterConfig wrapper.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
def __init__(self, folder, basename):
|
|
129
|
+
"""
|
|
130
|
+
Parses a rtcParameterConfig file.
|
|
131
|
+
|
|
132
|
+
:param folder: Folder in which the parameter configuration file is located.
|
|
133
|
+
:param basename: Basename of the parameter configuration file (e.g, 'rtcParameterConfig').
|
|
134
|
+
"""
|
|
135
|
+
if os.path.splitext(basename)[1] != ".xml":
|
|
136
|
+
basename = basename + ".xml"
|
|
137
|
+
self.__path_xml = os.path.join(folder, basename)
|
|
138
|
+
|
|
139
|
+
self.__tree = DefusedElementTree.parse(self.__path_xml)
|
|
140
|
+
self.__xml_root = self.__tree.getroot()
|
|
141
|
+
|
|
142
|
+
def get(self, group_id, parameter_id, location_id=None, model=None):
|
|
143
|
+
"""
|
|
144
|
+
Returns the value of the parameter with ID parameter_id in the group with ID group_id.
|
|
145
|
+
|
|
146
|
+
:param group_id: The ID of the parameter group to look in.
|
|
147
|
+
:param parameter_id: The ID of the parameter to look for.
|
|
148
|
+
:param location_id: The optional ID of the parameter location to look in.
|
|
149
|
+
:param model: The optional ID of the parameter model to look in.
|
|
150
|
+
|
|
151
|
+
:returns: The value of the specified parameter.
|
|
152
|
+
"""
|
|
153
|
+
groups = self.__xml_root.findall("pi:group[@id='{}']".format(group_id), ns)
|
|
154
|
+
for group in groups:
|
|
155
|
+
el = group.find("pi:locationId", ns)
|
|
156
|
+
if location_id is not None and el is not None:
|
|
157
|
+
if location_id != el.text:
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
el = group.find("pi:model", ns)
|
|
161
|
+
if model is not None and el is not None:
|
|
162
|
+
if model != el.text:
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
el = group.find("pi:parameter[@id='{}']".format(parameter_id), ns)
|
|
166
|
+
if el is None:
|
|
167
|
+
raise KeyError
|
|
168
|
+
return self.__parse_parameter(el)
|
|
169
|
+
|
|
170
|
+
raise KeyError("No such parameter ({}, {})".format(group_id, parameter_id))
|
|
171
|
+
|
|
172
|
+
def set(self, group_id, parameter_id, new_value, location_id=None, model=None):
|
|
173
|
+
"""
|
|
174
|
+
Set the value of the parameter with ID parameter_id in the group with ID group_id.
|
|
175
|
+
|
|
176
|
+
:param group_id: The ID of the parameter group to look in.
|
|
177
|
+
:param parameter_id: The ID of the parameter to look for.
|
|
178
|
+
:param new_value: The new value for the parameter.
|
|
179
|
+
:param location_id: The optional ID of the parameter location to look in.
|
|
180
|
+
:param model: The optional ID of the parameter model to look in.
|
|
181
|
+
"""
|
|
182
|
+
groups = self.__xml_root.findall("pi:group[@id='{}']".format(group_id), ns)
|
|
183
|
+
for group in groups:
|
|
184
|
+
el = group.find("pi:locationId", ns)
|
|
185
|
+
if location_id is not None and el is not None:
|
|
186
|
+
if location_id != el.text:
|
|
187
|
+
continue
|
|
188
|
+
|
|
189
|
+
el = group.find("pi:model", ns)
|
|
190
|
+
if model is not None and el is not None:
|
|
191
|
+
if model != el.text:
|
|
192
|
+
continue
|
|
193
|
+
|
|
194
|
+
el = group.find("pi:parameter[@id='{}']".format(parameter_id), ns)
|
|
195
|
+
if el is None:
|
|
196
|
+
raise KeyError
|
|
197
|
+
for child in el:
|
|
198
|
+
if child.tag.endswith("boolValue"):
|
|
199
|
+
if new_value is True:
|
|
200
|
+
child.text = "true"
|
|
201
|
+
return
|
|
202
|
+
elif new_value is False:
|
|
203
|
+
child.text = "false"
|
|
204
|
+
return
|
|
205
|
+
else:
|
|
206
|
+
raise Exception("Unsupported value for tag {}".format(child.tag))
|
|
207
|
+
elif child.tag.endswith("intValue"):
|
|
208
|
+
child.text = str(int(new_value))
|
|
209
|
+
return
|
|
210
|
+
elif child.tag.endswith("dblValue"):
|
|
211
|
+
child.text = str(new_value)
|
|
212
|
+
return
|
|
213
|
+
else:
|
|
214
|
+
raise Exception("Unsupported tag {}".format(child.tag))
|
|
215
|
+
|
|
216
|
+
raise KeyError("No such parameter ({}, {})".format(group_id, parameter_id))
|
|
217
|
+
|
|
218
|
+
def write(self, folder=None, basename=None):
|
|
219
|
+
"""
|
|
220
|
+
Writes the parameter configuration to a file.
|
|
221
|
+
Default behaviour is to overwrite original file.
|
|
222
|
+
|
|
223
|
+
:param path: Optional alternative destination folder
|
|
224
|
+
:param basename: Optional alternative basename of the file (e.g, 'rtcParameterConfig')
|
|
225
|
+
"""
|
|
226
|
+
|
|
227
|
+
# No path changes- overwrite original file
|
|
228
|
+
if folder is None and basename is None:
|
|
229
|
+
path = self.path
|
|
230
|
+
|
|
231
|
+
# We need to reconstruct the path
|
|
232
|
+
else:
|
|
233
|
+
# Determine folder
|
|
234
|
+
if folder is not None:
|
|
235
|
+
if not os.path.exists(folder):
|
|
236
|
+
# Make sure folder exists
|
|
237
|
+
raise FileNotFoundError("Folder not found: {}".format(folder))
|
|
238
|
+
else:
|
|
239
|
+
# Reuse folder of original file
|
|
240
|
+
folder = os.path.dirname(self.path)
|
|
241
|
+
|
|
242
|
+
# Determine basename
|
|
243
|
+
if basename is not None:
|
|
244
|
+
# Make sure basename ends in '.xml'
|
|
245
|
+
if os.path.splitext(basename)[1] != ".xml":
|
|
246
|
+
basename = basename + ".xml"
|
|
247
|
+
else:
|
|
248
|
+
# Reuse basename of original file
|
|
249
|
+
basename = os.path.split(self.path)[1]
|
|
250
|
+
|
|
251
|
+
# Construct path
|
|
252
|
+
path = os.path.join(folder, basename)
|
|
253
|
+
|
|
254
|
+
self.__tree.write(path)
|
|
255
|
+
|
|
256
|
+
@property
|
|
257
|
+
def path(self):
|
|
258
|
+
return self.__path_xml
|
|
259
|
+
|
|
260
|
+
def __parse_type(self, fews_type):
|
|
261
|
+
# Parse a FEWS type to an np type
|
|
262
|
+
if fews_type == "double":
|
|
263
|
+
return np.dtype("float")
|
|
264
|
+
else:
|
|
265
|
+
return np.dtype("S128")
|
|
266
|
+
|
|
267
|
+
def __parse_parameter(self, parameter):
|
|
268
|
+
for child in parameter:
|
|
269
|
+
if child.tag.endswith("boolValue"):
|
|
270
|
+
if child.text.lower() == "true":
|
|
271
|
+
return True
|
|
272
|
+
else:
|
|
273
|
+
return False
|
|
274
|
+
elif child.tag.endswith("intValue"):
|
|
275
|
+
return int(child.text)
|
|
276
|
+
elif child.tag.endswith("dblValue"):
|
|
277
|
+
return float(child.text)
|
|
278
|
+
elif child.tag.endswith("stringValue"):
|
|
279
|
+
return child.text
|
|
280
|
+
# return dict of lisstart_datetime
|
|
281
|
+
elif child.tag.endswith("table"):
|
|
282
|
+
columnId = {}
|
|
283
|
+
columnType = {}
|
|
284
|
+
for key in child.find("pi:row", ns).attrib:
|
|
285
|
+
# default Id
|
|
286
|
+
columnId[key] = key
|
|
287
|
+
columnType[key] = np.dtype("S128") # default Type
|
|
288
|
+
|
|
289
|
+
# get Id's if present
|
|
290
|
+
el_columnIds = child.find("pi:columnIds", ns)
|
|
291
|
+
if el_columnIds is not None:
|
|
292
|
+
for key, value in el_columnIds.attrib.items():
|
|
293
|
+
columnId[key] = value
|
|
294
|
+
|
|
295
|
+
# get Types if present
|
|
296
|
+
el_columnTypes = child.find("pi:columnTypes", ns)
|
|
297
|
+
if el_columnTypes is not None:
|
|
298
|
+
for key, value in el_columnTypes.attrib.items():
|
|
299
|
+
columnType[key] = self.__parse_type(value)
|
|
300
|
+
|
|
301
|
+
# get table contenstart_datetime
|
|
302
|
+
el_row = child.findall("pi:row", ns)
|
|
303
|
+
table = {
|
|
304
|
+
columnId[key]: np.empty(len(el_row), columnType[key]) for key in columnId
|
|
305
|
+
} # initialize table
|
|
306
|
+
|
|
307
|
+
i_row = 0
|
|
308
|
+
for row in el_row:
|
|
309
|
+
for key, value in row.attrib.items():
|
|
310
|
+
table[columnId[key]][i_row] = value
|
|
311
|
+
i_row += 1
|
|
312
|
+
return table
|
|
313
|
+
elif child.tag.endswith("description"):
|
|
314
|
+
pass
|
|
315
|
+
else:
|
|
316
|
+
raise Exception("Unsupported tag {}".format(child.tag))
|
|
317
|
+
|
|
318
|
+
def __iter__(self):
|
|
319
|
+
# Iterate over all parameter key, value pairs.
|
|
320
|
+
groups = self.__xml_root.findall("pi:group", ns)
|
|
321
|
+
for group in groups:
|
|
322
|
+
el = group.find("pi:locationId", ns)
|
|
323
|
+
if el is not None:
|
|
324
|
+
location_id = el.text
|
|
325
|
+
else:
|
|
326
|
+
location_id = None
|
|
327
|
+
|
|
328
|
+
el = group.find("pi:model", ns)
|
|
329
|
+
if el is not None:
|
|
330
|
+
model_id = el.text
|
|
331
|
+
else:
|
|
332
|
+
model_id = None
|
|
333
|
+
|
|
334
|
+
parameters = group.findall("pi:parameter", ns)
|
|
335
|
+
for parameter in parameters:
|
|
336
|
+
yield (
|
|
337
|
+
location_id,
|
|
338
|
+
model_id,
|
|
339
|
+
parameter.attrib["id"],
|
|
340
|
+
self.__parse_parameter(parameter),
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
class Timeseries:
|
|
345
|
+
"""
|
|
346
|
+
PI timeseries wrapper.
|
|
347
|
+
"""
|
|
348
|
+
|
|
349
|
+
def __init__(
|
|
350
|
+
self,
|
|
351
|
+
data_config,
|
|
352
|
+
folder,
|
|
353
|
+
basename,
|
|
354
|
+
binary=True,
|
|
355
|
+
pi_validate_times=False,
|
|
356
|
+
make_new_file=False,
|
|
357
|
+
):
|
|
358
|
+
"""
|
|
359
|
+
Load the timeseries from disk.
|
|
360
|
+
|
|
361
|
+
:param data_config: A :class:`DataConfig` object.
|
|
362
|
+
:param folder: The folder in which the time series is located.
|
|
363
|
+
:param basename: The basename of the time series file.
|
|
364
|
+
:param binary: True if the time series data is stored in a separate binary file.
|
|
365
|
+
Default is ``True``.
|
|
366
|
+
:param pi_validate_times: Check consistency of times. Default is ``False``.
|
|
367
|
+
:param make_new_file: Make new XML object which can be filled and written to a new file.
|
|
368
|
+
Default is ``False``.
|
|
369
|
+
"""
|
|
370
|
+
self.__data_config = data_config
|
|
371
|
+
|
|
372
|
+
self.__folder = folder
|
|
373
|
+
self.__basename = basename
|
|
374
|
+
|
|
375
|
+
self.__internal_dtype = np.float64
|
|
376
|
+
self.__pi_dtype = np.float32
|
|
377
|
+
|
|
378
|
+
self.make_new_file = make_new_file
|
|
379
|
+
if self.make_new_file:
|
|
380
|
+
self.__reset_xml_tree()
|
|
381
|
+
else:
|
|
382
|
+
self.__tree = DefusedElementTree.parse(self.path)
|
|
383
|
+
self.__xml_root = self.__tree.getroot()
|
|
384
|
+
|
|
385
|
+
self.__values = [{}]
|
|
386
|
+
self.__units = [{}]
|
|
387
|
+
|
|
388
|
+
self.__binary = binary
|
|
389
|
+
|
|
390
|
+
if not self.make_new_file:
|
|
391
|
+
f = None
|
|
392
|
+
if self.__binary:
|
|
393
|
+
try:
|
|
394
|
+
f = io.open(self.binary_path, "rb")
|
|
395
|
+
except IOError:
|
|
396
|
+
# Support placeholder XML files.
|
|
397
|
+
pass
|
|
398
|
+
|
|
399
|
+
# Read timezone
|
|
400
|
+
timezone = self.__xml_root.find("pi:timeZone", ns)
|
|
401
|
+
if timezone is not None:
|
|
402
|
+
self.__timezone = float(timezone.text)
|
|
403
|
+
else:
|
|
404
|
+
self.__timezone = None
|
|
405
|
+
|
|
406
|
+
# Check data consistency
|
|
407
|
+
self.__dt = None
|
|
408
|
+
self.__start_datetime = None
|
|
409
|
+
self.__end_datetime = None
|
|
410
|
+
self.__forecast_datetime = None
|
|
411
|
+
self.__forecast_index = None
|
|
412
|
+
self.__contains_ensemble = False
|
|
413
|
+
self.__ensemble_size = 1
|
|
414
|
+
ensemble_indexes = set()
|
|
415
|
+
for series in self.__xml_root.findall("pi:series", ns):
|
|
416
|
+
header = series.find("pi:header", ns)
|
|
417
|
+
|
|
418
|
+
variable = self.__data_config.variable(header)
|
|
419
|
+
|
|
420
|
+
try:
|
|
421
|
+
dt = self.__parse_time_step(header.find("pi:timeStep", ns))
|
|
422
|
+
except ValueError:
|
|
423
|
+
raise Exception(
|
|
424
|
+
"PI: Multiplier of time step of variable {} "
|
|
425
|
+
"must be a positive integer per the PI schema.".format(variable)
|
|
426
|
+
)
|
|
427
|
+
if self.__dt is None:
|
|
428
|
+
self.__dt = dt
|
|
429
|
+
else:
|
|
430
|
+
if dt != self.__dt:
|
|
431
|
+
raise Exception("PI: Not all timeseries have the same time step size.")
|
|
432
|
+
try:
|
|
433
|
+
start_datetime = self.__parse_date_time(header.find("pi:startDate", ns))
|
|
434
|
+
if self.__start_datetime is None:
|
|
435
|
+
self.__start_datetime = start_datetime
|
|
436
|
+
else:
|
|
437
|
+
if start_datetime < self.__start_datetime:
|
|
438
|
+
self.__start_datetime = start_datetime
|
|
439
|
+
except (AttributeError, ValueError):
|
|
440
|
+
raise Exception(
|
|
441
|
+
"PI: Variable {} in {} has no startDate.".format(
|
|
442
|
+
variable, os.path.join(self.__folder, basename + ".xml")
|
|
443
|
+
)
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
try:
|
|
447
|
+
end_datetime = self.__parse_date_time(header.find("pi:endDate", ns))
|
|
448
|
+
if self.__end_datetime is None:
|
|
449
|
+
self.__end_datetime = end_datetime
|
|
450
|
+
else:
|
|
451
|
+
if end_datetime > self.__end_datetime:
|
|
452
|
+
self.__end_datetime = end_datetime
|
|
453
|
+
except (AttributeError, ValueError):
|
|
454
|
+
raise Exception(
|
|
455
|
+
"PI: Variable {} in {} has no endDate.".format(
|
|
456
|
+
variable, os.path.join(self.__folder, basename + ".xml")
|
|
457
|
+
)
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
el = header.find("pi:forecastDate", ns)
|
|
461
|
+
if el is not None:
|
|
462
|
+
forecast_datetime = self.__parse_date_time(el)
|
|
463
|
+
else:
|
|
464
|
+
# the timeseries has no forecastDate, so the forecastDate
|
|
465
|
+
# is set to the startDate (per the PI-schema)
|
|
466
|
+
forecast_datetime = start_datetime
|
|
467
|
+
if self.__forecast_datetime is None:
|
|
468
|
+
self.__forecast_datetime = forecast_datetime
|
|
469
|
+
else:
|
|
470
|
+
if el is not None and forecast_datetime != self.__forecast_datetime:
|
|
471
|
+
raise Exception("PI: Not all timeseries share the same forecastDate.")
|
|
472
|
+
|
|
473
|
+
el = header.find("pi:ensembleMemberIndex", ns)
|
|
474
|
+
if el is not None:
|
|
475
|
+
ensemble_indexes.add(int(el.text))
|
|
476
|
+
|
|
477
|
+
# We assume the ensemble ids are zero-based and increasing by 1
|
|
478
|
+
if len(ensemble_indexes) > 1:
|
|
479
|
+
# check if ids are zero-based and increasing by 1
|
|
480
|
+
sorted_ensemble_indexes = sorted(ensemble_indexes)
|
|
481
|
+
if sorted_ensemble_indexes != list(range(len(sorted_ensemble_indexes))):
|
|
482
|
+
raise ValueError(
|
|
483
|
+
"PI: Ensemble ids must be zero-based and increasing by 1 when more than one"
|
|
484
|
+
" ensemble member is present."
|
|
485
|
+
)
|
|
486
|
+
self.__contains_ensemble = True
|
|
487
|
+
self.__ensemble_size = len(ensemble_indexes)
|
|
488
|
+
else:
|
|
489
|
+
# There are no ensemble members, or a single ensemble member of arbitrary id
|
|
490
|
+
self.__contains_ensemble = False
|
|
491
|
+
self.__ensemble_size = 1
|
|
492
|
+
|
|
493
|
+
# Define the times, and floor the global forecast_datetime to the
|
|
494
|
+
# global time step to get its index
|
|
495
|
+
if self.__dt:
|
|
496
|
+
t_len = int(
|
|
497
|
+
round(
|
|
498
|
+
(self.__end_datetime - self.__start_datetime).total_seconds()
|
|
499
|
+
/ self.__dt.total_seconds()
|
|
500
|
+
+ 1
|
|
501
|
+
)
|
|
502
|
+
)
|
|
503
|
+
self.__times = [self.__start_datetime + i * self.__dt for i in range(t_len)]
|
|
504
|
+
else: # Timeseries are non-equidistant
|
|
505
|
+
self.__times = []
|
|
506
|
+
for series in self.__xml_root.findall("pi:series", ns):
|
|
507
|
+
events = series.findall("pi:event", ns)
|
|
508
|
+
# We assume that timeseries can differ in length, but always are
|
|
509
|
+
# a complete 'slice' of datetimes between start and end. The
|
|
510
|
+
# longest timeseries then contains all datetimes between start and end.
|
|
511
|
+
if len(events) > len(self.__times):
|
|
512
|
+
self.__times = [self.__parse_date_time(e) for e in events]
|
|
513
|
+
|
|
514
|
+
# Check if the time steps of all series match the time steps of the global
|
|
515
|
+
# time range.
|
|
516
|
+
if pi_validate_times:
|
|
517
|
+
ref_times_set = set(self.__times)
|
|
518
|
+
for series in self.__xml_root.findall("pi:series", ns):
|
|
519
|
+
events = series.findall("pi:event", ns)
|
|
520
|
+
times = {self.__parse_date_time(e) for e in events}
|
|
521
|
+
if not ref_times_set.issuperset(times):
|
|
522
|
+
raise ValueError(
|
|
523
|
+
"PI: Not all timeseries share the same time step spacing. Make sure "
|
|
524
|
+
"the time steps of all series are a subset of the global time steps."
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
if self.__forecast_datetime is not None:
|
|
528
|
+
if self.__dt:
|
|
529
|
+
self.__forecast_datetime = self.__floor_date_time(
|
|
530
|
+
dt=self.__forecast_datetime, tdel=self.__dt
|
|
531
|
+
)
|
|
532
|
+
try:
|
|
533
|
+
self.__forecast_index = self.__times.index(self.__forecast_datetime)
|
|
534
|
+
except ValueError:
|
|
535
|
+
# This may occur if forecast_datetime is outside of
|
|
536
|
+
# the timeseries' range. Can be a valid case for historical
|
|
537
|
+
# timeseries, for instance.
|
|
538
|
+
self.__forecast_index = -1
|
|
539
|
+
|
|
540
|
+
# Parse data
|
|
541
|
+
for series in self.__xml_root.findall("pi:series", ns):
|
|
542
|
+
header = series.find("pi:header", ns)
|
|
543
|
+
|
|
544
|
+
variable = self.__data_config.variable(header)
|
|
545
|
+
|
|
546
|
+
dt = self.__parse_time_step(header.find("pi:timeStep", ns))
|
|
547
|
+
start_datetime = self.__parse_date_time(header.find("pi:startDate", ns))
|
|
548
|
+
end_datetime = self.__parse_date_time(header.find("pi:endDate", ns))
|
|
549
|
+
|
|
550
|
+
make_virtual_ensemble = False
|
|
551
|
+
if self.__contains_ensemble:
|
|
552
|
+
el = header.find("pi:ensembleMemberIndex", ns)
|
|
553
|
+
if el is not None:
|
|
554
|
+
ensemble_member = int(el.text)
|
|
555
|
+
while ensemble_member >= len(self.__values):
|
|
556
|
+
self.__values.append({})
|
|
557
|
+
while ensemble_member >= len(self.__units):
|
|
558
|
+
self.__units.append({})
|
|
559
|
+
else:
|
|
560
|
+
ensemble_member = 0
|
|
561
|
+
if el is None:
|
|
562
|
+
# Expand values dict to accommodate referencing of (virtual)
|
|
563
|
+
# ensemble series to the input values. This is e.g. needed
|
|
564
|
+
# for initial states that have a single historical values.
|
|
565
|
+
while self.ensemble_size > len(self.__values):
|
|
566
|
+
self.__values.append({})
|
|
567
|
+
while self.ensemble_size > len(self.__units):
|
|
568
|
+
self.__units.append({})
|
|
569
|
+
make_virtual_ensemble = True
|
|
570
|
+
else:
|
|
571
|
+
ensemble_member = 0
|
|
572
|
+
|
|
573
|
+
if self.__dt:
|
|
574
|
+
n_values = int(
|
|
575
|
+
round(
|
|
576
|
+
(end_datetime - start_datetime).total_seconds() / dt.total_seconds() + 1
|
|
577
|
+
)
|
|
578
|
+
)
|
|
579
|
+
else:
|
|
580
|
+
n_values = (
|
|
581
|
+
bisect.bisect_left(self.__times, end_datetime)
|
|
582
|
+
- bisect.bisect_left(self.__times, start_datetime)
|
|
583
|
+
+ 1
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
if self.__binary:
|
|
587
|
+
if f is not None:
|
|
588
|
+
self.__values[ensemble_member][variable] = np.fromfile(
|
|
589
|
+
f, count=n_values, dtype=self.__pi_dtype
|
|
590
|
+
)
|
|
591
|
+
else:
|
|
592
|
+
self.__values[ensemble_member][variable] = np.empty(
|
|
593
|
+
n_values, dtype=self.__internal_dtype
|
|
594
|
+
)
|
|
595
|
+
self.__values[ensemble_member][variable].fill(np.nan)
|
|
596
|
+
|
|
597
|
+
else:
|
|
598
|
+
events = series.findall("pi:event", ns)
|
|
599
|
+
|
|
600
|
+
self.__values[ensemble_member][variable] = np.empty(
|
|
601
|
+
n_values, dtype=self.__internal_dtype
|
|
602
|
+
)
|
|
603
|
+
self.__values[ensemble_member][variable].fill(np.nan)
|
|
604
|
+
# This assumes that start_datetime equals the datetime of the
|
|
605
|
+
# first value (which should be the case).
|
|
606
|
+
for i in range(min(n_values, len(events))):
|
|
607
|
+
self.__values[ensemble_member][variable][i] = float(events[i].get("value"))
|
|
608
|
+
|
|
609
|
+
miss_val = float(header.find("pi:missVal", ns).text)
|
|
610
|
+
self.__values[ensemble_member][variable][
|
|
611
|
+
self.__values[ensemble_member][variable] == miss_val
|
|
612
|
+
] = np.nan
|
|
613
|
+
|
|
614
|
+
unit = header.find("pi:units", ns).text
|
|
615
|
+
self.set_unit(variable, unit=unit, ensemble_member=ensemble_member)
|
|
616
|
+
|
|
617
|
+
# Prepend empty space, if start_datetime > self.__start_datetime.
|
|
618
|
+
if start_datetime > self.__start_datetime:
|
|
619
|
+
if self.__dt:
|
|
620
|
+
filler = np.empty(
|
|
621
|
+
int(
|
|
622
|
+
round(
|
|
623
|
+
(start_datetime - self.__start_datetime).total_seconds()
|
|
624
|
+
/ dt.total_seconds()
|
|
625
|
+
)
|
|
626
|
+
),
|
|
627
|
+
dtype=self.__internal_dtype,
|
|
628
|
+
)
|
|
629
|
+
else:
|
|
630
|
+
filler = np.empty(
|
|
631
|
+
int(
|
|
632
|
+
round(
|
|
633
|
+
bisect.bisect_left(self.__times, start_datetime)
|
|
634
|
+
- bisect.bisect_left(self.__times, self.__start_datetime)
|
|
635
|
+
)
|
|
636
|
+
),
|
|
637
|
+
dtype=self.__internal_dtype,
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
filler.fill(np.nan)
|
|
641
|
+
self.__values[ensemble_member][variable] = np.hstack(
|
|
642
|
+
(filler, self.__values[ensemble_member][variable])
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
# Append empty space, if end_datetime < self.__end_datetime
|
|
646
|
+
if end_datetime < self.__end_datetime:
|
|
647
|
+
if self.__dt:
|
|
648
|
+
filler = np.empty(
|
|
649
|
+
int(
|
|
650
|
+
round(
|
|
651
|
+
(self.__end_datetime - end_datetime).total_seconds()
|
|
652
|
+
/ dt.total_seconds()
|
|
653
|
+
)
|
|
654
|
+
),
|
|
655
|
+
dtype=self.__internal_dtype,
|
|
656
|
+
)
|
|
657
|
+
else:
|
|
658
|
+
filler = np.empty(
|
|
659
|
+
int(
|
|
660
|
+
round(
|
|
661
|
+
bisect.bisect_left(self.__times, self.__end_datetime)
|
|
662
|
+
- bisect.bisect_left(self.__times, end_datetime)
|
|
663
|
+
)
|
|
664
|
+
),
|
|
665
|
+
dtype=self.__internal_dtype,
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
filler.fill(np.nan)
|
|
669
|
+
self.__values[ensemble_member][variable] = np.hstack(
|
|
670
|
+
(self.__values[ensemble_member][variable], filler)
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
if make_virtual_ensemble:
|
|
674
|
+
# Make references to the original input series for the virtual
|
|
675
|
+
# ensemble members.
|
|
676
|
+
for i in range(1, self.ensemble_size):
|
|
677
|
+
self.__values[i][variable] = self.__values[0][variable]
|
|
678
|
+
self.set_unit(variable, unit=unit, ensemble_member=i)
|
|
679
|
+
|
|
680
|
+
if not self.__dt:
|
|
681
|
+
# Remove time values outside the start/end datetimes.
|
|
682
|
+
# Only needed for non-equidistant, because we can't build the
|
|
683
|
+
# times automatically from global start/end datetime.
|
|
684
|
+
self.__times = self.__times[
|
|
685
|
+
bisect.bisect_left(self.__times, self.__start_datetime) : bisect.bisect_left(
|
|
686
|
+
self.__times, self.__end_datetime
|
|
687
|
+
)
|
|
688
|
+
+ 1
|
|
689
|
+
]
|
|
690
|
+
|
|
691
|
+
if f is not None and self.__binary:
|
|
692
|
+
f.close()
|
|
693
|
+
|
|
694
|
+
def __reset_xml_tree(self):
|
|
695
|
+
# Make a new empty XML tree
|
|
696
|
+
self.__xml_root = ET.Element("{%s}" % (ns["pi"],) + "TimeSeries")
|
|
697
|
+
self.__tree = ET.ElementTree(self.__xml_root)
|
|
698
|
+
|
|
699
|
+
self.__xml_root.set("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance")
|
|
700
|
+
self.__xml_root.set("version", "1.2")
|
|
701
|
+
self.__xml_root.set(
|
|
702
|
+
"xsi:schemaLocation",
|
|
703
|
+
"http://www.wldelft.nl/fews/PI "
|
|
704
|
+
"http://fews.wldelft.nl/schemas/version1.0/pi-schemas/pi_timeseries.xsd",
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
def __parse_date_time(self, el):
|
|
708
|
+
# Parse a PI date time element.
|
|
709
|
+
return datetime.datetime.strptime(
|
|
710
|
+
el.get("date") + " " + el.get("time"), "%Y-%m-%d %H:%M:%S"
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
def __parse_time_step(self, el):
|
|
714
|
+
# Parse a PI time step element.
|
|
715
|
+
if el.get("unit") == "second":
|
|
716
|
+
return datetime.timedelta(seconds=int(el.get("multiplier")))
|
|
717
|
+
elif el.get("unit") == "nonequidistant":
|
|
718
|
+
return None
|
|
719
|
+
else:
|
|
720
|
+
raise Exception("Unsupported unit type: " + el.get("unit"))
|
|
721
|
+
|
|
722
|
+
def __floor_date_time(self, dt, tdel):
|
|
723
|
+
"""
|
|
724
|
+
Floor a PI date time to an integer number of PI time steps from the
|
|
725
|
+
start date time.
|
|
726
|
+
"""
|
|
727
|
+
roundTo = tdel.total_seconds()
|
|
728
|
+
|
|
729
|
+
seconds = (dt - self.__start_datetime).seconds
|
|
730
|
+
# // is a floor division:
|
|
731
|
+
rounding = (seconds + roundTo / 2) // roundTo * roundTo
|
|
732
|
+
return dt + datetime.timedelta(0, rounding - seconds, -dt.microsecond)
|
|
733
|
+
|
|
734
|
+
def __add_header(
|
|
735
|
+
self, variable, location_parameter_id, ensemble_member=0, miss_val=-999, unit="unit_unknown"
|
|
736
|
+
):
|
|
737
|
+
"""
|
|
738
|
+
Add a timeseries header to the timeseries object.
|
|
739
|
+
"""
|
|
740
|
+
# Save current datetime
|
|
741
|
+
now = datetime.datetime.now()
|
|
742
|
+
|
|
743
|
+
# Define the basic structure of the header
|
|
744
|
+
header_elements = [
|
|
745
|
+
"type",
|
|
746
|
+
"locationId",
|
|
747
|
+
"parameterId",
|
|
748
|
+
"timeStep",
|
|
749
|
+
"startDate",
|
|
750
|
+
"endDate",
|
|
751
|
+
"missVal",
|
|
752
|
+
"stationName",
|
|
753
|
+
"units",
|
|
754
|
+
"creationDate",
|
|
755
|
+
"creationTime",
|
|
756
|
+
]
|
|
757
|
+
header_element_texts = [
|
|
758
|
+
"instantaneous",
|
|
759
|
+
location_parameter_id.location_id,
|
|
760
|
+
location_parameter_id.parameter_id,
|
|
761
|
+
"",
|
|
762
|
+
"",
|
|
763
|
+
"",
|
|
764
|
+
str(miss_val),
|
|
765
|
+
location_parameter_id.location_id,
|
|
766
|
+
unit,
|
|
767
|
+
now.strftime("%Y-%m-%d"),
|
|
768
|
+
now.strftime("%H:%M:%S"),
|
|
769
|
+
]
|
|
770
|
+
|
|
771
|
+
# Add ensembleMemberIndex, forecastDate and qualifierId if necessary.
|
|
772
|
+
if self.__forecast_datetime != self.__start_datetime:
|
|
773
|
+
header_elements.insert(6, "forecastDate")
|
|
774
|
+
header_element_texts.insert(6, "")
|
|
775
|
+
if self.contains_ensemble:
|
|
776
|
+
header_elements.insert(3, "ensembleMemberIndex")
|
|
777
|
+
header_element_texts.insert(3, str(ensemble_member))
|
|
778
|
+
if len(location_parameter_id.qualifier_id) > 0:
|
|
779
|
+
# Track relative index to preserve original ordering of qualifier ID's
|
|
780
|
+
i = 0
|
|
781
|
+
for qualifier_id in location_parameter_id.qualifier_id:
|
|
782
|
+
header_elements.insert(3, "qualifierId")
|
|
783
|
+
header_element_texts.insert(3 + i, qualifier_id)
|
|
784
|
+
i += 1
|
|
785
|
+
|
|
786
|
+
# Fill the basics of the series
|
|
787
|
+
series = ET.Element("{%s}" % (ns["pi"],) + "series")
|
|
788
|
+
header = ET.SubElement(series, "{%s}" % (ns["pi"],) + "header")
|
|
789
|
+
for i in range(len(header_elements)):
|
|
790
|
+
el = ET.SubElement(header, "{%s}" % (ns["pi"],) + header_elements[i])
|
|
791
|
+
el.text = header_element_texts[i]
|
|
792
|
+
|
|
793
|
+
el = header.find("pi:timeStep", ns)
|
|
794
|
+
# Set time step
|
|
795
|
+
if self.dt:
|
|
796
|
+
el.set("unit", "second")
|
|
797
|
+
el.set("multiplier", str(int(self.dt.total_seconds())))
|
|
798
|
+
else:
|
|
799
|
+
el.set("unit", "nonequidistant")
|
|
800
|
+
|
|
801
|
+
# Set the time range.
|
|
802
|
+
el = header.find("pi:startDate", ns)
|
|
803
|
+
el.set("date", self.__start_datetime.strftime("%Y-%m-%d"))
|
|
804
|
+
el.set("time", self.__start_datetime.strftime("%H:%M:%S"))
|
|
805
|
+
el = header.find("pi:endDate", ns)
|
|
806
|
+
el.set("date", self.__end_datetime.strftime("%Y-%m-%d"))
|
|
807
|
+
el.set("time", self.__end_datetime.strftime("%H:%M:%S"))
|
|
808
|
+
|
|
809
|
+
# Set the forecast date if applicable
|
|
810
|
+
if self.__forecast_datetime != self.__start_datetime:
|
|
811
|
+
el = header.find("pi:forecastDate", ns)
|
|
812
|
+
el.set("date", self.__forecast_datetime.strftime("%Y-%m-%d"))
|
|
813
|
+
el.set("time", self.__forecast_datetime.strftime("%H:%M:%S"))
|
|
814
|
+
|
|
815
|
+
# Add series to xml
|
|
816
|
+
self.__xml_root.append(series)
|
|
817
|
+
|
|
818
|
+
def write(self, output_folder=None, output_filename=None) -> None:
|
|
819
|
+
"""
|
|
820
|
+
Writes the time series data to disk.
|
|
821
|
+
|
|
822
|
+
:param output_folder: The folder in which the output file is located.
|
|
823
|
+
If None, the original folder is used.
|
|
824
|
+
:param output_filename: The name of the output file without extension.
|
|
825
|
+
If None, the original filename is used.
|
|
826
|
+
"""
|
|
827
|
+
xml_path = self.output_path(output_folder, output_filename)
|
|
828
|
+
binary_path = self.output_binary_path(output_folder, output_filename)
|
|
829
|
+
|
|
830
|
+
if self.__binary:
|
|
831
|
+
f = io.open(binary_path, "wb")
|
|
832
|
+
|
|
833
|
+
if self.make_new_file:
|
|
834
|
+
# Force reinitialization in case write() is called more than once
|
|
835
|
+
self.__reset_xml_tree()
|
|
836
|
+
|
|
837
|
+
for ensemble_member in range(len(self.__values)):
|
|
838
|
+
for variable in sorted(self.__values[ensemble_member].keys()):
|
|
839
|
+
location_parameter_id = self.__data_config.pi_variable_ids(variable)
|
|
840
|
+
unit = self.get_unit(variable, ensemble_member)
|
|
841
|
+
self.__add_header(
|
|
842
|
+
variable,
|
|
843
|
+
location_parameter_id,
|
|
844
|
+
ensemble_member=ensemble_member,
|
|
845
|
+
miss_val=-999,
|
|
846
|
+
unit=unit,
|
|
847
|
+
)
|
|
848
|
+
|
|
849
|
+
for ensemble_member in range(len(self.__values)):
|
|
850
|
+
if self.timezone is not None:
|
|
851
|
+
timezone = self.__xml_root.find("pi:timeZone", ns)
|
|
852
|
+
if timezone is None:
|
|
853
|
+
timezone = ET.Element("{%s}" % (ns["pi"],) + "timeZone")
|
|
854
|
+
# timeZone has to be the first element according to the schema
|
|
855
|
+
self.__xml_root.insert(0, timezone)
|
|
856
|
+
timezone.text = str(self.timezone)
|
|
857
|
+
|
|
858
|
+
for series in self.__xml_root.findall("pi:series", ns):
|
|
859
|
+
header = series.find("pi:header", ns)
|
|
860
|
+
|
|
861
|
+
# First check ensembleMemberIndex, to see if it is the correct one.
|
|
862
|
+
el = header.find("pi:ensembleMemberIndex", ns)
|
|
863
|
+
if el is not None:
|
|
864
|
+
if ensemble_member != int(el.text):
|
|
865
|
+
# Skip over this series, wrong index.
|
|
866
|
+
continue
|
|
867
|
+
|
|
868
|
+
# Update the time range, which may have changed.
|
|
869
|
+
el = header.find("pi:startDate", ns)
|
|
870
|
+
el.set("date", self.__start_datetime.strftime("%Y-%m-%d"))
|
|
871
|
+
el.set("time", self.__start_datetime.strftime("%H:%M:%S"))
|
|
872
|
+
|
|
873
|
+
el = header.find("pi:endDate", ns)
|
|
874
|
+
el.set("date", self.__end_datetime.strftime("%Y-%m-%d"))
|
|
875
|
+
el.set("time", self.__end_datetime.strftime("%H:%M:%S"))
|
|
876
|
+
|
|
877
|
+
variable = self.__data_config.variable(header)
|
|
878
|
+
|
|
879
|
+
miss_val = header.find("pi:missVal", ns).text
|
|
880
|
+
values = self.__values[ensemble_member][variable]
|
|
881
|
+
|
|
882
|
+
# Update the header, which may have changed
|
|
883
|
+
el = header.find("pi:units", ns)
|
|
884
|
+
el.text = self.get_unit(variable, ensemble_member)
|
|
885
|
+
|
|
886
|
+
# No values to be written, so the entire element is removed from
|
|
887
|
+
# the XML, and the loop restarts.
|
|
888
|
+
if len(values) == 0:
|
|
889
|
+
self.__xml_root.remove(series)
|
|
890
|
+
continue
|
|
891
|
+
|
|
892
|
+
# Write output
|
|
893
|
+
nans = np.isnan(values)
|
|
894
|
+
if self.__binary:
|
|
895
|
+
f.write(values.astype(self.__pi_dtype).tobytes())
|
|
896
|
+
else:
|
|
897
|
+
events = series.findall("pi:event", ns)
|
|
898
|
+
|
|
899
|
+
t = self.__start_datetime
|
|
900
|
+
for i, value in enumerate(values):
|
|
901
|
+
if self.dt is None:
|
|
902
|
+
t = self.times[i]
|
|
903
|
+
|
|
904
|
+
if i < len(events):
|
|
905
|
+
event = events[i]
|
|
906
|
+
else:
|
|
907
|
+
event = ET.Element("pi:event")
|
|
908
|
+
series.append(event)
|
|
909
|
+
|
|
910
|
+
# Always set the date/time, so that any date/time steps
|
|
911
|
+
# that are wrong in the placeholder file are corrected.
|
|
912
|
+
event.set("date", t.strftime("%Y-%m-%d"))
|
|
913
|
+
event.set("time", t.strftime("%H:%M:%S"))
|
|
914
|
+
|
|
915
|
+
if nans[i]:
|
|
916
|
+
event.set("value", miss_val)
|
|
917
|
+
else:
|
|
918
|
+
event.set("value", str(value))
|
|
919
|
+
|
|
920
|
+
if self.dt:
|
|
921
|
+
t += self.dt
|
|
922
|
+
|
|
923
|
+
# Remove superfluous elements
|
|
924
|
+
if len(events) > len(values):
|
|
925
|
+
for i in range(len(values), len(events)):
|
|
926
|
+
series.remove(events[i])
|
|
927
|
+
|
|
928
|
+
if self.__binary:
|
|
929
|
+
f.close()
|
|
930
|
+
|
|
931
|
+
self.format_xml_data()
|
|
932
|
+
self.__tree.write(xml_path)
|
|
933
|
+
|
|
934
|
+
def format_xml_data(self):
|
|
935
|
+
"""
|
|
936
|
+
Format the XML data
|
|
937
|
+
|
|
938
|
+
Make sure it has proper indentation and newlines.
|
|
939
|
+
"""
|
|
940
|
+
self.format_xml_element(self.__xml_root)
|
|
941
|
+
|
|
942
|
+
@staticmethod
|
|
943
|
+
def format_xml_element(element: ET.Element, level=0):
|
|
944
|
+
"""
|
|
945
|
+
Format an XML element
|
|
946
|
+
|
|
947
|
+
Make sure it has proper indentation and newlines.
|
|
948
|
+
This code is based on
|
|
949
|
+
https://stackoverflow.com/questions/3095434/inserting-newlines-in-xml-file-generated-via-xml-etree-elementtree-in-python
|
|
950
|
+
"""
|
|
951
|
+
indent = "\n" + level * " "
|
|
952
|
+
if len(element):
|
|
953
|
+
if not element.text or not element.text.strip():
|
|
954
|
+
element.text = indent + " "
|
|
955
|
+
if not element.tail or not element.tail.strip():
|
|
956
|
+
element.tail = indent
|
|
957
|
+
for subelement in element:
|
|
958
|
+
Timeseries.format_xml_element(subelement, level + 1)
|
|
959
|
+
if not element.tail or not element.tail.strip():
|
|
960
|
+
element.tail = indent
|
|
961
|
+
else:
|
|
962
|
+
if level and (not element.tail or not element.tail.strip()):
|
|
963
|
+
element.tail = indent
|
|
964
|
+
|
|
965
|
+
@property
|
|
966
|
+
def contains_ensemble(self):
|
|
967
|
+
"""
|
|
968
|
+
Flag to indicate TimeSeries contains an ensemble.
|
|
969
|
+
"""
|
|
970
|
+
return self.__contains_ensemble
|
|
971
|
+
|
|
972
|
+
@contains_ensemble.setter
|
|
973
|
+
def contains_ensemble(self, contains_ensemble):
|
|
974
|
+
if not contains_ensemble:
|
|
975
|
+
assert self.ensemble_size == 1
|
|
976
|
+
self.__contains_ensemble = contains_ensemble
|
|
977
|
+
|
|
978
|
+
@property
|
|
979
|
+
def ensemble_size(self):
|
|
980
|
+
"""
|
|
981
|
+
Ensemble size.
|
|
982
|
+
"""
|
|
983
|
+
return self.__ensemble_size
|
|
984
|
+
|
|
985
|
+
@ensemble_size.setter
|
|
986
|
+
def ensemble_size(self, ensemble_size):
|
|
987
|
+
if ensemble_size > 1:
|
|
988
|
+
assert self.contains_ensemble
|
|
989
|
+
self.__ensemble_size = ensemble_size
|
|
990
|
+
while self.__ensemble_size > len(self.__values):
|
|
991
|
+
self.__values.append({})
|
|
992
|
+
while self.__ensemble_size > len(self.__units):
|
|
993
|
+
self.__units.append({})
|
|
994
|
+
|
|
995
|
+
@property
|
|
996
|
+
def start_datetime(self):
|
|
997
|
+
"""
|
|
998
|
+
Start time.
|
|
999
|
+
"""
|
|
1000
|
+
return self.__start_datetime
|
|
1001
|
+
|
|
1002
|
+
@property
|
|
1003
|
+
def end_datetime(self):
|
|
1004
|
+
"""
|
|
1005
|
+
End time.
|
|
1006
|
+
"""
|
|
1007
|
+
return self.__end_datetime
|
|
1008
|
+
|
|
1009
|
+
@property
|
|
1010
|
+
def forecast_datetime(self):
|
|
1011
|
+
"""
|
|
1012
|
+
Forecast time (t0).
|
|
1013
|
+
"""
|
|
1014
|
+
return self.__forecast_datetime
|
|
1015
|
+
|
|
1016
|
+
@forecast_datetime.setter
|
|
1017
|
+
def forecast_datetime(self, forecast_datetime):
|
|
1018
|
+
self.__forecast_datetime = forecast_datetime
|
|
1019
|
+
self.__forecast_index = self.__times.index(forecast_datetime)
|
|
1020
|
+
|
|
1021
|
+
@property
|
|
1022
|
+
def forecast_index(self):
|
|
1023
|
+
"""
|
|
1024
|
+
Forecast time (t0) index.
|
|
1025
|
+
"""
|
|
1026
|
+
return self.__forecast_index
|
|
1027
|
+
|
|
1028
|
+
@property
|
|
1029
|
+
def dt(self):
|
|
1030
|
+
"""
|
|
1031
|
+
Time step.
|
|
1032
|
+
"""
|
|
1033
|
+
return self.__dt
|
|
1034
|
+
|
|
1035
|
+
@dt.setter
|
|
1036
|
+
def dt(self, dt):
|
|
1037
|
+
self.__dt = dt
|
|
1038
|
+
|
|
1039
|
+
@property
|
|
1040
|
+
def times(self):
|
|
1041
|
+
"""
|
|
1042
|
+
Time stamps.
|
|
1043
|
+
"""
|
|
1044
|
+
return self.__times
|
|
1045
|
+
|
|
1046
|
+
@times.setter
|
|
1047
|
+
def times(self, times):
|
|
1048
|
+
self.__times = times
|
|
1049
|
+
self.__start_datetime = times[0]
|
|
1050
|
+
self.__end_datetime = times[-1]
|
|
1051
|
+
|
|
1052
|
+
@property
|
|
1053
|
+
def timezone(self):
|
|
1054
|
+
"""
|
|
1055
|
+
Time zone in decimal hours shift from GMT.
|
|
1056
|
+
"""
|
|
1057
|
+
return self.__timezone
|
|
1058
|
+
|
|
1059
|
+
@timezone.setter
|
|
1060
|
+
def timezone(self, timezone):
|
|
1061
|
+
self.__timezone = timezone
|
|
1062
|
+
|
|
1063
|
+
def get(self, variable, ensemble_member=0):
|
|
1064
|
+
"""
|
|
1065
|
+
Look up a time series.
|
|
1066
|
+
|
|
1067
|
+
:param variable: Time series ID.
|
|
1068
|
+
:param ensemble_member: Ensemble member index.
|
|
1069
|
+
|
|
1070
|
+
:returns: A :class:`Timeseries` object.
|
|
1071
|
+
"""
|
|
1072
|
+
return self.__values[ensemble_member][variable]
|
|
1073
|
+
|
|
1074
|
+
def set(self, variable, new_values, unit=None, ensemble_member=0):
|
|
1075
|
+
"""
|
|
1076
|
+
Fill a time series with new values, and set the unit.
|
|
1077
|
+
|
|
1078
|
+
:param variable: Time series ID.
|
|
1079
|
+
:param new_values: List of new values.
|
|
1080
|
+
:param unit: Unit.
|
|
1081
|
+
:param ensemble_member: Ensemble member index.
|
|
1082
|
+
"""
|
|
1083
|
+
self.__values[ensemble_member][variable] = new_values
|
|
1084
|
+
if unit is None:
|
|
1085
|
+
unit = self.get_unit(variable, ensemble_member)
|
|
1086
|
+
self.set_unit(variable, unit, ensemble_member)
|
|
1087
|
+
|
|
1088
|
+
def get_unit(self, variable, ensemble_member=0):
|
|
1089
|
+
"""
|
|
1090
|
+
Look up the unit of a time series.
|
|
1091
|
+
|
|
1092
|
+
:param variable: Time series ID.
|
|
1093
|
+
:param ensemble_member: Ensemble member index.
|
|
1094
|
+
|
|
1095
|
+
:returns: A :string: containing the unit. If not set for the variable,
|
|
1096
|
+
returns 'unit_unknown'.
|
|
1097
|
+
"""
|
|
1098
|
+
try:
|
|
1099
|
+
return self.__units[ensemble_member][variable]
|
|
1100
|
+
except KeyError:
|
|
1101
|
+
return "unit_unknown"
|
|
1102
|
+
|
|
1103
|
+
def set_unit(self, variable, unit, ensemble_member=0):
|
|
1104
|
+
"""
|
|
1105
|
+
Set the unit of a time series.
|
|
1106
|
+
|
|
1107
|
+
:param variable: Time series ID.
|
|
1108
|
+
:param unit: Unit.
|
|
1109
|
+
:param ensemble_member: Ensemble member index.
|
|
1110
|
+
"""
|
|
1111
|
+
self.__units[ensemble_member][variable] = unit
|
|
1112
|
+
|
|
1113
|
+
def resize(self, start_datetime, end_datetime):
|
|
1114
|
+
"""
|
|
1115
|
+
Resize the timeseries to stretch from start_datetime to end_datetime.
|
|
1116
|
+
|
|
1117
|
+
:param start_datetime: Start date and time.
|
|
1118
|
+
:param end_datetime: End date and time.
|
|
1119
|
+
"""
|
|
1120
|
+
|
|
1121
|
+
if self.__dt:
|
|
1122
|
+
n_delta_s = int(
|
|
1123
|
+
round(
|
|
1124
|
+
(start_datetime - self.__start_datetime).total_seconds()
|
|
1125
|
+
/ self.__dt.total_seconds()
|
|
1126
|
+
)
|
|
1127
|
+
)
|
|
1128
|
+
else:
|
|
1129
|
+
if start_datetime >= self.__start_datetime:
|
|
1130
|
+
n_delta_s = bisect.bisect_left(self.__times, start_datetime) - bisect.bisect_left(
|
|
1131
|
+
self.__times, self.__start_datetime
|
|
1132
|
+
)
|
|
1133
|
+
else:
|
|
1134
|
+
raise ValueError(
|
|
1135
|
+
"PI: Resizing a non-equidistant timeseries to stretch "
|
|
1136
|
+
"outside of the global range of times is not allowed."
|
|
1137
|
+
)
|
|
1138
|
+
|
|
1139
|
+
for ensemble_member in range(len(self.__values)):
|
|
1140
|
+
if n_delta_s > 0:
|
|
1141
|
+
# New start datetime lies after old start datetime (timeseries will be shortened).
|
|
1142
|
+
for key in self.__values[ensemble_member].keys():
|
|
1143
|
+
self.__values[ensemble_member][key] = self.__values[ensemble_member][key][
|
|
1144
|
+
n_delta_s:
|
|
1145
|
+
]
|
|
1146
|
+
elif n_delta_s < 0:
|
|
1147
|
+
# New start datetime lies before old start datetime (timeseries will be lengthened).
|
|
1148
|
+
filler = np.empty(abs(n_delta_s))
|
|
1149
|
+
filler.fill(np.nan)
|
|
1150
|
+
for key in self.__values[ensemble_member].keys():
|
|
1151
|
+
self.__values[ensemble_member][key] = np.hstack(
|
|
1152
|
+
(filler, self.__values[ensemble_member][key])
|
|
1153
|
+
)
|
|
1154
|
+
self.__start_datetime = start_datetime
|
|
1155
|
+
|
|
1156
|
+
if self.__dt:
|
|
1157
|
+
n_delta_e = int(
|
|
1158
|
+
round(
|
|
1159
|
+
(end_datetime - self.__end_datetime).total_seconds() / self.__dt.total_seconds()
|
|
1160
|
+
)
|
|
1161
|
+
)
|
|
1162
|
+
else:
|
|
1163
|
+
if end_datetime <= self.__end_datetime:
|
|
1164
|
+
n_delta_e = bisect.bisect_left(self.__times, end_datetime) - bisect.bisect_left(
|
|
1165
|
+
self.__times, self.__end_datetime
|
|
1166
|
+
)
|
|
1167
|
+
else:
|
|
1168
|
+
raise ValueError(
|
|
1169
|
+
"PI: Resizing a non-equidistant timeseries to stretch "
|
|
1170
|
+
"outside of the global range of times is not allowed."
|
|
1171
|
+
)
|
|
1172
|
+
|
|
1173
|
+
for ensemble_member in range(len(self.__values)):
|
|
1174
|
+
if n_delta_e > 0:
|
|
1175
|
+
# New end datetime lies after old end datetime (timeseries will be lengthened).
|
|
1176
|
+
filler = np.empty(n_delta_e)
|
|
1177
|
+
filler.fill(np.nan)
|
|
1178
|
+
for key in self.__values[ensemble_member].keys():
|
|
1179
|
+
self.__values[ensemble_member][key] = np.hstack(
|
|
1180
|
+
(self.__values[ensemble_member][key], filler)
|
|
1181
|
+
)
|
|
1182
|
+
elif n_delta_e < 0:
|
|
1183
|
+
# New end datetime lies before old end datetime (timeseries will be shortened).
|
|
1184
|
+
for key in self.__values[ensemble_member].keys():
|
|
1185
|
+
self.__values[ensemble_member][key] = self.__values[ensemble_member][key][
|
|
1186
|
+
:n_delta_e
|
|
1187
|
+
]
|
|
1188
|
+
self.__end_datetime = end_datetime
|
|
1189
|
+
|
|
1190
|
+
@property
|
|
1191
|
+
def path(self) -> str:
|
|
1192
|
+
"""
|
|
1193
|
+
The path to the original xml file.
|
|
1194
|
+
"""
|
|
1195
|
+
return os.path.join(self.__folder, self.__basename + ".xml")
|
|
1196
|
+
|
|
1197
|
+
@property
|
|
1198
|
+
def binary_path(self) -> str:
|
|
1199
|
+
"""
|
|
1200
|
+
The path to the original binary data .bin file.
|
|
1201
|
+
"""
|
|
1202
|
+
return os.path.join(self.__folder, self.__basename + ".bin")
|
|
1203
|
+
|
|
1204
|
+
def _output_path_without_extension(self, output_folder=None, output_filename=None) -> str:
|
|
1205
|
+
"""
|
|
1206
|
+
Get the output path without file extension.
|
|
1207
|
+
"""
|
|
1208
|
+
if output_folder is None:
|
|
1209
|
+
output_folder = self.__folder
|
|
1210
|
+
if output_filename is None:
|
|
1211
|
+
output_filename = self.__basename
|
|
1212
|
+
return os.path.join(output_folder, output_filename)
|
|
1213
|
+
|
|
1214
|
+
def output_path(self, output_folder=None, output_filename=None) -> str:
|
|
1215
|
+
"""
|
|
1216
|
+
Get the path to the output xml file.
|
|
1217
|
+
|
|
1218
|
+
The optional arguments are the same as in :py:method:`write`.
|
|
1219
|
+
"""
|
|
1220
|
+
return self._output_path_without_extension(output_folder, output_filename) + ".xml"
|
|
1221
|
+
|
|
1222
|
+
def output_binary_path(self, output_folder=None, output_filename=None) -> str:
|
|
1223
|
+
"""
|
|
1224
|
+
Get the path to the output binary file.
|
|
1225
|
+
|
|
1226
|
+
The optional arguments are the same as in :py:method:`write`.
|
|
1227
|
+
"""
|
|
1228
|
+
return self._output_path_without_extension(output_folder, output_filename) + ".bin"
|
|
1229
|
+
|
|
1230
|
+
def items(self, ensemble_member=0):
|
|
1231
|
+
"""
|
|
1232
|
+
Returns an iterator over all timeseries IDs and value arrays for the given
|
|
1233
|
+
ensemble member.
|
|
1234
|
+
"""
|
|
1235
|
+
for key in self.__values[ensemble_member].keys():
|
|
1236
|
+
yield key, self.get(key, ensemble_member)
|