OpenDHW 0.1__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.
OpenDHW/OpenDHW.py ADDED
@@ -0,0 +1,1608 @@
1
+ # -*- coding: utf-8 -*-
2
+ import numpy as np
3
+ import pandas as pd
4
+ import matplotlib.pyplot as plt
5
+ import seaborn as sns
6
+ from pathlib import Path
7
+ import math
8
+ import statistics
9
+ import random
10
+ import scipy
11
+ from scipy.stats import beta
12
+ import matplotlib.dates as mdates
13
+
14
+ """
15
+ This is the script that stores all function of the DHWcalc package.
16
+ It is not meant to be executed on its own, but rather a toolbox for building
17
+ small scripts. Examples are given in OpenDHW/Examples.
18
+
19
+ OpenDHW is mostly built on Pandas, a good introduction is given here:
20
+ https://pandas.pydata.org/pandas-docs/stable/user_guide/10min.html
21
+ https://pandas.pydata.org/pandas-docs/stable/user_guide/timeseries.html
22
+
23
+ OpenDHW_Utilities stores a few other functions that do not generate DHW
24
+ Timeseries directly, like the StorageLoad Function.
25
+ """
26
+
27
+ # RWTH colours
28
+ rwth_blue = "#00549F"
29
+ rwth_red = "#CC071E"
30
+ # sns.set_style("white")
31
+ sns.set_context("paper")
32
+
33
+ # --- Constants ---
34
+ rho = 980 / 1000 # kg/L for Water (at 60°C? at 10°C its = 1)
35
+ cp = 4180 # J/kgK
36
+
37
+
38
+ def import_from_dhwcalc(s_step, daylight_saving, categories,
39
+ mean_drawoff_vol_per_day=200, max_flowrate=1200):
40
+ """
41
+ DHWcalc yields Volume Flow TimeSeries (in Liters per hour).
42
+
43
+ :param s_step: int: resolution of file in seconds
44
+ :param categories: int: either '1' or '4'
45
+ :param mean_drawoff_vol_per_day: int: daily water demand in Liters
46
+ :param daylight_saving: Bool: apply daylight saving or not
47
+ :param max_flowrate: int: maximum water flowrate in L/h
48
+
49
+ :return timeseries_df: df: dataframe that holds the data
50
+ """
51
+
52
+ if daylight_saving:
53
+ ds_string = 'ds'
54
+ else:
55
+ ds_string = 'nods'
56
+
57
+ # --- DHWcalc result files, saved in the OpenDHW Package
58
+ dhw_file = "{vol}L_{s_step}min_{cats}cat_sf_{ds}_max{max_flow}.txt".format(
59
+ vol=mean_drawoff_vol_per_day,
60
+ s_step=int(s_step / 60),
61
+ cats=categories,
62
+ ds=ds_string,
63
+ max_flow=max_flowrate,
64
+ )
65
+
66
+ dhw_profile = Path.cwd().parent / "DHWcalc_Files" / dhw_file
67
+
68
+ assert dhw_profile.exists(), 'No DHWcalc File for the selected ' \
69
+ 'parameters: {}'.format(dhw_file)
70
+
71
+ # Flowrate in Liter per Hour in each Step
72
+ water_LperH = [int(word.strip('\n')) for word in
73
+ open(dhw_profile).readlines()] # L/h each step
74
+
75
+ date_range = pd.date_range(start='2019-01-01', end='2020-01-01',
76
+ freq=str(s_step) + 'S')
77
+ date_range = date_range[:-1]
78
+
79
+ # make dataframe
80
+ timeseries_df = pd.DataFrame(water_LperH, index=date_range, columns=[
81
+ 'Water_LperH'])
82
+
83
+ timeseries_df['Water_L'] = timeseries_df['Water_LperH'] / 3600 * s_step
84
+ timeseries_df['method'] = 'DHWcalc'
85
+ timeseries_df['mean_drawoff_vol_per_day'] = mean_drawoff_vol_per_day
86
+ timeseries_df['categories'] = categories
87
+ timeseries_df['initial_day'] = 0
88
+ timeseries_df['weekend_weekday_factor'] = 1.2
89
+ timeseries_df['sdtdev_drawoff_vol_per_day'] = mean_drawoff_vol_per_day / 4
90
+
91
+ if categories == 1:
92
+ mean_vol_per_drawoff = 8 # constant DHWcalc 1 category
93
+ timeseries_df['mean_vol_per_drawoff'] = 8
94
+
95
+ mean_drawoff_flow_rate_LperH = mean_vol_per_drawoff * 3600 / s_step
96
+ timeseries_df[
97
+ 'mean_drawoff_flow_rate_LperH'] = mean_drawoff_flow_rate_LperH
98
+
99
+ sdt_dev_drawoff_flow_rate = mean_drawoff_flow_rate_LperH / 4 # in L/h
100
+ timeseries_df[
101
+ 'sdtdev_drawoff_flow_rate_LperH'] = sdt_dev_drawoff_flow_rate
102
+
103
+ mean_no_drawoffs_per_day \
104
+ = mean_drawoff_vol_per_day / mean_vol_per_drawoff
105
+ timeseries_df['mean_no_drawoffs_per_day'] = mean_no_drawoffs_per_day
106
+
107
+ return timeseries_df
108
+
109
+
110
+ def generate_dhw_profile(s_step, categories, holidays, mean_drawoff_vol_per_day=200, weekend_weekday_factor=1.2, initial_day=0):
111
+ """
112
+ Generates a DHW profile. The generation is split up in different
113
+ functions and generally follows the methodology described in the DHWcalc
114
+ paper from Uni Kassel.
115
+
116
+ 1) Load some data for the drawoff categories (cats_df).
117
+ 2) Generate a yearly probability profile
118
+ 3) Generate Drawoffs and distribute them randomly into the probability
119
+ profile p_norm_integral.
120
+ 4) Add some additionally stats to the dataframe.
121
+
122
+ :param s_step: int: timestep width in seconds.
123
+ :param categories: int: 1 or 4 (see DHWcalc)
124
+ :param weekend_weekday_factor: int: taken from DHWcalc
125
+ :param mean_drawoff_vol_per_day: int: function of number of people in
126
+ the house of floor area.
127
+ :param initial_day: int: 0:Mon - 1:Tues ... 6:Sun
128
+ :return: timeseries_df df: dataframe with all timeseries
129
+ """
130
+
131
+ # --- holds statistic info about the drawoffs
132
+ cats_df = get_data_drawoff_categories(
133
+ s_step=s_step,
134
+ categories=categories,
135
+ mean_drawoff_vol_per_day=mean_drawoff_vol_per_day,
136
+ )
137
+
138
+ # --- deterministic function
139
+ timeseries_df = generate_yearly_probability_profile(
140
+ s_step=s_step,
141
+ weekend_weekday_factor=weekend_weekday_factor,
142
+ holidays = holidays,
143
+ initial_day=0,
144
+ )
145
+
146
+ # --- empty drawoffs list, will be filled afterwards
147
+ timeseries_df['Water_LperH'] = [0] * int(365 * 24 * 3600 / s_step)
148
+
149
+ # --- for each category, generate and distribute drawoffs.
150
+ for i in range(len(cats_df)):
151
+ timeseries_df = generate_and_distribute_drawoffs(
152
+ timeseries_df=timeseries_df,
153
+ cats_series=cats_df.iloc[i],
154
+ )
155
+
156
+ # --- add some additional stats
157
+ timeseries_df['Water_L'] = timeseries_df['Water_LperH'] / 3600 * s_step
158
+ timeseries_df['method'] = 'OpenDHW'
159
+ timeseries_df['categories'] = categories
160
+ timeseries_df['initial_day'] = initial_day
161
+ timeseries_df['weekend_weekday_factor'] = weekend_weekday_factor
162
+ timeseries_df['mean_drawoff_vol_per_day'] = mean_drawoff_vol_per_day
163
+
164
+ return timeseries_df
165
+
166
+
167
+ def get_data_drawoff_categories(s_step, categories, mean_drawoff_vol_per_day):
168
+ """
169
+ Get some data for each drawoff category. If only one category is chosen,
170
+ a simplified datafarme is returned.
171
+
172
+ :param s_step: int: seconds in a timestep. f.e 900
173
+ :param categories: int: 1 or 4, 1: short laod (washing hands, etc.), 2: medium load (dish-washer, etc.), 3: bath, 4:shower (see DHWcalc)
174
+ :param mean_drawoff_vol_per_day: int: volume per day used in house
175
+ :return: cats_df: df: Categores Data
176
+ """
177
+ if categories == 4:
178
+ cats_data_60 = {'mean_flow_rate_per_drawoff_LperH': [60, 360, 840, 480],
179
+ 'drawoff_duration_min': [1, 1, 10, 5],
180
+ 'portion': [0.14, 0.36, 0.1, 0.4],
181
+ 'stddev_flow_rate_per_drawoff_LperH': [120, 120, 12, 24],
182
+ 'min_flow_rate_per_drawoff_LperH': [1, 1, 1, 1]
183
+ }
184
+
185
+ cats_df = pd.DataFrame(data=cats_data_60)
186
+ # sort by duration distributes long drawoff types first.
187
+ # todo: second sort by portion, biggest portion distributed first
188
+ cats_df.sort_values(by=['drawoff_duration_min'], ascending=False,
189
+ inplace=True)
190
+
191
+ elif categories == 1:
192
+ cats_data_60 = {'mean_flow_rate_per_drawoff_LperH': [480],
193
+ 'drawoff_duration_min': [1],
194
+ 'portion': [1],
195
+ 'stddev_flow_rate_per_drawoff_LperH': [120],
196
+ 'min_flow_rate_per_drawoff_LperH': [6]
197
+ }
198
+
199
+ cats_df = pd.DataFrame(data=cats_data_60)
200
+ else:
201
+ raise Exception('unkown number of categories')
202
+
203
+ # if DHWcalc uses 4 categories with a timestep other than 60s,
204
+ # the drawoffs data has to be altered.
205
+ if s_step != 60:
206
+ cats_df['drawoff_duration_min_old'] = cats_df['drawoff_duration_min']
207
+
208
+ cats_df['drawoff_duration_min'] = int(s_step / 60)
209
+
210
+ cats_df['conversion_factor'] = cats_df['drawoff_duration_min'] / \
211
+ cats_df['drawoff_duration_min_old']
212
+
213
+ cats_df['mean_flow_rate_per_drawoff_LperH'] \
214
+ = cats_df['mean_flow_rate_per_drawoff_LperH'] / cats_df[
215
+ 'conversion_factor']
216
+ cats_df['stddev_flow_rate_per_drawoff_LperH'] = \
217
+ cats_df['stddev_flow_rate_per_drawoff_LperH'] / cats_df[
218
+ 'conversion_factor']
219
+
220
+ # add more data to the category dataframe.
221
+ cats_df['mean_vol_per_drawoff'] = \
222
+ cats_df['mean_flow_rate_per_drawoff_LperH'] \
223
+ / 60 * cats_df['drawoff_duration_min']
224
+
225
+ cats_df['mean_vol_per_day'] = mean_drawoff_vol_per_day * cats_df['portion']
226
+
227
+ cats_df['mean_vol_per_year'] = cats_df['mean_vol_per_day'] * 365
228
+
229
+ cats_df['mean_no_drawoffs_per_day'] = \
230
+ cats_df['mean_vol_per_day'] / cats_df['mean_vol_per_drawoff']
231
+
232
+ cats_df['mean_no_drawoffs_per_year'] = \
233
+ cats_df['mean_no_drawoffs_per_day'] * 365
234
+
235
+ # add max flow rate: Max(1200, highest category mean flow rate)
236
+ cats_df['max_flow_rate_per_drawoff_LperH'] \
237
+ = max(cats_df['mean_flow_rate_per_drawoff_LperH'].max(), 1200)
238
+
239
+ return cats_df
240
+
241
+ def generate_daily_probability_step_function(mode, s_step, save_fig=False,
242
+ test_concentrated_ps=False):
243
+ """
244
+ Generates probabilities for a day with 6 periods. Corresponds to the mode
245
+ "step function for weekdays and weekends" in DHWcalc and uses the same
246
+ standard values. Each Day starts at 0:00. Steps in hours. Sum of steps
247
+ has to be 24. Sum of probabilities has to be 1.
248
+
249
+ :param test_concentrated_ps: bool: different probabilities,
250
+ very concentrated in the morning
251
+ :param mode: string: weekday or weekend day
252
+ :param s_step: int: seconds within a timestep
253
+ :param save_fig: Bool: plot the probability distribution
254
+ :return: p_day list: distribution for one day.
255
+ """
256
+
257
+ # todo: add profiles for non-residential buildings, no more heavy periods
258
+ # in the morning and evening? different for every industry type? more
259
+ # during the night?
260
+
261
+ if s_step <= 1800:
262
+ if mode == 'work-day':
263
+ steps_and_ps = [(6.5, 0.01), (1, 0.5), (4.5, 0.06), (1, 0.16),
264
+ (5, 0.06), (4, 0.2), (2, 0.01)]
265
+
266
+ elif mode == 'off-day':
267
+ steps_and_ps = [(7, 0.02), (2, 0.475), (6, 0.071), (2, 0.237),
268
+ (3, 0.036), (3, 0.143), (1, 0.018)]
269
+
270
+ else:
271
+ raise Exception('Unknown Mode. Please Choose "work-day" or '
272
+ '"off-day".')
273
+ else:
274
+ # no more half-hourly steps
275
+ if mode == 'work-day':
276
+ steps_and_ps = [(7, 0.01), (1, 0.5), (4, 0.06), (1, 0.16),
277
+ (5, 0.06), (4, 0.2), (2, 0.01)]
278
+
279
+ elif mode == 'off-day':
280
+ steps_and_ps = [(7, 0.02), (2, 0.475), (6, 0.071), (2, 0.237),
281
+ (3, 0.036), (3, 0.143), (1, 0.018)]
282
+
283
+ else:
284
+ raise Exception('Unknown Mode. Please Choose "work-day" or '
285
+ '"off-day".')
286
+
287
+ if test_concentrated_ps:
288
+ # just as a test, if p is very concentrated, only 2 hours in the morning
289
+ steps_and_ps = [(7, 0), (2, 1), (15, 0)]
290
+
291
+ steps = [tup[0] for tup in steps_and_ps]
292
+ ps = [tup[1] for tup in steps_and_ps]
293
+
294
+ assert sum(steps) == 24
295
+ assert sum(ps) == 1
296
+
297
+ p_day = []
298
+
299
+ for tup in steps_and_ps:
300
+ p_lst = [tup[1] for _ in range(int(tup[0] * 3600 / s_step))]
301
+ p_day.extend(p_lst)
302
+
303
+ # check if length of daily intervals fits into the stepwidth.
304
+ assert len(p_day) == 24 * 3600 / s_step
305
+
306
+ if save_fig:
307
+ fig, ax = plt.subplots()
308
+ plt.plot(p_day)
309
+ plt.show()
310
+ dir_output = Path.cwd() / "plots"
311
+ dir_output.mkdir(exist_ok=True)
312
+ fname = "Daily_Probability_Profile_{}S_{}".format(s_step, mode)
313
+ fig.savefig(dir_output / (fname + '.pdf'))
314
+ fig.savefig(dir_output / (fname + '.svg'))
315
+ fig.savefig(dir_output / (fname + '.png'))
316
+
317
+ return p_day
318
+
319
+
320
+ def generate_yearly_probability_profile(s_step, holidays, weekend_weekday_factor=1.2,
321
+ initial_day=0):
322
+ """
323
+ generate a summed yearly probability profile. The whole function is
324
+ deterministic. The same inputs always produce the same outputs.
325
+
326
+ 1) Probabilities for weekdays and weekend-days are loaded (p_we, p_wd).
327
+ 2) Probability of weekend-days is increased relative to weekdays (shift).
328
+ 3) Based on an initial day, the yearly probability distribution (p_final)
329
+ is generated. The seasonal influence is modelled by a sine-function.
330
+ 4) p_final is normalized and integrated. The sum over the year is thus
331
+ equal to 1 (p_norm_integral).
332
+
333
+ :param s_step: int: seconds in a timestep
334
+ :param weekend_weekday_factor: float: shift probabilities towards weekend
335
+ :param initial_day: int: Mon: 0 ... Sun: 6
336
+ :return: timeseries_df: df: df that holds the yearly profile
337
+ """
338
+
339
+ # load daily probabilities (deterministic)
340
+ p_we = generate_daily_probability_step_function(
341
+ mode='off-day',
342
+ s_step=s_step,
343
+ )
344
+
345
+ p_wd = generate_daily_probability_step_function(
346
+ mode='work-day',
347
+ s_step=s_step,
348
+ )
349
+
350
+ # shift towards weekend (deterministic)
351
+ p_wd_weighted, p_we_weighted, av_p_week_weighted = shift_weekend_weekday(
352
+ p_work_day=p_wd,
353
+ p_off_day=p_we,
354
+ factor=weekend_weekday_factor
355
+ )
356
+
357
+ # yearly curve (deterministic)
358
+ p_final = generate_yearly_probabilities(
359
+ initial_day=initial_day,
360
+ p_off_day=p_we_weighted,
361
+ p_work_day=p_wd_weighted,
362
+ s_step=s_step,
363
+ holidays=holidays,
364
+ )
365
+
366
+ # sum and normalize to range between 0 and 1.
367
+ p_norm_integral = normalize_and_sum_list(lst=p_final)
368
+
369
+ # make timeseries dataframe and append the final list
370
+ date_range = pd.date_range(start='2019-01-01', end='2020-01-01',
371
+ freq=str(s_step) + 'S')
372
+ date_range = date_range[:-1]
373
+
374
+ timeseries_df = pd.DataFrame(index=date_range,
375
+ data={'p_norm_integral': p_norm_integral})
376
+
377
+ return timeseries_df
378
+
379
+
380
+ def shift_weekend_weekday(p_work_day, p_off_day, factor):
381
+ """
382
+ Shifts the probabilities between the weekday list and the weekend list by a
383
+ defined factor. If the factor is bigger than 1, the probability on the
384
+ weekend is increased. If its smaller than 1, the probability on the
385
+ weekend is decreased.
386
+
387
+ :param p_work_day: list: probabilities for 1 work day of the week [0...1]
388
+ :param p_off_day: list: probabilities for 1 off day of the week [0...1]
389
+ :param factor: float: factor to shift the probabilities between
390
+ weekdays and weekend-days
391
+ :return:
392
+ """
393
+
394
+ p_wd_factor = 1 / (5 / 7 + factor * 2 / 7)
395
+ p_we_factor = 1 / (1 / factor * 5 / 7 + 2 / 7)
396
+
397
+ assert p_wd_factor * 5 / 7 + p_we_factor * 2 / 7 == 1
398
+
399
+ p_wd_weighted = [p * p_we_factor for p in p_work_day]
400
+ p_we_weighted = [p * p_we_factor for p in p_off_day]
401
+
402
+ av_p_wd_weighted = statistics.mean(p_wd_weighted)
403
+ av_p_we_weighted = statistics.mean(p_we_weighted)
404
+
405
+ av_p_week_weighted = av_p_wd_weighted * 5 / 7 + av_p_we_weighted * 2 / 7
406
+
407
+ return p_wd_weighted, p_we_weighted, av_p_week_weighted
408
+
409
+
410
+ def generate_yearly_probabilities(initial_day, p_off_day, p_work_day,
411
+ s_step, holidays, plot_p_yearly=False):
412
+ """
413
+ Takes the probabilities of a working days and a off days and generates a
414
+ list of yearly probabilities by adding a seasonal probability factor.
415
+ The seasonal factor is a sine-function, like in DHWcalc.
416
+
417
+ :param initial_day: int: 0: Mon, 1: Tue, 2: Wed, 3: Thur, 4: Fri,
418
+ 5 : Sat, 6 : Sun
419
+ :param p_off_day: list: probabilities of an off day
420
+ :param p_work_day: list: probabilities of a working day
421
+ :param s_step: int: seconds within a timestep
422
+ :param plot_p_yearly: bool: plot the yearly probabilities
423
+
424
+ :return: p_final: list: probabilities of a full year
425
+ """
426
+
427
+ p_final = []
428
+ timesteps_day = int(24 * 3600 / s_step)
429
+
430
+ for day in range(365):
431
+
432
+ # Define if the day is a working day or not
433
+ if (day + initial_day) % 7 in (0, 6) or (day + initial_day) in holidays:
434
+ p_day = p_off_day
435
+ else:
436
+ p_day = p_work_day
437
+
438
+ # Compute seasonal factor
439
+ arg = math.pi * (2 / 365 * day - 1 / 4)
440
+ probability_season = 1 + 0.1 * np.cos(arg)
441
+
442
+ for step in range(timesteps_day):
443
+ probability = p_day[step] * probability_season
444
+ p_final.append(probability)
445
+
446
+ if plot_p_yearly:
447
+ fig, ax = plt.subplots()
448
+ plt.plot(p_final)
449
+ plt.show()
450
+ dir_output = Path.cwd() / "plots"
451
+ dir_output.mkdir(exist_ok=True)
452
+ fname = "Yearly_Probability_Profile_{}initalday_{}S".format(
453
+ initial_day, s_step)
454
+ fig.savefig(dir_output / (fname + '.pdf'))
455
+ fig.savefig(dir_output / (fname + '.svg'))
456
+ fig.savefig(dir_output / (fname + '.png'))
457
+
458
+ return p_final
459
+
460
+
461
+ def normalize_and_sum_list(lst, save_fig=False):
462
+ """
463
+ takes a list and normalizes it based on the sum of all list elements.
464
+ then generates a new list based on the current sum of each list entry.
465
+
466
+ :param lst: list: input list
467
+ :param save_fig: bool: plot the output list
468
+ :return: lst_norm_integral: list output list
469
+ """
470
+
471
+ sum_lst = sum(lst)
472
+ lst_norm = [float(i) / sum_lst for i in lst]
473
+
474
+ current_sum = 0
475
+ lst_norm_integral = []
476
+
477
+ for entry in lst_norm:
478
+ current_sum += entry
479
+ lst_norm_integral.append(current_sum)
480
+
481
+ if save_fig:
482
+ fig, ax = plt.subplots()
483
+ plt.plot(lst_norm_integral)
484
+ plt.show()
485
+ dir_output = Path.cwd() / "plots"
486
+ dir_output.mkdir(exist_ok=True)
487
+ fname = "Normed_and_summed_probability_profile"
488
+ fig.savefig(dir_output / (fname + '.pdf'))
489
+ fig.savefig(dir_output / (fname + '.svg'))
490
+ fig.savefig(dir_output / (fname + '.png'))
491
+
492
+ return lst_norm_integral
493
+
494
+
495
+ def generate_and_distribute_drawoffs(timeseries_df, cats_series):
496
+ """
497
+ generate and distribute drawoffs
498
+
499
+ :param timeseries_df: df: holds the timeseries
500
+ :param cats_series: series: constants for a category
501
+ """
502
+
503
+ # --- compute how many timesteps the drawoff occupies. some take more than 1
504
+ s_step = int(timeseries_df.index.freqstr[:-1])
505
+ drawoff_duration = cats_series['drawoff_duration_min'] * 60
506
+ drawoff_steps = int(drawoff_duration / s_step)
507
+
508
+ # --- generate drawoffs until V_max is reached ---
509
+ V_curr = 0
510
+ V_max = cats_series['mean_vol_per_year']
511
+ drawoffs = [] # L/h
512
+
513
+ while V_curr <= V_max:
514
+ drawoff = generate_single_drawoff_inside_boundaries(cats_series, s_step)
515
+ drawoffs.append(drawoff)
516
+
517
+ drawoff_L = drawoff / 3600 * s_step * drawoff_steps # L
518
+ V_curr += drawoff_L
519
+
520
+ # --- generate a probability for each drawoff ---
521
+ p_norm_integral = list(timeseries_df['p_norm_integral'])
522
+ min_rand = min(p_norm_integral)
523
+ max_rand = max(p_norm_integral)
524
+ p_drawoffs = [random.uniform(min_rand, max_rand) for _ in range(
525
+ len(drawoffs))]
526
+
527
+ # --- sort both lists for the distribution algorithm ---
528
+ p_drawoffs.sort()
529
+ p_norm_integral.sort()
530
+
531
+ # --- distribute drawoffs ---
532
+ water_LperH_cat = [0] * int(365 * 24 * 3600 / s_step)
533
+ water_LperH = list(timeseries_df['Water_LperH'])
534
+ max_flow_rate = cats_series['max_flow_rate_per_drawoff_LperH']
535
+
536
+ # counter for the drawoffs
537
+ drawoff_count = 0
538
+
539
+ # loop p_norm_integral and place drawoffs:
540
+ for time_step, p_current_sum in enumerate(p_norm_integral):
541
+
542
+ # dont place drawoff if timestep has already reached the max flowrate
543
+ if water_LperH[time_step] >= max_flow_rate:
544
+ continue
545
+
546
+ # if all drawoffs are palced, break the loop.
547
+ if drawoff_count >= len(drawoffs):
548
+ break
549
+
550
+ # if the looping of p_norm_integral results in surpassing the
551
+ # probability of the chosen drawoff, that drawoff might be placed at
552
+ # that timestep!
553
+ # This while loop allows for the possibility that two draw-offs from the same
554
+ # category can be added together at the same timestep if two consecutive elements
555
+ # (or even more) of p_drawoffs are lower than p_current_sum at this timestep.
556
+ while p_drawoffs[drawoff_count] < p_current_sum:
557
+
558
+ # if the drawoff event occupies more than one timestep,
559
+ # a list (drawoffs_time_step_delta) is placed, rather than a
560
+ # single number.
561
+ drawoff = drawoffs[drawoff_count]
562
+ drawoffs_time_step_delta = [drawoff] * drawoff_steps
563
+
564
+ # boolean, to count the drawoff events, but not the timesteps
565
+ # occupied by all drawoffs. this is needed when the drawoff
566
+ # event occupies more than one timestep.
567
+ drawoff_occured = True
568
+
569
+ for i in range(drawoff_steps):
570
+
571
+ # if the added drawoff surpasses the max flowrate in any of
572
+ # the possible timesteps it would occupy, it should not occur!
573
+ if water_LperH[time_step + i] + \
574
+ drawoffs_time_step_delta[i] > max_flow_rate:
575
+ drawoff_occured = False
576
+ break
577
+
578
+ if drawoff_occured:
579
+ for i in range(drawoff_steps):
580
+ # if the added drawoff would not surpass the max
581
+ # flowrate, add it to both return lists! for the
582
+ # category, and for the whole list.
583
+ water_LperH[time_step + i] \
584
+ += drawoffs_time_step_delta[i]
585
+ water_LperH_cat[time_step + i] \
586
+ += drawoffs_time_step_delta[i]
587
+
588
+ drawoff_count += 1
589
+
590
+ else:
591
+ break # break the while loop.
592
+
593
+ if drawoff_count >= len(drawoffs):
594
+ break
595
+
596
+ # update the sum of all categories
597
+ timeseries_df['Water_LperH'] = water_LperH
598
+
599
+ # write the drawoff list for the current category to the df
600
+ cat_id = int(cats_series['mean_flow_rate_per_drawoff_LperH'])
601
+ timeseries_df['Water_LperH_cat{}'.format(cat_id)] = water_LperH_cat
602
+
603
+ # compute the amount of water for the category
604
+ timeseries_df['Water_L_cat{}'.format(cat_id)] = \
605
+ timeseries_df['Water_LperH_cat{}'.format(cat_id)] * s_step / 3600
606
+
607
+ return timeseries_df
608
+
609
+
610
+ def generate_single_drawoff_inside_boundaries(cats_series, s_step):
611
+ """
612
+ From the data of one category, generate a drawoff inside the defined
613
+ boundaries, similar to DHWcalc.
614
+
615
+ :param cats_series: df: pandas series that holds the drawoff data
616
+ :param s_step: int: seconds in a timestep
617
+ :return: drawoff: int: drawoff eevnt in L/h
618
+ """
619
+
620
+ # --- get mean and stddev from series ---
621
+ mu = cats_series['mean_flow_rate_per_drawoff_LperH'] # in L/h
622
+ sig = cats_series['stddev_flow_rate_per_drawoff_LperH'] # in L/h
623
+
624
+ # --- generate drawoff
625
+ drawoff = random.gauss(mu, sig)
626
+
627
+ # --- get min and max allowed flowrate
628
+ max_drawoff_flow_rate = cats_series['max_flow_rate_per_drawoff_LperH']
629
+ min_drawoff_flow_rate = cats_series['min_flow_rate_per_drawoff_LperH']
630
+
631
+ # --- set boundaries for drawoff
632
+ low_lim = max(float(mu - 2 * sig), min_drawoff_flow_rate)
633
+ up_lim = min(float(mu + 2 * sig), max_drawoff_flow_rate)
634
+
635
+ # --- if drawoff is outside boundaries, generate it again until its inside.
636
+ while drawoff < low_lim or drawoff > up_lim:
637
+ drawoff = random.gauss(mu, sig)
638
+
639
+ # --- DHWcalc uses a fixed flow rate step width rather than floats.
640
+ if s_step == 60:
641
+ flow_rate_step = 6
642
+ else:
643
+ flow_rate_step = 1
644
+ drawoff = flow_rate_step * round(drawoff / flow_rate_step)
645
+
646
+ return drawoff # in L/h
647
+
648
+
649
+ def compute_heat(timeseries_df, temp_dT):
650
+ """
651
+ Add heat columns to the timeseries
652
+
653
+ :param timeseries_df: df: Pandas Dataframe with all the timeseries
654
+ :param temp_dT: int: temperature difference between freshwater
655
+ and average DHW outlet temperature.
656
+
657
+ :return: timeseries_df: df: Dataframe with added 'Heat' Column
658
+ """
659
+
660
+ timeseries_df['Heat_W'] = \
661
+ timeseries_df['Water_LperH'] / 3600 * rho * cp * temp_dT
662
+ timeseries_df['Heat_kW'] = timeseries_df['Heat_W'] / 1000
663
+
664
+ s_step = int(timeseries_df.index.freqstr[:-1])
665
+ timeseries_df['Heat_J'] = timeseries_df['Heat_W'] * s_step
666
+ timeseries_df['Heat_kWh'] = timeseries_df['Heat_J'] / (3600 * 1000)
667
+
668
+ return timeseries_df
669
+
670
+
671
+ def draw_lineplot(timeseries_df, plot_var='water', start_plot='2019-02-01',
672
+ end_plot='2019-02-05', save_fig=False):
673
+ """
674
+ Plots the timeseries for a given timedelta in a year.
675
+
676
+ :param timeseries_df: df: Dataframe that holds the timeseries.
677
+ :param plot_var: str: choose to plot Water or Heat series.
678
+ :param start_plot: str: start date of the plot. F.e. 2019-01-01
679
+ :param end_plot: str: end date of the plot. F.e. 2019-01-07
680
+ :param save_fig: bool: decide to save plots as pdf
681
+ """
682
+
683
+ fig, ax1 = plt.subplots()
684
+ fig.tight_layout()
685
+
686
+ if plot_var == 'water':
687
+ # make subset of dataframe for plotting
688
+ plot_df = timeseries_df[['Water_LperH', 'mean_drawoff_vol_per_day']]
689
+
690
+ ax1 = sns.lineplot(ax=ax1, data=plot_df[start_plot:end_plot],
691
+ linewidth=1.0, palette=[rwth_blue, rwth_red])
692
+
693
+ ax1.legend(loc="upper left")
694
+
695
+ title_str = make_title_str(timeseries_df=timeseries_df)
696
+ ax1.set_title(title_str)
697
+
698
+ if plot_var == 'heat':
699
+ # make subset of dataframe for plotting
700
+ plot_df = timeseries_df[['Heat_W']]
701
+
702
+ ax1 = sns.lineplot(ax=ax1, data=plot_df[start_plot:end_plot],
703
+ linewidth=1.0, palette=[rwth_red])
704
+
705
+ ax1.legend(loc="upper left")
706
+
707
+ # compute some stats for figure title.
708
+ # todo: add to make_title_str function for Heat plots.
709
+ max_water_flow = timeseries_df['Water_LperH'].max() # in L/h
710
+ s_step = timeseries_df.index.freqstr
711
+ method = timeseries_df['method'][0]
712
+
713
+ plt.title('Heat Time-series from {}, timestep = {}\n'
714
+ 'with a Peak of {:.1f} L/h'.format(method, s_step,
715
+ max_water_flow))
716
+
717
+ # set the x axis ticks
718
+ # https://matplotlib.org/3.1.1/gallery/ticks_and_spines/date_concise_formatter.html
719
+ locator = mdates.AutoDateLocator()
720
+ formatter = mdates.ConciseDateFormatter(locator)
721
+ ax1.xaxis.set_major_locator(locator)
722
+ ax1.xaxis.set_major_formatter(formatter)
723
+
724
+ plt.show()
725
+
726
+ if save_fig:
727
+ method = timeseries_df['method'][0]
728
+ s_step = get_s_step(timeseries_df)
729
+ vol_per_day = timeseries_df['mean_drawoff_vol_per_day'][0]
730
+ cats = timeseries_df['categories'][0]
731
+
732
+ dir_output = Path.cwd() / "plots"
733
+ dir_output.mkdir(exist_ok=True)
734
+
735
+ fname = "Lineplot_{}_{}S_{}LperDay_{}cats".format(
736
+ method, s_step, vol_per_day, cats)
737
+ fig.savefig(dir_output / (fname + '.pdf'))
738
+ fig.savefig(dir_output / (fname + '.svg'))
739
+ fig.savefig(dir_output / (fname + '.png'))
740
+
741
+
742
+ def draw_histplot(timeseries_df, extra_kde=False, save_fig=False):
743
+ """
744
+ Takes a DHW profile and plots a histogram with some stats in the title
745
+
746
+ :param save_fig: bool: save the figure
747
+ :param timeseries_df: df: Dataframe that holds the water timeseries
748
+ :param extra_kde: bool: plot a detailed kde plot behind the main
749
+ histogram.
750
+ """
751
+
752
+ # get non-zero values of the profile
753
+ drawoffs_df = get_drawoffs(timeseries_df=timeseries_df, remove_cats=False)
754
+
755
+ cats = timeseries_df['categories'][0]
756
+ if cats == 1:
757
+ drawoffs_df = drawoffs_df['Water_LperH']
758
+
759
+ fig, ax1 = plt.subplots()
760
+ ax2 = ax1.twinx()
761
+
762
+ # https://seaborn.pydata.org/generated/seaborn.histplot.html
763
+ sns.histplot(data=drawoffs_df, ax=ax2, stat='count', kde=True,
764
+ kde_kws={'bw_adjust': 1})
765
+
766
+ if extra_kde:
767
+ # https://seaborn.pydata.org/generated/seaborn.kdeplot.html
768
+ sns.kdeplot(data=drawoffs_df, ax=ax1, alpha=.05, bw_adjust=0.05,
769
+ legend=False, color='r')
770
+
771
+ # title
772
+ title_str = make_title_str(timeseries_df=timeseries_df)
773
+ ax1.set_title(title_str)
774
+
775
+ plt.show()
776
+
777
+ if save_fig:
778
+ method = timeseries_df['method'][0]
779
+ s_step = get_s_step(timeseries_df)
780
+ vol_per_day = timeseries_df['mean_drawoff_vol_per_day'][0]
781
+ cats = timeseries_df['categories'][0]
782
+
783
+ dir_output = Path.cwd() / "plots"
784
+ dir_output.mkdir(exist_ok=True)
785
+
786
+ fname = "Histplot_{}_{}S_{}LperDay_{}cats".format(
787
+ method, s_step, vol_per_day, cats)
788
+ fig.savefig(dir_output / (fname + '.pdf'))
789
+ fig.savefig(dir_output / (fname + '.svg'))
790
+ fig.savefig(dir_output / (fname + '.png'))
791
+
792
+
793
+ def draw_detailed_histplot(timeseries_df):
794
+ """
795
+ https://towardsdatascience.com/advanced-histogram-using-python-bceae288e715
796
+ plot to further analyse timeseries with 1 drawoff category.
797
+ """
798
+ cats = timeseries_df['categories'][0]
799
+ method = timeseries_df['method'][0]
800
+
801
+ if cats == 1 and method == 'DHWcalc':
802
+
803
+ # create bin values
804
+ mean = timeseries_df['mean_drawoff_flow_rate_LperH'][0]
805
+ sdtdev = timeseries_df['sdtdev_drawoff_flow_rate_LperH'][0]
806
+ non_zero_min = timeseries_df[timeseries_df['Water_LperH'] > 0][
807
+ 'Water_LperH'].min() # smallest entry that is not 0.
808
+
809
+ bin_values = [non_zero_min,
810
+ mean - 2 * sdtdev,
811
+ mean - sdtdev,
812
+ mean,
813
+ mean + sdtdev,
814
+ mean + 2 * sdtdev,
815
+ timeseries_df['Water_LperH'].max()]
816
+ bin_values = list(set(bin_values)) # remove double entries
817
+ bin_values.sort() # bins have to be sorted
818
+
819
+ # get non-zero values of the profile
820
+ drawoffs = timeseries_df[timeseries_df['Water_LperH'] != 0][
821
+ 'Water_LperH']
822
+
823
+ # Plot the Histogram from the random data
824
+ fig, (ax) = plt.subplots()
825
+
826
+ # counts: count of data ponts for each bin/column in the histogram
827
+ # bins: bin edge/range values
828
+ # patches: list of Patch objects. each Patch object contains a
829
+ # Rectangle object. e.g. Rectangle(xy=(-2.51953, 0), width=0.501013,
830
+ # height=3, angle=0)
831
+ counts, bins, patches = ax.hist(drawoffs, bins=bin_values,
832
+ edgecolor='black')
833
+
834
+ # Set the ticks to be at the edges of the bins.
835
+ ax.set_xticks(bins.round(2))
836
+
837
+ # Set the graph title and axes titles
838
+ plt.ylabel('Count')
839
+ plt.xlabel('Flowrate L/h')
840
+
841
+ # Calculate bar centre to display the count of data points and %
842
+ bin_x_centers = 0.5 * np.diff(bins) + bins[:-1]
843
+ bin_y_centers = ax.get_yticks()[1] * 0.25
844
+
845
+ # Display the the count of data points and % for each bar in histogram
846
+ for i in range(len(bins) - 1):
847
+ bin_label = "{0:,}".format(counts[i]) + " ({0:.2f}%)".format(
848
+ (counts[i] / counts.sum()) * 100)
849
+ plt.text(bin_x_centers[i], bin_y_centers, bin_label, rotation=90,
850
+ rotation_mode='anchor')
851
+
852
+ # Display the graph
853
+ plt.show()
854
+
855
+ else:
856
+ print('detailed histplot is only meant to analyse DHWcalc timeseries '
857
+ 'with one drawoff category.')
858
+
859
+
860
+ def add_additional_runs(timeseries_df, holidays, total_runs=5, dir_output=None):
861
+ """
862
+ method to add more runs to a timeseries dataframe with the same input
863
+ parameters as the original timeseries.
864
+
865
+ :param timeseries_df:
866
+ :param total_runs:
867
+ :param dir_output:
868
+ :return:
869
+ """
870
+ added_runs = total_runs - 1
871
+
872
+ s_step = int(timeseries_df.index.freqstr[:-1])
873
+ mean_drawoff_vol_per_day = timeseries_df['mean_drawoff_vol_per_day'][0]
874
+ weekend_weekday_factor = timeseries_df['weekend_weekday_factor'][0]
875
+ initial_day = timeseries_df['initial_day'][0]
876
+ method = timeseries_df['method'][0]
877
+ categories = timeseries_df['categories'][0]
878
+
879
+ if method == 'OpenDHW':
880
+
881
+ for run in range(added_runs):
882
+ extra_timeseries_df = generate_dhw_profile(
883
+ s_step=s_step,
884
+ categories=categories,
885
+ holidays=holidays,
886
+ weekend_weekday_factor=weekend_weekday_factor,
887
+ mean_drawoff_vol_per_day=mean_drawoff_vol_per_day,
888
+ initial_day=initial_day
889
+ )
890
+
891
+ additional_profile = extra_timeseries_df['Water_LperH']
892
+ timeseries_df['Water_LperH_' + str(run)] = additional_profile
893
+
894
+ elif method == 'DHWcalc':
895
+
896
+ raise Exception('adding multiple plots for DWHcalc is not so useful, '
897
+ 'as DHWcalc does not work with a random seed!')
898
+
899
+ if dir_output is not None:
900
+ # set a name for the file
901
+ save_name = "{}_{}runs_{}L_{}min.csv".format(
902
+ method, total_runs, mean_drawoff_vol_per_day, int(s_step / 60))
903
+
904
+ # make a directory. if it already exists, no problem, just use it
905
+ dir_output.mkdir(exist_ok=True)
906
+
907
+ # save the dataframe in the folder as a csv with the chosen name
908
+ timeseries_df.to_csv(dir_output / save_name)
909
+
910
+ return timeseries_df
911
+
912
+
913
+ def get_drawoffs(timeseries_df, remove_cats=True):
914
+ """
915
+ get sorted drawoff events from a timeseries Dataframe.
916
+ """
917
+
918
+ # only columns that contain 'Water_LperH'
919
+ timeseries_df.columns = timeseries_df.columns.astype(str)
920
+ cols_bool_str = timeseries_df.columns.str.contains('Water_LperH')
921
+ water_LperH_df = timeseries_df.loc[:, cols_bool_str]
922
+
923
+ if remove_cats:
924
+ # not columns that contain 'cat'
925
+ cols_bool_str2 = water_LperH_df.columns.str.contains('cat')
926
+ cols_bool_str2 = [not i for i in cols_bool_str2]
927
+ water_LperH_df = water_LperH_df.loc[:, cols_bool_str2]
928
+
929
+ drawoffs_df = water_LperH_df.reset_index(drop=True)
930
+
931
+ for col_name in drawoffs_df.columns:
932
+ # From each column, get only values != 0.
933
+ drawoffs_series = water_LperH_df[water_LperH_df[col_name] != 0][
934
+ col_name]
935
+ drawoffs_lst = list(drawoffs_series)
936
+
937
+ # fill zero-values with NaN's
938
+ empty_cells_len = len(timeseries_df) - len(drawoffs_lst)
939
+ empty_cells_lst = [np.nan] * empty_cells_len
940
+ drawoffs_lst.extend(empty_cells_lst)
941
+ drawoffs_lst.sort()
942
+
943
+ # append to the drawoff dataframe
944
+ drawoffs_df[col_name] = drawoffs_lst
945
+
946
+ # Drop rows that have only NaN's as values
947
+ drawoffs_df = drawoffs_df.dropna(how='all')
948
+
949
+ return drawoffs_df
950
+
951
+
952
+ def plot_multiple_runs(timeseries_df, plot_demands_overlay=True,
953
+ start_plot='2019-02-01', end_plot='2019-02-02',
954
+ plot_hist=True, plot_kde=True):
955
+ """
956
+ This function should only be used when the 'add_additional_runs' function
957
+ has been used before.
958
+
959
+ :param timeseries_df: df: dataframe with timesieries
960
+ :param plot_demands_overlay: bool: plot lineplot
961
+ :param start_plot: str: start date
962
+ :param end_plot: str: end date
963
+ :param plot_hist: bool: plot histogram
964
+ :param plot_kde: bool: plot kde plot
965
+ """
966
+
967
+ drawoffs_df = get_drawoffs(timeseries_df=timeseries_df)
968
+
969
+ if plot_demands_overlay:
970
+ fig, ax1 = plt.subplots()
971
+ fig.tight_layout()
972
+
973
+ # only columns that contain 'Water_LperH'
974
+ cols_bool_str = timeseries_df.columns.str.contains('Water_LperH')
975
+ water_LperH_df = timeseries_df.loc[:, cols_bool_str]
976
+
977
+ # not columns that contrain 'cats'
978
+ cols_bool_str2 = water_LperH_df.columns.str.contains('cat')
979
+ cols_bool_str2 = [not i for i in cols_bool_str2]
980
+ water_LperH_df = water_LperH_df.loc[:, cols_bool_str2]
981
+
982
+ ax1 = sns.lineplot(ax=ax1, data=water_LperH_df[start_plot:end_plot],
983
+ linewidth=0.5, legend=False)
984
+
985
+ # set beautiful x axis ticks for datetime
986
+ # https://matplotlib.org/3.1.1/gallery/ticks_and_spines/date_concise_formatter.html
987
+ locator = mdates.AutoDateLocator()
988
+ formatter = mdates.ConciseDateFormatter(locator)
989
+ ax1.xaxis.set_major_locator(locator)
990
+ ax1.xaxis.set_major_formatter(formatter)
991
+
992
+ plt.show()
993
+
994
+ if plot_hist:
995
+ sns.histplot(data=drawoffs_df, kde=False, element="step", fill=False,
996
+ stat='count', line_kws={'alpha': 0.8, 'linewidth': 0.9})
997
+
998
+ title_str = make_title_str(timeseries_df)
999
+ plt.title(title_str)
1000
+
1001
+ plt.show()
1002
+
1003
+ if plot_kde:
1004
+ sns.kdeplot(data=drawoffs_df, bw_adjust=0.1, alpha=0.5, fill=False,
1005
+ linewidth=0.5, legend=True)
1006
+
1007
+ title_str = make_title_str(timeseries_df)
1008
+ plt.title(title_str)
1009
+
1010
+ plt.show()
1011
+
1012
+
1013
+ def plot_multiple_timeseries(timeseries_lst, col_part='Water_LperH',
1014
+ plot_demands_overlay=True,
1015
+ start_plot='2019-02-01', end_plot='2019-02-02',
1016
+ plot_hist=True, plot_kde=True):
1017
+ """
1018
+ plots multiple timeseries given in a list. better than "plot multiple runs?"
1019
+
1020
+ :param timeseries_lst: list: list with timeseries dataframes
1021
+ :param col_part: str: string that matches colum names
1022
+ which should be plotted
1023
+ :param plot_demands_overlay: bool: plot lineplot of all dfs
1024
+ :param start_plot: str: start of lineplot
1025
+ :param end_plot: str: end of lineplot
1026
+ :param plot_hist: bool: plot histogram
1027
+ :param plot_kde: bool: plot kde plot
1028
+ :return:
1029
+ """
1030
+
1031
+ # get the index column of one timeseries and use it to make a plot df.
1032
+ plot_index = timeseries_lst[0].index
1033
+ plot_df = pd.DataFrame(index=plot_index)
1034
+
1035
+ for i, df in enumerate(timeseries_lst):
1036
+ # get colum names
1037
+ cols_LperH = [name for name in list(df.columns) if col_part in name]
1038
+
1039
+ # the timeseries_df should only have 1 column that matches the
1040
+ # desired string. more are not implemented yet
1041
+ assert len(cols_LperH) <= 1
1042
+
1043
+ # fill the plot dataframe with the matching column
1044
+ plot_df[i] = df[cols_LperH]
1045
+
1046
+ drawoffs_df = get_drawoffs(timeseries_df=plot_df)
1047
+
1048
+ if plot_demands_overlay:
1049
+ fig, ax1 = plt.subplots()
1050
+ fig.tight_layout()
1051
+
1052
+ ax1 = sns.lineplot(ax=ax1, data=plot_df[start_plot:end_plot],
1053
+ linewidth=0.5, legend=True)
1054
+
1055
+ # set beautiful x axis ticks for datetime
1056
+ # https://matplotlib.org/3.1.1/gallery/ticks_and_spines/date_concise_formatter.html
1057
+ locator = mdates.AutoDateLocator()
1058
+ formatter = mdates.ConciseDateFormatter(locator)
1059
+ ax1.xaxis.set_major_locator(locator)
1060
+ ax1.xaxis.set_major_formatter(formatter)
1061
+
1062
+ plt.show()
1063
+
1064
+ if plot_hist:
1065
+ sns.histplot(data=drawoffs_df, kde=True, element="step", fill=False,
1066
+ stat='count', line_kws={'alpha': 0.8, 'linewidth': 0.9})
1067
+ plt.show()
1068
+
1069
+ if plot_kde:
1070
+ sns.kdeplot(data=drawoffs_df, bw_adjust=0.1, alpha=0.5, fill=False,
1071
+ linewidth=0.5, legend=True)
1072
+ plt.show()
1073
+
1074
+
1075
+ def compare_generators(timeseries_df_1, timeseries_df_2,
1076
+ start_plot='2019-03-01', end_plot='2019-03-08',
1077
+ plot_date_slice=True, plot_distribution=True,
1078
+ plot_detailed_distribution=True, save_fig=False):
1079
+ """
1080
+ Compares two timeseries by plotting them next to each other with the same
1081
+ x and y axis limits.
1082
+
1083
+ :param timeseries_df_1: df: first timeseries dataframe
1084
+ :param timeseries_df_2: df: second timeseries dataframe
1085
+ :param start_plot: str: date, f.e. 2019-03-01
1086
+ :param end_plot: str: date, f.e. 2019-03-08
1087
+ :param plot_date_slice: bool: plot lineplots
1088
+ :param plot_distribution: bool: plot histplots
1089
+ :param plot_detailed_distribution: bool: plot detailed histplots
1090
+ :param save_fig: bool: save the plot
1091
+ """
1092
+
1093
+ cats_1 = timeseries_df_1['categories'][0]
1094
+ cats_2 = timeseries_df_2['categories'][0]
1095
+ if cats_1 or cats_2 == 1:
1096
+ print("detailed distribution is designed to compare timeseries with "
1097
+ "one drawoff category")
1098
+ plot_detailed_distribution = False
1099
+
1100
+ # compute Stats for the title
1101
+ drawoffs_1 = timeseries_df_1[timeseries_df_1['Water_LperH'] != 0][
1102
+ 'Water_LperH']
1103
+
1104
+ drawoffs_2 = timeseries_df_2[timeseries_df_2['Water_LperH'] != 0][
1105
+ 'Water_LperH']
1106
+
1107
+ if plot_date_slice:
1108
+
1109
+ # make dataframe for plotting with seaborn
1110
+ plot_df_1 = timeseries_df_1[['Water_LperH', 'mean_drawoff_vol_per_day']]
1111
+ plot_df_2 = timeseries_df_2[['Water_LperH', 'mean_drawoff_vol_per_day']]
1112
+
1113
+ fig, (ax1, ax2) = plt.subplots(2, 1)
1114
+ fig.tight_layout()
1115
+
1116
+ # First Subplot
1117
+ ax1 = sns.lineplot(ax=ax1, data=plot_df_1[start_plot:end_plot],
1118
+ linewidth=1.0, palette=[rwth_blue, rwth_red])
1119
+
1120
+ title_str_1 = make_title_str(timeseries_df=timeseries_df_1)
1121
+ ax1.set_title(title_str_1)
1122
+
1123
+ ax1.legend(loc="upper left")
1124
+
1125
+ # Second Subplot
1126
+ ax2 = sns.lineplot(ax=ax2, data=plot_df_2[start_plot:end_plot],
1127
+ linewidth=1.0, palette=[rwth_blue, rwth_red])
1128
+
1129
+ title_str_2 = make_title_str(timeseries_df=timeseries_df_2)
1130
+ ax2.set_title(title_str_2)
1131
+
1132
+ ax2.legend(loc="upper left")
1133
+
1134
+ # --- set both aes to the same y limit ---
1135
+ ymin1, ymax1 = ax1.get_ylim()
1136
+ ymin2, ymax2 = ax2.get_ylim()
1137
+
1138
+ ymax_set = max(ymax1, ymax2)
1139
+
1140
+ ax1.set_ylim(ymin1, ymax_set)
1141
+ ax2.set_ylim(ymin2, ymax_set)
1142
+
1143
+ # --- beautiful x-ticks ---
1144
+ locator = mdates.AutoDateLocator()
1145
+ formatter = mdates.ConciseDateFormatter(locator)
1146
+ ax1.xaxis.set_major_locator(locator)
1147
+ ax1.xaxis.set_major_formatter(formatter)
1148
+ ax2.xaxis.set_major_locator(locator)
1149
+ ax2.xaxis.set_major_formatter(formatter)
1150
+
1151
+ plt.show()
1152
+
1153
+ if save_fig:
1154
+ dir_output = Path.cwd() / "plots"
1155
+ dir_output.mkdir(exist_ok=True)
1156
+
1157
+ fname = "Timeseries_Comparison_Lineplot"
1158
+ fig.savefig(dir_output / (fname + '.pdf'))
1159
+ fig.savefig(dir_output / (fname + '.svg'))
1160
+ fig.savefig(dir_output / (fname + '.png'))
1161
+
1162
+ if plot_distribution:
1163
+ # compute Jensen Shannon Distance
1164
+ distance = jensen_shannon_distance(q=timeseries_df_1['Water_LperH'],
1165
+ p=timeseries_df_2['Water_LperH'])
1166
+
1167
+ fig, (ax1, ax2) = plt.subplots(2, 1)
1168
+ fig.tight_layout()
1169
+
1170
+ # plot the distribution
1171
+ # https://seaborn.pydata.org/generated/seaborn.displot.html
1172
+ ax1 = sns.histplot(ax=ax1, data=drawoffs_1, kde=True)
1173
+ ax2 = sns.histplot(ax=ax2, data=drawoffs_2, kde=True)
1174
+
1175
+ # --- Set titles and Labels ---
1176
+ title_str_1 = make_title_str(timeseries_df=timeseries_df_1)
1177
+ title_str_1 = 'Jensen Shannon Distance = {:.4f} \n'.format(distance) \
1178
+ + title_str_1
1179
+ ax1.set_title(title_str_1)
1180
+
1181
+ ax1.set_ylabel('Count in a Year')
1182
+ ax1.set_xlabel('Flowrate [L/h]')
1183
+
1184
+ title_str_2 = make_title_str(timeseries_df=timeseries_df_2)
1185
+ ax2.set_title(title_str_2)
1186
+
1187
+ ax2.set_ylabel('Count in a Year')
1188
+ ax2.set_xlabel('Flowrate [L/h]')
1189
+
1190
+ # --- set both axes to the same y limit ---
1191
+ ymin1, ymax1 = ax1.get_ylim()
1192
+ ymin2, ymax2 = ax2.get_ylim()
1193
+
1194
+ ymax_set = max(ymax1, ymax2)
1195
+
1196
+ ax1.set_ylim(ymin1, ymax_set)
1197
+ ax2.set_ylim(ymin2, ymax_set)
1198
+
1199
+ # --- set both axes to the same x limit ---
1200
+ xmin1, xmax1 = ax1.get_xlim()
1201
+ xmin2, xmax2 = ax2.get_xlim()
1202
+
1203
+ xmax_set = max(xmax1, xmax2)
1204
+
1205
+ ax1.set_xlim(xmin1, xmax_set)
1206
+ ax2.set_xlim(xmin2, xmax_set)
1207
+
1208
+ plt.show()
1209
+
1210
+ if save_fig:
1211
+ dir_output = Path.cwd() / "plots"
1212
+ dir_output.mkdir(exist_ok=True)
1213
+
1214
+ fname = "Timeseries_Comparison_Histplot"
1215
+ fig.savefig(dir_output / (fname + '.pdf'))
1216
+ fig.savefig(dir_output / (fname + '.svg'))
1217
+ fig.savefig(dir_output / (fname + '.png'))
1218
+
1219
+ if plot_detailed_distribution:
1220
+
1221
+ # https://towardsdatascience.com/advanced-histogram-using-python-bceae288e715
1222
+
1223
+ # compute Jensen Shannon Distance
1224
+ distance = jensen_shannon_distance(q=timeseries_df_1['Water_LperH'],
1225
+ p=timeseries_df_2['Water_LperH'])
1226
+
1227
+ fig, axes = plt.subplots(2, 1)
1228
+ ax1 = axes[0]
1229
+ ax2 = axes[1]
1230
+ fig.tight_layout()
1231
+
1232
+ drawoffs_lst = [drawoffs_1, drawoffs_2]
1233
+
1234
+ # create bin values
1235
+ mean1 = timeseries_df_1['mean_drawoff_flow_rate_LperH'][0]
1236
+ sdtdev1 = timeseries_df_1['sdtdev_drawoff_flow_rate_LperH'][0]
1237
+ non_zero_min1 = timeseries_df_1[timeseries_df_1['Water_LperH'] > 0][
1238
+ 'Water_LperH'].min() # smallest entry that is not 0.
1239
+
1240
+ bin_values1 = [non_zero_min1,
1241
+ mean1 - 2 * sdtdev1,
1242
+ mean1 - sdtdev1,
1243
+ mean1,
1244
+ mean1 + sdtdev1,
1245
+ mean1 + 2 * sdtdev1,
1246
+ timeseries_df_1['Water_LperH'].max()]
1247
+ bin_values1 = list(set(bin_values1)) # remove double entries
1248
+ bin_values1.sort() # bins have to be sorted
1249
+
1250
+ mean2 = timeseries_df_2['mean_drawoff_flow_rate_LperH'][0]
1251
+ sdtdev2 = timeseries_df_2['sdtdev_drawoff_flow_rate_LperH'][0]
1252
+ non_zero_min2 = timeseries_df_2[timeseries_df_2['Water_LperH'] > 0][
1253
+ 'Water_LperH'].min() # smallest entry that is not 0.
1254
+
1255
+ bin_values2 = [non_zero_min2,
1256
+ mean2 - 2 * sdtdev2,
1257
+ mean2 - sdtdev2,
1258
+ mean2,
1259
+ mean2 + sdtdev2,
1260
+ mean2 + 2 * sdtdev2,
1261
+ timeseries_df_2['Water_LperH'].max()]
1262
+ bin_values2 = list(set(bin_values2)) # remove double entries
1263
+ bin_values2.sort() # bins have to be sorted
1264
+
1265
+ bin_values_lst = [bin_values1, bin_values2]
1266
+
1267
+ for sub_i, drawoffs_i in enumerate(drawoffs_lst):
1268
+
1269
+ ax = axes[sub_i]
1270
+
1271
+ counts, bins, patches = ax.hist(
1272
+ drawoffs_i, bins=bin_values_lst[sub_i], edgecolor='black')
1273
+
1274
+ # Set the ticks to be at the edges of the bins.
1275
+ ax.set_xticks(bins.round(2))
1276
+
1277
+ # Calculate bar centre to display the count of data points and %
1278
+ bin_x_centers = 0.1 * np.diff(bins) + bins[:-1]
1279
+ bin_y_centers = ax.get_yticks()[1] * 0.25
1280
+
1281
+ # Display the the count of data points and % for each bar in hist
1282
+ for i in range(len(bins) - 1):
1283
+ bin_label = str(int(counts[i])) + "\n{0:.2f}%".format(
1284
+ (counts[i] / counts.sum()) * 100)
1285
+ ax.text(bin_x_centers[i], bin_y_centers, bin_label, rotation=0)
1286
+
1287
+ title_str_1 = make_title_str(timeseries_df=timeseries_df_1)
1288
+ title_str_1 = 'Jensen Shannon Distance = {:.4f} \n'.format(distance) \
1289
+ + title_str_1
1290
+ ax1.set_title(title_str_1)
1291
+
1292
+ ax1.set_ylabel('Count in a Year')
1293
+
1294
+ title_str_2 = make_title_str(timeseries_df=timeseries_df_2)
1295
+ ax2.set_title(title_str_2)
1296
+
1297
+ ax2.set_ylabel('Count in a Year')
1298
+ ax2.set_xlabel('Flowrate [L/h]')
1299
+
1300
+ # --- set both aes to the same y limit ---
1301
+ ymin1, ymax1 = ax1.get_ylim()
1302
+ ymin2, ymax2 = ax2.get_ylim()
1303
+
1304
+ ymax_set = max(ymax1, ymax2)
1305
+
1306
+ ax1.set_ylim(ymin1, ymax_set)
1307
+ ax2.set_ylim(ymin2, ymax_set)
1308
+
1309
+ plt.show()
1310
+
1311
+
1312
+ def plot_three_histplots(timeseries_df_1, timeseries_df_2, timeseries_df_3):
1313
+ """
1314
+ Compares three timeseries by means of a triple subplot.
1315
+ :param timeseries_df_1: df: first time series
1316
+ :param timeseries_df_2: df: second time series
1317
+ :param timeseries_df_3: df: third time series
1318
+
1319
+ """
1320
+
1321
+ # compute Stats for the title
1322
+ drawoffs_1 = timeseries_df_1[timeseries_df_1['Water_LperH'] != 0][
1323
+ 'Water_LperH']
1324
+ drawoffs_2 = timeseries_df_2[timeseries_df_2['Water_LperH'] != 0][
1325
+ 'Water_LperH']
1326
+ drawoffs_3 = timeseries_df_3[timeseries_df_3['Water_LperH'] != 0][
1327
+ 'Water_LperH']
1328
+
1329
+ fig, (ax1, ax2, ax3) = plt.subplots(3, 1)
1330
+ fig.tight_layout()
1331
+
1332
+ # plot the distribution
1333
+ # https://seaborn.pydata.org/generated/seaborn.displot.html
1334
+ ax1 = sns.histplot(ax=ax1, data=drawoffs_1, kde=True)
1335
+ ax2 = sns.histplot(ax=ax2, data=drawoffs_2, kde=True)
1336
+ ax3 = sns.histplot(ax=ax3, data=drawoffs_3, kde=True)
1337
+
1338
+ # --- Set titles and Labels ---
1339
+ title_str_1 = make_title_str(timeseries_df=timeseries_df_1)
1340
+ ax1.set_title(title_str_1)
1341
+ ax1.set_ylabel('Count in a Year')
1342
+ ax1.set_xlabel('Flowrate [L/h]')
1343
+
1344
+ title_str_2 = make_title_str(timeseries_df=timeseries_df_2)
1345
+ ax2.set_title(title_str_2)
1346
+ ax2.set_ylabel('Count in a Year')
1347
+ ax2.set_xlabel('Flowrate [L/h]')
1348
+
1349
+ title_str_3 = make_title_str(timeseries_df=timeseries_df_3)
1350
+ ax3.set_title(title_str_3)
1351
+ ax3.set_ylabel('Count in a Year')
1352
+ ax3.set_xlabel('Flowrate [L/h]')
1353
+
1354
+ # --- set both axes to the same y limit ---
1355
+ ymin1, ymax1 = ax1.get_ylim()
1356
+ ymin2, ymax2 = ax2.get_ylim()
1357
+ ymin3, ymax3 = ax3.get_ylim()
1358
+
1359
+ ymax_set = max(ymax1, ymax2, ymax3)
1360
+
1361
+ ax1.set_ylim(ymin1, ymax_set)
1362
+ ax2.set_ylim(ymin2, ymax_set)
1363
+ ax3.set_ylim(ymin3, ymax_set)
1364
+
1365
+ # --- set both axes to the same x limit ---
1366
+ xmin1, xmax1 = ax1.get_xlim()
1367
+ xmin2, xmax2 = ax2.get_xlim()
1368
+ xmin3, xmax3 = ax3.get_xlim()
1369
+
1370
+ xmax_set = max(xmax1, xmax2, xmax3)
1371
+
1372
+ ax1.set_xlim(xmin1, xmax_set)
1373
+ ax2.set_xlim(xmin2, xmax_set)
1374
+ ax3.set_xlim(xmin3, xmax_set)
1375
+
1376
+ plt.show()
1377
+
1378
+
1379
+ def jensen_shannon_distance(p, q):
1380
+ """
1381
+ method to compute the Jenson-Shannon Distance between two probability
1382
+ distributions. 0 indicates that the two distributions are the same,
1383
+ and 1 would indicate that they are nowhere similar.
1384
+
1385
+ From https://medium.com/@sourcedexter/how-to-find-the-similarity-between-two-probability-distributions-using-python-a7546e90a08d
1386
+ """
1387
+
1388
+ # convert the vectors into numpy arrays in case that they aren't
1389
+ p = np.array(p)
1390
+ q = np.array(q)
1391
+
1392
+ # calculate m
1393
+ m = (p + q) / 2
1394
+
1395
+ # compute Jensen Shannon Divergence
1396
+ divergence = (scipy.stats.entropy(p, m) + scipy.stats.entropy(q, m)) / 2
1397
+
1398
+ # compute the Jensen Shannon Distance
1399
+ distance = np.sqrt(divergence)
1400
+
1401
+ return round(distance, 4)
1402
+
1403
+
1404
+ def get_s_step(timeseries_df):
1405
+ """
1406
+ get the seconds within a timestep from a pandas dataframe. When loading
1407
+ Dataframes from a csv, the index loses its 'freq' attribute. This is thus
1408
+ just a workaround when loading Timeseries from csv.
1409
+ """
1410
+
1411
+ try:
1412
+ s_step = int(timeseries_df.index.freqstr[:-1])
1413
+ # todo: why doesnt this work for Dataframes loaded from a csv?
1414
+
1415
+ except TypeError:
1416
+
1417
+ steps = len(timeseries_df)
1418
+ secs_in_year = 8760 * 60 * 60
1419
+ s_step = secs_in_year / steps
1420
+
1421
+ # check if s_step has no decimal points (should not be 60.01 f.e.)
1422
+ assert s_step % 1 == 0
1423
+ s_step = int(s_step)
1424
+
1425
+ return s_step
1426
+
1427
+
1428
+ def make_title_str(timeseries_df):
1429
+ """
1430
+ creates a title string based on the timeseries dataframe. The title
1431
+ string can then be used for a variety of plots.
1432
+ """
1433
+
1434
+ # compute additional stats for title
1435
+ s_step = get_s_step(timeseries_df)
1436
+ yearly_water_demand = timeseries_df['Water_L'].sum() # in L
1437
+ drawoffs = timeseries_df[timeseries_df['Water_LperH'] != 0]['Water_LperH']
1438
+ max_water_flow = timeseries_df['Water_LperH'].max()
1439
+ method = timeseries_df['method'][0]
1440
+ cats = timeseries_df['categories'][0]
1441
+
1442
+ if cats == 1:
1443
+ method = "{} ({} cat)".format(method, cats)
1444
+
1445
+ title_str = '{}, ∆t = {}, Yearly Demand = {:.1f} L \n' \
1446
+ 'No. Drawoffs = {}, Peak = {:.1f} L/h, ' \
1447
+ 'Mean = {:.1f} L/h, SdtDev = {:.1f} L/h'.format(
1448
+ method, s_step, yearly_water_demand, len(drawoffs), max_water_flow,
1449
+ drawoffs.mean(), drawoffs.std())
1450
+
1451
+ else: # f.e. four categories
1452
+
1453
+ if 'OpenDHW' in method:
1454
+
1455
+ method = "{} ({} cats)".format(method, cats)
1456
+
1457
+ col_names = list(timeseries_df.columns)
1458
+ cols_LperH = [name for name in col_names if 'Water_L_' in name]
1459
+ water_LperH_df = timeseries_df[cols_LperH]
1460
+
1461
+ cats_str = ''
1462
+ for col in cols_LperH:
1463
+ cat_sum = water_LperH_df[col].sum()
1464
+ cats_str += '{:.0f} L, '.format(cat_sum)
1465
+ cats_str = cats_str[:-2]
1466
+
1467
+ title_str = f'{method}, ∆t = {s_step}, No. Drawoffs =' \
1468
+ f' {len(drawoffs)}, Peak = {max_water_flow:.1f} L/h ' \
1469
+ f'\n Yearly Demand = {yearly_water_demand:.0f} L (=' \
1470
+ f' {cats_str})'
1471
+
1472
+ elif 'DHWcalc' in method:
1473
+
1474
+ method = "{} ({} cats)".format(method, cats)
1475
+
1476
+ title_str = f"{method}, ∆t = {s_step}, No. Drawoffs =" \
1477
+ f" {len(drawoffs)}, Peak = {max_water_flow:.1f} L/h " \
1478
+ f"\n Yearly Demand = {yearly_water_demand:.0f} L"
1479
+
1480
+ else:
1481
+ raise Exception("Unkown method, try 'OpenDHW' or 'DHWcalc'.")
1482
+
1483
+ return title_str
1484
+
1485
+
1486
+ def resample_water_series(timeseries_df, s_step_output):
1487
+ """
1488
+ Before resampling a dataframe, we have to choose which data has to be
1489
+ resampled in what way. some columns list constants, some list intensive
1490
+ properties (like L/h, kW) and some list extensive properties (Like
1491
+ Liters/kWh).
1492
+ Constants should stay the same, intensive properties should be averaged
1493
+ and extensive properties should be summed up.
1494
+
1495
+ :param timeseries_df: df: dataframe that holds the timeseries
1496
+ :param s_step_output: int: desired output seconds in a timestep
1497
+ :return: timeseries_df_re: df: resampled dataframe
1498
+ """
1499
+
1500
+ s_step_old = get_s_step(timeseries_df)
1501
+ conversion_factor = s_step_output / s_step_old
1502
+
1503
+ if conversion_factor != 1:
1504
+ # separate constants from variables
1505
+ cols_consts = list(timeseries_df.columns[timeseries_df.nunique() <= 1])
1506
+ cols_vars = list(timeseries_df.columns[timeseries_df.nunique() > 1])
1507
+
1508
+ # separate flows (intensive) from sums (extensive)
1509
+ cols_flows = [i for i in cols_vars if 'Lper' in i]
1510
+ cols_sums = [i for i in cols_vars if i not in cols_flows]
1511
+
1512
+ # separate constants that change with the timestep (intensive
1513
+ # properties)
1514
+ cols_const_flows = [i for i in cols_consts if 'Lper' in i]
1515
+ cols_consts = [i for i in cols_consts if i not in cols_const_flows]
1516
+
1517
+ # make new sub-dataframes
1518
+ timeseries_df_sum = timeseries_df[cols_sums]
1519
+ timeseries_df_flows = timeseries_df[cols_flows]
1520
+ timeseries_df_consts = timeseries_df[cols_consts]
1521
+ timeseries_df_const_flows = timeseries_df[cols_const_flows]
1522
+
1523
+ # resample them according to their physical properties
1524
+ rule = str(s_step_output) + 'S'
1525
+ timeseries_df_sum_re = timeseries_df_sum.resample(rule=rule).sum()
1526
+ timeseries_df_flows_re = timeseries_df_flows.resample(rule=rule).mean()
1527
+
1528
+ # cut the dataframe with the constant variables and update the index
1529
+ resampled_index = timeseries_df_sum_re.index
1530
+ timeseries_df_consts_cut = timeseries_df_consts[0:len(resampled_index)]
1531
+ timeseries_df_consts_re \
1532
+ = timeseries_df_consts_cut.set_index(resampled_index)
1533
+
1534
+ # update the constants that change with the timestep (intensive
1535
+ # properties) with the conversion factor
1536
+ timeseries_df_consts_flows_cut \
1537
+ = timeseries_df_const_flows[0:len(resampled_index)]
1538
+ timeseries_df_consts_flows_re \
1539
+ = timeseries_df_consts_flows_cut.set_index(resampled_index)
1540
+ timeseries_df_consts_flows_re \
1541
+ = timeseries_df_consts_flows_re / conversion_factor
1542
+
1543
+ timeseries_df_re = pd.concat(
1544
+ [timeseries_df_flows_re, timeseries_df_sum_re,
1545
+ timeseries_df_consts_re, timeseries_df_consts_flows_re],
1546
+ axis=1)
1547
+
1548
+ # add 'resampled' tag to method column
1549
+ timeseries_df_re['method'] = timeseries_df['method'][0] + ' (resampled)'
1550
+
1551
+ else:
1552
+ timeseries_df_re = timeseries_df
1553
+
1554
+ return timeseries_df_re
1555
+
1556
+
1557
+ def reduce_no_drawoffs(timeseries_df):
1558
+ """
1559
+ for some reason, DHWcalc still yields less yearly drawoffs than OpenDHW.
1560
+ In case the yearly water demand is higher in an OpenDHW timeseries than
1561
+ the expected one, this function removes some randomly selected drawoffs
1562
+ events with a small flowrate to reduce the yearly water demand until its
1563
+ just under the expected one and simultaneously decreasing the number of
1564
+ drawoffs.
1565
+
1566
+ :param timeseries_df: df: input dataframe
1567
+ :return: timeseries_df_cleaned: df output dataframe
1568
+ """
1569
+
1570
+ # get the expected yearly water demand
1571
+ expected_yearly_water = timeseries_df['mean_drawoff_vol_per_day'][0] * 365
1572
+ actual_yearly_water = timeseries_df['Water_L'].sum()
1573
+
1574
+ if expected_yearly_water < actual_yearly_water:
1575
+
1576
+ # select a cut off flow rate
1577
+ max_flow_rate = timeseries_df['Water_LperH'].max()
1578
+ min_flow_rate = \
1579
+ timeseries_df[timeseries_df['Water_LperH'] != 0].min()[
1580
+ 'Water_LperH']
1581
+ cut_off_flow_rate = max(min_flow_rate * 5, max_flow_rate / 200)
1582
+
1583
+ # shuffle df so random days are selected when iterated over.
1584
+ timeseries_df_shuffled = timeseries_df.sample(frac=1).reset_index(
1585
+ drop=False)
1586
+
1587
+ # loop over the shuffled timeseries and set some vales to 0.
1588
+ for i in timeseries_df_shuffled.index:
1589
+
1590
+ curr_sum = timeseries_df_shuffled['Water_L'].sum()
1591
+ if curr_sum <= expected_yearly_water:
1592
+ break
1593
+
1594
+ curr_flow_rate = timeseries_df_shuffled.loc[i, 'Water_LperH']
1595
+ if curr_flow_rate != 0 and curr_flow_rate < cut_off_flow_rate:
1596
+ timeseries_df_shuffled.loc[i, 'Water_L'] = 0
1597
+ timeseries_df_shuffled.loc[i, 'Water_LperH'] = 0
1598
+
1599
+ # un-shuffle df
1600
+ timeseries_df_shuffled = timeseries_df_shuffled.set_index('index')
1601
+ timeseries_df_cleaned = timeseries_df_shuffled.sort_index()
1602
+
1603
+ else:
1604
+ timeseries_df_cleaned = timeseries_df
1605
+ print('No drawoffs have neen reduced, as expected_yearly_water >= '
1606
+ 'actual_yearly_water')
1607
+
1608
+ return timeseries_df_cleaned