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.
Files changed (50) hide show
  1. rtc_tools-2.7.3.dist-info/METADATA +53 -0
  2. rtc_tools-2.7.3.dist-info/RECORD +50 -0
  3. rtc_tools-2.7.3.dist-info/WHEEL +5 -0
  4. rtc_tools-2.7.3.dist-info/entry_points.txt +3 -0
  5. rtc_tools-2.7.3.dist-info/licenses/COPYING.LESSER +165 -0
  6. rtc_tools-2.7.3.dist-info/top_level.txt +1 -0
  7. rtctools/__init__.py +5 -0
  8. rtctools/_internal/__init__.py +0 -0
  9. rtctools/_internal/alias_tools.py +188 -0
  10. rtctools/_internal/caching.py +25 -0
  11. rtctools/_internal/casadi_helpers.py +99 -0
  12. rtctools/_internal/debug_check_helpers.py +41 -0
  13. rtctools/_version.py +21 -0
  14. rtctools/data/__init__.py +4 -0
  15. rtctools/data/csv.py +150 -0
  16. rtctools/data/interpolation/__init__.py +3 -0
  17. rtctools/data/interpolation/bspline.py +31 -0
  18. rtctools/data/interpolation/bspline1d.py +169 -0
  19. rtctools/data/interpolation/bspline2d.py +54 -0
  20. rtctools/data/netcdf.py +467 -0
  21. rtctools/data/pi.py +1236 -0
  22. rtctools/data/rtc.py +228 -0
  23. rtctools/data/storage.py +343 -0
  24. rtctools/optimization/__init__.py +0 -0
  25. rtctools/optimization/collocated_integrated_optimization_problem.py +3208 -0
  26. rtctools/optimization/control_tree_mixin.py +221 -0
  27. rtctools/optimization/csv_lookup_table_mixin.py +462 -0
  28. rtctools/optimization/csv_mixin.py +300 -0
  29. rtctools/optimization/goal_programming_mixin.py +769 -0
  30. rtctools/optimization/goal_programming_mixin_base.py +1094 -0
  31. rtctools/optimization/homotopy_mixin.py +165 -0
  32. rtctools/optimization/initial_state_estimation_mixin.py +89 -0
  33. rtctools/optimization/io_mixin.py +320 -0
  34. rtctools/optimization/linearization_mixin.py +33 -0
  35. rtctools/optimization/linearized_order_goal_programming_mixin.py +235 -0
  36. rtctools/optimization/min_abs_goal_programming_mixin.py +385 -0
  37. rtctools/optimization/modelica_mixin.py +482 -0
  38. rtctools/optimization/netcdf_mixin.py +177 -0
  39. rtctools/optimization/optimization_problem.py +1302 -0
  40. rtctools/optimization/pi_mixin.py +292 -0
  41. rtctools/optimization/planning_mixin.py +19 -0
  42. rtctools/optimization/single_pass_goal_programming_mixin.py +676 -0
  43. rtctools/optimization/timeseries.py +56 -0
  44. rtctools/rtctoolsapp.py +131 -0
  45. rtctools/simulation/__init__.py +0 -0
  46. rtctools/simulation/csv_mixin.py +171 -0
  47. rtctools/simulation/io_mixin.py +195 -0
  48. rtctools/simulation/pi_mixin.py +255 -0
  49. rtctools/simulation/simulation_problem.py +1293 -0
  50. 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)